Compare commits

...
Author SHA1 Message Date
koka df51e17198 Release alpha.25 terrain, layered wetlands and native underground structures
Build Sanctuary / build (push) Canceled after 0s
2026-09-11 23:52:34 +02:00
koka e7d1f298b9 docs: record verified alpha.24 release and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-11 18:33:26 +02:00
koka 27d7cba93f feat(worldgen): add persistent aerial Sanctuary sites for alpha.24
Build Sanctuary / build (push) Canceled after 0s
2026-09-11 18:29:38 +02:00
koka fbefb2e6d6 feat: natural Sanctuary secrets and guaranteed expansions for alpha23.1 2026-09-11 15:41:07 +02:00
koka e654d565f6 feat: add native Blocodex and material census for alpha23 2026-09-10 19:28:18 +02:00
koka c9d9dab1b5 docs: define isolated Sanctuary landmarks before implementation 2026-09-10 18:15:52 +02:00
koka a633d4cac9 docs: record verified alpha22 release and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-10 11:54:17 +02:00
koka d6e0c144d0 feat: selective surface placement and independent underground core for alpha22
Build Sanctuary / build (push) Canceled after 0s
2026-09-10 11:52:24 +02:00
koka d462f01e2b docs: record verified alpha21 release and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-10 10:08:01 +02:00
koka bb63fab388 worldgen: sober palettes and connected industrial galleries for alpha21
Build Sanctuary / build (push) Canceled after 0s
2026-09-10 10:03:30 +02:00
koka ae4c6200db docs: record verified alpha20 release and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-10 01:21:33 +02:00
koka 0edac29dd8 feat: add detailed architecture for Sanctuary alpha20
Build Sanctuary / build (push) Canceled after 0s
2026-09-10 01:18:31 +02:00
koka 2facf6f722 docs: record verified alpha19 release and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 22:49:20 +02:00
koka c23e72b8e6 feat(worldgen): add reversible alpha19 heritage structures and routes
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 22:42:32 +02:00
341 changed files with 47828 additions and 136 deletions
+1
View File
@@ -1,5 +1,6 @@
.DS_Store
.gradle/
__pycache__/
**/build/
**/run/
**/out/
+90
View File
@@ -1,5 +1,95 @@
# Changelog
## 0.1.0-alpha.25 — 2026-09-11
- Nouveau générateur `sanctuary:island_v25` pour l’île initiale : massifs
dispersés de calcite, cavités reliées, refuges à champignons, lacs
souterrains deau et de lave selon la roche disponible. Les filons suivent
la profondeur relative ; les rivières cherchent des sorties vers le vide
sans imposer de cascade à chaque extrémité.
- Marais attachés aux eaux et berges existantes, en surface et sous l’île.
Plusieurs strates peuvent se superposer en X/Z, avec des limites verticales
propres, des sols humides et de la végétation locale. Les biomes des autres
étages et larrivée naturelle en surface restent préservés.
- Recherche conditionnelle dun village abandonné souterrain, dune mine et
de sa cabane, dune hutte et de salles à mobs. Les placements inadaptés
restent refusés ; ces structures ne sont pas garanties sur chaque graine.
Les secrets et les cinq lieux aériens restent soumis à leurs options.
- Escaliers du sanctuaire minéral raccordés et cadre central de blackstone
de 7 × 9 blocs, ouverture de 5 × 7, inactif. Aucun nouveau portail
fonctionnel ni nouvelle dimension nest activé.
- Monument vanilla garanti pour chaque nouvelle expansion océanique de génération 25
**acceptée**, de 512 ou 1 024 blocs, avec les structures Minecraft activées.
Son départ et ses pièces sont enregistrés avant réservation ; une recherche
infructueuse refuse la demande. La référence dune carte dexploration marque désormais son
unique chunk gagnant à sauvegarder, sans transformer une recherche en visite.
Les tailles 512 / 724 / 1 024 et Moyen par défaut sont conservés. Un **nouveau
monde Sanctuary** est nécessaire. Les essais portent sur des mondes neufs ;
la reprise des anciens mondes ne fait pas partie de cette livraison.
`check build` et `assemblePack` passent. Le JAR, le MRpack et le ZIP Prism
sont vérifiés localement ; leur publication est en cours.
Les preuves acquises, dont les six créations et six réouvertures de monuments,
sont détaillées avec leur périmètre dans les [essais alpha.25](docs/testing-alpha25.md).
Voir aussi le [contrat de génération](docs/generation-alpha25.md) et les
[mesures de performances](docs/performance-alpha25.md) : aucun gain global
de chargement nest établi. Essais natifs sur Mac ; aucun essai Windows natif
nest revendiqué. Lalpha.24 reste publiée ; aucune publication ou
synchronisation Prism de lalpha.25 nest annoncée à ce stade.
## 0.1.0-alpha.20 — 2026-09-10
- Passe architecturale sur les six bâtiments urbains, les quatorze anciennes
infrastructures, les espaces publics et les réseaux souterrains. Palettes
propres aux usages : chêne, bouleau, granit, diorite, andésite et tuf ;
fondations maçonnées en briques de pierre conservées.
- Angles coupés, baies en saillie, portiques, retraits, loggias, toitures
nervurées et verrières avec passage lumineux. Sols et plafonds détaillés,
corniches, encadrements, murets et blocs ciselés ; bordures de routes en
briques de tuf et accents ciselés.
- Vitres, barreaux, clôtures, murs bas et angles descaliers raccordés depuis
la géométrie complète de chaque pièce avant son découpage entre chunks.
- Nouveau codec `sanctuary:island_v20`, journal et pièces distincts. Les mondes
19 et antérieurs gardent leurs rendus et futurs chunks. Les plans de terrain,
dhydrologie et dimplantation restent ceux de la génération précédente.
Personnaliser conserve les trois tailles et les structures expérimentales.
`check build assemblePack` passe, avec huit GameTests généraux. Création et
réouverture de la ville et des infrastructures passent ; les rapports 19 restent
identiques aux témoins publiés. Option désactivée et Petit/graine42 passent.
JAR, MRpack et ZIP Prism vérifiés ; release et canal packwiz publiés. Deux
synchronisations isolées puis deux dans la même instance Prism passent, avec
925 fichiers personnels et réglages suivis conservés.
Voir le [catalogue illustré](docs/architecture-alpha20.md) et les
[vérifications et limites](docs/testing-alpha20.md). Les fonctions de la bêta
restent à construire ; aucun nouveau système économique nest activé.
## 0.1.0-alpha.19 — 2026-09-09
- Quatorze nouvelles infrastructures abandonnées sur l’île initiale, avec pièces,
accès, mobilier, secrets et outils vanilla associés. La présence varie selon
la graine et le relief ; les quatorze types ont été rencontrés en tests natifs.
- Voies de fret aux rails incomplets et chemins entre destinations réelles.
Le témoin Moyen, graine 0, contient treize lieux et sept liaisons, dont un
fret de 499 blocs entre dépôt et pompage. Tracés enregistrés par chunk.
- Option FR/EN « Structures expérimentales » dans le bouton natif Personnaliser,
trois tailles conservées. Codec `sanctuary:island_v19` et journal 19 ; les
anciens mondes gardent leurs plans et futurs chunks. Un nouveau monde avec
loption désactivée conserve le socle urbain sans les expériences 17/19.
- Commande opérateur `/sanctuary structures` pour relever les entrées.
La ville et ses sous-sols sont conservés ; les systèmes de la bêta restent
à construire.
`check build assemblePack` passe, dont les huit GameTests généraux. Les trois
formats sont vérifiés nativement ; le témoin moyen se rouvre avec ses 112 pièces,
13 coffres, 68 cibles accessibles et la modification témoin. Les identités 17
restent identiques à la release publiée. JAR, MRpack et ZIP Prism vérifiés ;
release et canal packwiz publiés. Deux synchronisations isolées puis deux dans
la même instance Prism passent, avec 767 fichiers personnels et réglages suivis
conservés. Voir [les preuves et limites](docs/testing-alpha19.md).
## 0.1.0-alpha.17 — 2026-09-09
- Prototype de trois réseaux oubliés sur l’île initiale : mines et ateliers,
+191 -8
View File
@@ -15,10 +15,192 @@ infrastructures qui permettront progressivement d'ouvrir de nouveaux continents.
Ce dépôt démarre la nouvelle base Fabric. La [vision complète](docs/vision.md)
conserve les intentions ; le [backlog](docs/backlog.md) suit les
tickets. Les systèmes d’économie, de progression collective et les dimensions
décrits dans la vision ne sont pas encore implémentés. **Lalpha.17 est publiée** :
trois familles de réseaux oubliés expérimentaux rejoignent la ville et ses grands
sous-sols sur les nouvelles îles de départ. Le canal packwiz et la même instance
Prism sont à jour ; voir [Distribution](docs/packwiz.md).
décrits dans la vision ne sont pas encore implémentés. **Lalpha.25 est construite et vérifiée localement** : elle retravaille les reliefs et les profondeurs de
Sanctuary Island. **Lalpha.24 reste la livraison publiée** sur le canal
stable ; aucune synchronisation Prism pour lalpha.25 nest encore annoncée. Voir
[les essais alpha.25](docs/testing-alpha25.md) et [la distribution](docs/packwiz.md).
## Alpha.25 — reliefs et profondeurs
L’île initiale reçoit des massifs dispersés de calcite, des cavités reliées
par des galeries sinueuses, des refuges à champignons et des propositions
de lacs souterrains deau et de lave. Leurs fonds et berges doivent tenir
dans la roche existante. Les minerais suivent la profondeur relative de
l’île ; les sorties de rivière peuvent souvrir sur ses flancs quand le
terrain sy prête.
Les marais suivent de vraies eaux calmes et leurs berges, **en surface et
sous l’île**, avec boue, argile et végétation locale. Plusieurs strates
peuvent occuper le même emplacement horizontal à des hauteurs différentes.
Leur biome reste dans chaque bande habitable, sans remplacer toute la
colonne de grottes ni créer un plan deau artificiel. Larrivée reste
naturelle en surface.
Village abandonné souterrain, mine, cabane de mineur, hutte et salles à mobs
sont recherchés **sans garantie de présence**. Les secrets et les cinq lieux
suspendus conservent leurs options de génération. Le sanctuaire minéral
reçoit des escaliers raccordés et un cadre central de blackstone inactif.
Avec les structures Minecraft activées, chaque nouvelle expansion océanique de génération 25
acceptée de **512 ou 1 024 blocs** prévoit un monument vanilla avant sa
réservation ; faute demplacement adapté, la demande est refusée.
Créer un **nouveau monde Sanctuary** pour `sanctuary:island_v25`. Les diamètres
512 / 724 / 1 024, **Moyen par défaut**, le bouton **Personnaliser**, la hauteur
de 384 blocs et les nuages sont conservés. Les essais portent sur des mondes
neufs ; la reprise des anciens mondes ne fait pas partie de cette livraison.
Le cadre minéral nouvre pas de nouvelle dimension.
`check build` et lassemblage passent sur Mac. Le JAR, le MRpack et le ZIP
Prism sont vérifiés ; la publication et la synchronisation sont en cours.
Aucune validation Windows native nest revendiquée. Le [contrat de génération](docs/generation-alpha25.md),
les [preuves et limites](docs/testing-alpha25.md) et les
[mesures de performances](docs/performance-alpha25.md) précisent la portée
de chaque résultat. Aucun gain global de chargement face à lalpha.24
nest établi.
## Alpha.24 — les lieux suspendus
La nouvelle génération `sanctuary:island_v24` ajoute une montgolfière, deux
navires marchands habités et deux îlots riches en minerais autour de Sanctuary.
Les îlots reçoivent de lherbe, des fleurs et des petits arbres ; exactement
lun des deux possède une cascade. Chaque lieu contient un coffre, enterré
dans les îlots. Il faut construire pour les atteindre et ramener les villageois.
Les cinq emplacements sont réservés dès la création du monde, même avant leur
exploration. Les expansions respectent ces réservations ; les anciennes
sauvegardes gardent leur génération. Créer un **nouveau monde Sanctuary** pour
les découvrir, avec les structures et loption expérimentale activées.
Les tailles 512 / 724 / 1 024 et le bouton natif **Personnaliser** restent
inchangés. Voir [le contrat](docs/generation-alpha24.md) et
[les vérifications](docs/testing-alpha24.md). Les quatorze scénarios de génération,
les 24 GameTests généraux, `check build assemblePack` et le MRpack passent sur
Mac. Le [MRpack alpha.24](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.24/Sanctuary-0.1.0-alpha.24.mrpack)
est publié, le canal packwiz et la même instance Prism sont synchronisés. Les
925 fichiers personnels et réglages suivis sont conservés. Les essais sont
réalisés sur Mac ; aucun essai Windows natif nest revendiqué.
## Alpha.23.1 — livraison locale vérifiée
Le [chantier de génération](docs/generation-alpha23.1.md) réunit le Blocodex, une
île initiale naturelle avec quelques secrets, le sanctuaire minéral, les océans
et le catalogue des structures vanilla. Chaque nouvelle expansion acceptée, avec
les structures activées, prévoit au moins une structure adaptée avant sa réservation.
Les familles absentes des régions entièrement vérifiées sont favorisées.
`check build`, `assemblePack`, les essais natifs et le MRpack passent sur Mac.
Voir les [vérifications et limites](docs/testing-alpha23.1.md). Créer un nouveau
monde Sanctuary pour `island_v23` ; les anciennes sauvegardes gardent leur génération.
La publication séparée de lalpha.23.1 était bloquée par les identifiants Git.
Ce socle est désormais inclus dans lalpha.24 publiée ci-dessus.
## Alpha.23 — Blocodex et socle commun de données, livraison locale
Le Blocodex fait partie de Sanctuary et souvre avec **B**, touche modifiable
dans les contrôles. Son catalogue permet de rechercher et filtrer les blocs
inconnus, vus, minés, déjà possédés, jetés ou posés. Le stock actuellement porté
est affiché séparément de la possession passée. Aucun objet ni permission
opérateur nest nécessaire.
La mémoire est personnelle et conservée côté serveur. Les compteurs de minage
et de jet viennent des statistiques Minecraft ; les observations et poses
commencent avec cette version. Les sauvegardes précédentes gardent leur terrain
et leur génération.
Le socle commun ajoute la distribution du terrain chargé par dimension et
chunk, ainsi que les quantités ditems dans les inventaires connectés, les
conteneurs chargés et au sol. La couverture et lactualité accompagnent chaque
relevé ; les coffres dont le butin nest pas encore généré restent intacts.
Les opérateurs disposent de `/sanctuary census`. Palettes de structures,
expansions et shop pourront réutiliser ces données côté serveur.
Des relevés par jour UTC des activités de minage, pose, ramassage, jet et
fabrication conservent des périodes de
lhistoire du serveur. Le [contrat Blocodex](docs/blocodex.md) précise les APIs,
la persistance et les exclusions. Shop, ballast, Backrooms et leurs règles de
progression restent à construire ; la direction narrative est conservée dans
la [cosmologie](docs/cosmologie.md). Le Blocodex initial est désormais inclus dans lalpha.24 publiée sur le
canal packwiz. `check build assemblePack`, les 24 GameTests natifs et
le client graphique connecté passent ; voir [les essais alpha.23](docs/testing-alpha23.md).
La suite est en [conception](docs/structures-conception.md) :
départ naturel, quelques lieux isolés, fonctions reproductibles et donjon majeur
à définir. Ce cahier accompagne la poursuite des systèmes après la génération ;
lalpha.24 est la livraison actuelle.
## Alpha.22 — trouver le bon terrain
La nouvelle génération préfère refuser une ville ou un bâtiment facultatif
qui ne trouve pas un emplacement adapté. Plus de ville forcée sur une réserve
artificielle ; moins de terrassement, des fondations proches de la roche et des
chemins qui suivent le relief. La grande traversée et la salle dexpansion sont
réservées en priorité et peuvent exister sans ville au-dessus. Les autres lieux
nont pas de quota garanti.
Créer un **nouveau monde Sanctuary** pour utiliser ces règles. Les tailles
512/724/1024 et le bouton natif **Personnaliser** restent inchangés ; les anciens
mondes conservent leurs générateurs. La vision retient désormais **Minecraft
comme archéologie dun autre Minecraft** : chaque ruine témoigne dune ancienne
solution, et de ceux qui lont construite puis ont disparu.
Les trois tailles natives, la réouverture, le cas expérimental OFF et la
compatibilité21 passent, ainsi que `check build assemblePack` et ses huit
GameTests généraux. Le [MRpack alpha.22](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.22/Sanctuary-0.1.0-alpha.22.mrpack)
est disponible, le canal packwiz et la même instance Prism sont synchronisés.
Les925 fichiers personnels et réglages suivis sont conservés. Voir
[la distribution](docs/packwiz.md).
## Alpha.21 — matériaux, quartiers et traversées
La ville revient à la pierre lisse, pierre, briques de pierre, andésite et chêne.
Le tuf accompagne surtout les chemins et les sous-sols. Les toits et ornements
sont raccordés à leurs appuis ; les pièces gardent leurs aménagements. La ville
recherche des petits quartiers séparés par du terrain naturel, avec des chemins
vers le cœur. Les lieux patrimoniaux recherchent davantage de raccords.
Loption expérimentale ajoute une recherche de galeries industrielles : chaussée
cinq blocs, deux trottoirs, salles et escaliers de surface. Les placements sur
pente sont assouplis et les destinations isolées sont prioritaires pour les chemins.
Créer un **nouveau monde Sanctuary** pour ce générateur21. Les anciens mondes
conservent leur génération. Les vérifications et coordonnées du
témoin sont consignées dans [les essais](docs/testing-alpha21.md).
Le [MRpack alpha.21](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.21/Sanctuary-0.1.0-alpha.21.mrpack)
est disponible. Le canal packwiz et la même instance Prism sont synchronisés ;
les 925 fichiers personnels et réglages suivis sont conservés. Les tests de
création/réouverture, le cas OFF et `check build assemblePack` passent.
## Alpha.20 — identité des bâtiments
La passe reprend les six bâtiments de ville, les quatorze infrastructures,
les parcs et les sous-sols. Bois de chêne et de bouleau, granit, diorite et tuf
identifient les usages ; biseaux, baies, corniches, portiques et verrières
donnent du relief aux volumes. Les routes reçoivent des bordures de briques
de tuf et des blocs ciselés. Les vitres et grilles se raccordent entre chunks.
Créer un **nouveau monde Sanctuary, Moyen, graine 0**, avec **Structures
expérimentales** activées pour retrouver le témoin. Les anciens mondes gardent
leur génération. Le bouton natif **Personnaliser** et ses trois tailles restent
identiques. Voir le [catalogue illustré](docs/architecture-alpha20.md) et les
[essais alpha.20](docs/testing-alpha20.md). `check build assemblePack`, les essais natifs et les artefacts sont vérifiés ;
la [release](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.20)
et le canal packwiz sont publiés. La même instance Prism est synchronisée
deux fois, avec 925 fichiers personnels et réglages suivis conservés.
## Alpha.19 — anciennes infrastructures
Le chantier ajoute une première passe des quatorze lieux du
[catalogue](docs/structures-audit.md), avec leurs accès, des petits butins vanilla
et des voies de fret ou chemins abandonnés entre destinations réelles.
Loption **Structures expérimentales** reste enregistrée à la création du monde.
La nouvelle génération garde les anciennes sauvegardes intactes. Aucun système
de progression, mailbox, caméra ou réacteur nest activé.
Les trois tailles sont vérifiées en génération native ; création et réouverture
conservent les structures et les coffres. `check build assemblePack` passe.
Pour voir le grand fret : **Moyen, graine 0**, option activée ;
`/sanctuary structures` liste les entrées. Voir [les preuves et la carte](docs/testing-alpha19.md).
La [release alpha.19](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.19)
et le canal packwiz sont publiés. La même instance Prism est synchronisée deux
fois, avec 767 fichiers personnels et réglages suivis conservés.
## Alpha.17 — prototype des réseaux oubliés
@@ -409,7 +591,7 @@ sera une mise à jour explicite, avec vérification des API et des sauvegardes.
| Fabric API | 0.160.0+26.3 |
| Fabric Loom | 1.17.20 |
| Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 |
| Sanctuary / pack | 0.1.0-alpha.17 |
| Sanctuary / pack | 0.1.0-alpha.25 — en validation locale |
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
@@ -433,12 +615,13 @@ cd sanctuary-beta
qui charge par défaut le nouveau générateur unifié Sanctuary dans un monde
de développement neuf. Les suites historiques utilisent explicitement
`-PsanctuaryUnifiedTests=false`.
Les scénarios couverts et les essais multijoueurs restant à effectuer sont
décrits dans [Validation](docs/testing.md).
Les scénarios actuels et leurs limites sont décrits dans les
[essais alpha.25](docs/testing-alpha25.md), complétés par le
[guide général de validation](docs/testing.md).
Résultats :
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.17.jar` : mod à installer avec
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.25.jar` : mod de développement à 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).
+115
View File
@@ -0,0 +1,115 @@
# Architecture de lalpha.20
Cette passe donne une identité visuelle aux lieux déjà planifiés : volumes,
façades, sols, plafonds et mobilier. Elle réutilise les emprises et les accès
certifiés des générations précédentes. Les bâtiments de ville et les sites
expérimentaux restent inscrits dans leurs parcelles de 17 × 17 blocs ; leurs
corniches et leurs retraits restent à lintérieur de cette limite.
Les nouveaux rendus demandent un **nouveau monde de génération 20**. Les mondes
19 et antérieurs conservent leurs bâtiments, y compris dans les futurs chunks.
Le bouton **Personnaliser**, les trois tailles et loption **Structures
expérimentales** restent disponibles. Le catalogue nimpose pas que chaque lieu
apparaisse sur chaque île.
![Sélection darchitectures produites par les rendus de lalpha.20](images/architecture20-overview.png)
*Vues des blocs réellement émis par les générateurs, affichés avec les modèles
Minecraft, sans terrain. Ce montage est une prévisualisation géométrique, pas
une capture du client en jeu ; il montre une sélection du catalogue.*
## Les six bâtiments de Lost City
Les étages conservent leurs plans alternés, leurs pièces et leurs escaliers.
Les sanitaires sont clos, les lits adossés à de vrais murs, et les lampes
reposent sur des blocs complets. Les aménagements de toit décrits ci-dessous
possèdent des points daccès vérifiés par le smoke du renderer.
| Fonction | Forme et façade | Espaces et détails construits |
| --- | --- | --- |
| Immeuble | Angles avant coupés, baies en saillie, montants de chêne écorcé et bandeaux de tuf. | Parquets alternant chêne et bouleau, logements avec cuisine et chambre ; jardinières, portique de bois et table sur le toit. |
| Hôtel | Portique et travées encadrées de diorite polie, garde-corps et bandeaux en bouleau ; pavillon de toit en retrait. | Réception, chambres, salons, sanitaires et buanderie ; petit local de linge et bancs sur le toit. |
| Bibliothèque | Grandes baies, angles coupés et lames verticales en bouleau écorcé ; lanterneau vitré avec ouverture à travers le toit et le plafond du dernier étage. | Rayonnages, archives, tables et lutrins ; sols délimitant les espaces de lecture et terrasse de lecture sur le toit. |
| Restaurant | Encadrements de granit poli, vitrines et auvents associant chêne et bouleau. | Salles à manger et cuisines réparties par étage, garde-manger et sanitaires ; tables sur le toit et sorties dextraction maçonnées. |
| Caserne | Piles épaisses de briques de tuf, soubassements ciselés et fenêtres grillagées. | Dortoirs, bureau de service, réserves d’équipement et sanitaires ; cage descalier coiffée dun pavillon accessible. |
| Hall | Travées monumentales, piliers ciselés, corniches de diorite et fronton de toit en gradins. | Billetterie, bureaux, salle avec scène sur deux niveaux, galerie protégée, salles de réunion et archives. |
## Les quatorze anciennes infrastructures
Chaque site conserve ses seuils, son accès de service et son coffre de découverte.
Les fonctions ci-dessous sont exprimées par les pièces et les blocs vanilla
présents ; elles ne constituent pas de nouveaux systèmes de progression.
| Lieu | Architecture et usage visibles |
| --- | --- |
| Gare de fret | Halle aux angles coupés, lanterneau vitré et long auvent de quai en chêne ; voie, salle dattente et petit bureau de signalisation. |
| Poste et centre de tri | Façade de granit, accents de diorite et toiture en retrait ; guichet, rangées de tonneaux, passage de tri et bureau. |
| Dépôt communal | Grand entrepôt nervuré de pierre, toiture industrielle à trois travées vitrées en dents de scie ; rails, chargement, caisses et mezzanine dinventaire. |
| Cartothèque dexpédition | Corps en bouleau, petites ailes différenciées et balcon arrière abrité ; table de cartographie, lutrin, archives et réserve. |
| Observatoire et météo | Tour à pans coupés, contreforts de diorite et couronne ouverte portée par des nervures ; deux volées descalier, salle dinstruments et plateforme dobservation. |
| Atelier ferroviaire | Large couverture à nervures et vitrages, piles de granit ; deux voies, fosse dentretien avec escalier, établi, fourneau, meule et petite réserve. |
| Station de pompage et château deau | Bâtiment bas, conduites figurées par des grilles et murets, cuve haute sur quatre piles ciselées ; galerie dentretien et réserve sèche. Leau reste dans la cuve. |
| Sous-station | Cour clôturée, supports et grilles autour des équipements, locaux bas à toiture nervurée ; commandes vanilla et tranchée dentretien accessible. |
| Serre et graineterie | Verrière haute rythmée par des nervures de bouleau ; cultures, deux points dirrigation contenus, allée de tuf et remise de rempotage. |
| Quai sur le vide | Platelage en chêne, garde-corps à piliers sculptés, abris bas et grue de bois à chaîne ; attente, débarcadère et réserve. |
| Relais radio et maison du gardien | Petite maison de chêne avec toiture à deux matières et cheminée ; antenne haute à traverses, plateforme accessible et chambre du gardien. |
| Club photo et chambre noire | Façade claire de diorite, corniche sculptée et toiture arrière en retrait ; salle du club, chambre noire, bacs et espace de séchage à chaînes. |
| Vestiaires et salles étranges | Entrée basse et étroite suivie dune salle haute à colonnes répétées ; casiers, sols carrelés, alcôve et petite pièce de service. |
| Refuge de chantier | Cabane surélevée sur piles ciselées, toiture inclinée et ossature inachevée ; couchage, établi, escalier extérieur et trappe avec échelle vers la réserve inférieure. |
Les voies de fret gardent leur ballast, leurs traverses, leurs courbes et leurs
rails manquants. Les chemins reçoivent des bordures de briques de tuf et des
accents ciselés ; leurs pentes et leurs destinations restent celles du plan
sauvegardé.
## Espaces publics et équipements extérieurs
| Espace | Détails présents en génération 20 |
| --- | --- |
| Rues et ponts | Chaussée dandésite polie, bordures de tuf poli, bandes de briques de tuf ponctuées de blocs ciselés ; marches sur les pentes, piles de garde-corps et grilles sur les ponts. Les tabliers najoutent pas de colonnes vers le vide. |
| Parc arboré | Allées en croix, bancs de chêne à accoudoirs minéraux, lampadaires et petite couronne de bouleau à côté des arbres existants. |
| Jardin | Quatre parterres bordés de diorite, angles de tuf sculpté, portique de bois et promenade centrale. |
| Aire de jeux | Bac à sable bordé de bouleau, portique de balançoire, plateforme de jeu avec marches et garde-corps, bancs et éclairage. |
| Place | Sol organisé en couronnes de diorite, granit et tuf ; fontaine sèche à coupe basse et piédestal sculpté. |
| Mémorial | Sol minéral à lignes de tuf, marches autour du socle, colonne mêlant tuf et briques de pierre sculptées. |
| Marché | Croisement piéton, quatre étals avec pieds ciselés, poteaux de chêne et couvertures alternant dalles de chêne et de bouleau. |
| Parking | Rampe et stationnements conservés ; piliers dandésite, assises ciselées, bandeau en tuf, grilles de ventilation et lampes de plafond. Il reste enterré seulement lorsque le sol est certifié. |
| Piscine extérieure | Bassin et marches immergées conservés, margelles de diorite, chaises longues et auvent de bouleau près des bacs de douche. |
| Excavation | Escalier central vers la fosse, barrières, piles sculptées, poutre sur appuis, établi et meule ; le chantier reste limité à la roche certifiée. |
## Sous la ville et réseaux oubliés
| Ensemble | Traitement architectural |
| --- | --- |
| Grande piscine couverte | Bassin, escaliers et colonnade conservés ; parois de calcite, bandeaux de diorite, chapiteaux ciselés et nervures hautes répétées. |
| Parkings souterrains | Plateformes et rampes conservées ; fondations maçonnées et bandeaux de tuf ponctués de motifs ciselés. |
| Grande salle sèche | Boiseries de chêne, assises de tuf, murs clairs, corniches et bancs de chêne ; le socle central reste sans machine de progression. |
| Égouts et sorties deau | Briques de tuf, motifs ciselés et corniches hautes ; les canaux, passerelles et écoulements existants sont conservés. |
| Passages et accès | Liserés de granit ou de tuf, seuils et accès verticaux conservés. |
| Mines, ateliers et réserves oubliés | Poutres de chêne, tuf, sols bordés de granit, établi et outillage vanilla ; rails, réserves et plateforme de travaux interrompus. |
| Archives et salles étranges | Parquets de bouleau, boiseries, rayonnages ; ailleurs, carrelages de calcite et diorite, colonnes et niches répétées. |
| Salles techniques oubliées | Nervures minérales, corniches et encadrements ciselés autour des seuils ; pompes figurées, grilles et postes de commande vanilla. |
## Matériaux et raccords
Les fondations des nouveaux bâtiments et chemins sont en **briques de pierre**.
La stone et la smooth stone restent présentes ; landésite, la diorite et le
granit différencient les sols et les usages. Le tuf existe brut, poli, en
briques et sous ses deux formes ciselées. Les dalles, escaliers inversés,
murets, grilles et trappes donnent de l’épaisseur aux corniches, aux appuis de
fenêtre et aux équipements. Le chêne et le bouleau apparaissent comme poteaux,
boiseries, parquets, charpentes et couvertures selon les lieux.
Le rendu 20 calcule les raccords des vitres, barreaux, clôtures, murets et
angles descaliers à partir de tous les blocs de la pièce, après sa rotation
et avant son découpage entre chunks. Une fenêtre peut ainsi rejoindre son
véritable encadrement, même lorsque le chargement découpe le bâtiment.
Les smokes des bâtiments de ville et des espaces extérieurs passent, notamment
pour les accès, les sanitaires, les lits, les lampes, les rampes et les bassins.
Les essais natifs de création et de réouverture de la ville et des infrastructures
passent ; leurs résultats et les contrôles de compatibilité sont suivis dans [les essais alpha.20](testing-alpha20.md).
Aucune caméra, mailbox, machine dexpansion ou production électrique dédiée
nest ajoutée par cette passe. Les meubles utilisables restent les blocs
vanilla, et les butins de découverte restent partagés et non renouvelés.
+24
View File
@@ -0,0 +1,24 @@
# Alpha.21 — palettes et couvertures
La ville revient à une base commune : pierre lisse, pierre, briques de pierre,
andésite et chêne. Les façades, seuils, corniches et verrières gardent leurs
formes ; les pièces conservent leurs fonctions et leur circulation. La serre
emploie le bouleau comme bois propre. Le tuf accompagne les routes et les
parties souterraines.
![Modèles des bâtiments alpha.21](images/architecture-alpha21-overview.png)
Ces vues sont calculées depuis les blocs produits par les renderers21 et les
modèles/textures de Minecraft 26.3-pre-2. Elles montrent des variantes choisies,
sans terrain, éclairage ni végétation de la partie. Ce ne sont pas des captures
du client et elles ne prouvent pas les raccords avec le relief.
Les couvertures de latelier, du dépôt, de la serre, du relais radio et du refuge
reposent sur des murs ou charpentes. Lancien plafond opaque sous leur nouvelle
couverture est retiré. Les vérifications contrôlent aussi les petits appuis des
excavations, piscines, rampes de garage et ornements.
![Coupe des couvertures](images/architecture-alpha21-roofs.png)
La seconde planche coupe volontairement les murs pour voir les dessous des toits.
Les détails de validation et la graine dessai sont dans [le protocole](testing-alpha21.md).
+214 -1
View File
@@ -717,6 +717,190 @@ Aucune structure, règle de butin, version binaire ou instance Prism modifiée.
La sélection des lieux et la réalisation des axes feront lobjet du chantier
suivant ; ce catalogue nest pas une liste dajouts tous validés.
### WG-22 — Anciennes infrastructures et liaisons — alpha.19
**Branche :** `codex/heritage-worldgen-alpha19`.
**Demande validée :** première passe de toutes les nouvelles structures du
catalogue WG-21 et de leurs liaisons avant la bêta, en conservant la possibilité
de revenir à une génération sans ce chantier.
- Ajouter les quatorze lieux avec architectures, entrées, pièces, mobilier et
petits butins vanilla associés à leur usage. Présence selon le terrain ;
relever les lieux réellement générés et donner les coordonnées des témoins.
- Planifier des voies de fret et chemins entre destinations réelles, avec
sections abandonnées lisibles, appuis et passages vérifiés. Rechercher les
raccords aux anciennes installations dans un budget borné.
- Garder une majorité naturelle à l’île ; conserver le terrain, lhydrologie,
la ville et les réseaux existants comme base. Première île uniquement.
- Nouveau codec 19, option expérimentale sauvegardée à la création du monde.
Option désactivée : socle urbain seul. Les mondes 17 et antérieurs gardent
leur génération, leurs pièces et leurs futurs chunks. Aucune conversion,
suppression de structures ou rétrogradation dune sauvegarde 19.
- Les bâtiments préparent les futurs services ; mailbox, deposit boxes,
caméra, réacteur, progression et récompenses personnelles restent pour la
bêta. Les coffres de cette passe sont partagés et utilisent du butin vanilla.
- Vérifier créations/réouvertures, option désactivée, anciens mondes, supports,
circulations et vrais placements. `check build assemblePack`, artefacts,
publication packwiz et mise à jour de la même instance Prism.
**État : livré en alpha.19 ; code, artefacts et synchronisation Prism vérifiés.** Voir
[les essais natifs et la carte des lieux](testing-alpha19.md). Version 19 demandée
explicitement ; aucune version 18 publiée
dans ce dépôt à louverture du chantier. Le catalogue est maintenant accepté
pour une première passe, sans imposer sa présence exhaustive sur chaque graine.
### WG-23 — Identité architecturale et blocs détaillés — alpha.20
**Branche :** `codex/architecture-alpha20`.
**Demande :** donner aux bâtiments une identité contemporaine lisible selon
leur fonction, corriger les vitres isolées et détailler formes, façades et
circulations. Employer du tuf, ses briques et variantes ciselées, du chêne,
du bouleau, du granit, de la diorite et de landésite ; conserver les fondations
en briques de pierre. Ajouter des blocs sculptés et du relief architectural.
- Reprendre les quatorze lieux expérimentaux, les bâtiments de Lost City,
leurs espaces extérieurs et leurs sous-sols ; palettes par usage, volumes
en retrait, biseaux, baies, corniches, liserés et mobilier adapté.
- Résoudre les connexions des vitres, grilles, clôtures et murs ainsi que les
angles descaliers depuis la géométrie complète, avant le découpage par chunk.
- Dessiner les bordures de routes avec les briques de tuf et le tuf ciselé,
en conservant la circulation et les tracés sauvegardés.
- Garder les plans de placement et les emprises certifiées ; génération 20
et types de pièces distincts. Les mondes 19 et antérieurs gardent leurs
rendus, y compris dans les futurs chunks. Aucune rénovation rétroactive.
- Garder le bouton natif Personnaliser, les trois tailles et loption des
structures expérimentales pour les nouveaux mondes.
- Vérifier les états réels des blocs, les accès, les supports, les coffres,
la création/réouverture et la compatibilité avec la version 19 publiée.
Livrer le pack 20 par le canal stable et synchroniser la même instance Prism.
**État : livré en alpha.20 ; code, artefacts et même instance Prism vérifiés.** Voir
[le contrat et les essais alpha.20](testing-alpha20.md).
### WG-24 — Palettes sobres, toits portés et galeries — alpha.21
**Branche :** `codex/palettes-reseaux-alpha21`.
Reprendre les extérieurs sans changer les plans intérieurs approuvés : smooth
stone, stone brick, stone, andésite et chêne dominants en ville ; un bois cohérent
par bâtiment unique et tuf surtout au sol et sous terre. Corriger les toits
suspendus et les anciens plafonds superposés, avec appuis vérifiables.
Étendre la ville par au plus deux petits quartiers séparés de terrain naturel,
reliés par des chemins praticables ; rapprocher les destinations patrimoniales
par un graphe de chemins. Ajouter des galeries industrielles abandonnées :
chaussée de cinq blocs, deux trottoirs, quelques grandes salles et deux accès
par escaliers depuis la surface, en respectant les eaux et structures existantes.
Ajouter des fronts rocheux et de petits filons intacts dans les galeries et
leurs salles de maintenance : ressources vanilla réellement minables, sans
régénération de butin ni remplacement des blocs déjà extraits. Fournir un MRpack
pour lessai séparé demandé.
Assouplir les refus de placement : admettre les pentes, des volumes partiellement
enfouis et des appuis adaptés ; rechercher des détours et prioriser les lieux
isolés pour les chemins. Garder une ville fragmentée sans socle géant.
Nouvelle génération21 réservée aux nouveaux mondes ; tous les codecs et rendus
20 et antérieurs restent disponibles. Tailles512/724/1024, Moyen par défaut ;
option expérimentale enregistrée. Aucun monde personnel modifié. Vérifier les
pièces natives, accès, connexions, supports, création/réouverture, ancien monde20,
puis livrer le pack stable21 et synchroniser la même instance Prism.
**État : livré en alpha.21 ; MRpack, release et canal packwiz publiés, même instance Prism synchronisée deux fois. Tests, sauvegardes et limites documentés dans `docs/testing-alpha21.md`.**
### WG-25 — Placement sélectif et archéologie — alpha.22
**Branche :** `codex/placement-selectif-alpha22`.
Annule pour les nouveaux mondes lassouplissement de WG-24. Chercher un terrain
vraiment adapté et accepter labsence dune structure plutôt que de forcer son
implantation. La ville ne reçoit plus de réservation artificielle de secours.
Bâtiments et chemins limitent terrassement et remblai. Exception explicite :
la grande traversée et la salle dexpansion sont des éléments de base, à placer
en priorité indépendamment de la ville ; les égouts peuvent être ailleurs.
Les galeries gardent leur génie civil et la salle reçoit un accès autonome.
Les recherches restent bornées.
Conserver les palettes, les intérieurs, les filons et le rendu21. Nouveau codec22
pour les nouveaux mondes, sans migration ni retouche des mondes précédents.
Vérifier ladmission sur les bons terrains, les refus sur les mauvais, la création
native, la réouverture et la compatibilité21. Livrer le MRpack et mettre à jour
le canal et la même instance Prism en conservant les données personnelles.
Inscrire dans la vision « Minecraft comme archéologie dun autre Minecraft » :
chaque ruine garde la trace dune solution ancienne. Les neuf personnages sont
ceux nommés dans la vision ; la découverte et les rôles restent à définir.
Ce cadre narratif ne constitue pas une nouvelle mécanique implémentée.
**État : livré en alpha.22 ; MRpack, release et canal packwiz publiés, même instance Prism synchronisée deux fois.** Voir [les essais22](testing-alpha22.md).
### WG-26 — Définir les lieux et leurs usages avant de reprendre le code
**Branche :** `codex/conception-territoire`.
Construire ensemble le [cahier de conception](structures-conception.md) de la
prochaine organisation de Sanctuary Island. La direction retenue est un départ
naturel, quelques lieux isolés et mystérieux, des fonctions reproductibles par
les joueurs et un donjon majeur nécessaire à la progression. Villes,
regroupements et autres structures restent en réserve. Linstallation
dexpansion peut être reconstruite ailleurs que dans la salle historique.
Le résultat attendu est une sélection précise, les règles dimplantation et de
découverte, des fiches de lieux avec utilité et architecture, le rôle du boss et
de son objet de quête, puis les besoins des machines qui déterminent la salle.
Distinguer décisions retenues, propositions et questions ouvertes. La sélection
actuelle et les principes sont consignés ; plans, nombres, distances, rencontres
et règles de progression restent à définir.
**État : conception en discussion ; aucun code, nouveau binaire ou déploiement.**
Ce ticket documentaire nincrémente pas les versions et ne clôture pas les
décisions encore ouvertes. Les critères de la prochaine implémentation seront
établis à partir du cahier validé.
### META-01 — Blocodex natif et socle commun de données — alpha.23
**Branche :** `codex/blocodex-natif`.
**Demande :** intégrer à Sanctuary une connaissance des blocs inconnus, vus,
minés, possédés, jetés et posés, puis la distribution du terrain et des stocks
accessibles. Les palettes, expansions et shop futurs doivent consulter un socle
commun, avec des périodes dactivité utilisables pour lhistoire du serveur.
- Mémoire personnelle par UUID et sauvegarde, commune aux dimensions ;
états indépendants et stock porté distinct de la possession historique.
- Minage et jet issus des statistiques vanilla ; suivi serveur des observations
et poses réellement réussies. Pas dinférence des poses depuis `used`.
- Catalogue natif accessible sans item ni droit opérateur, recherche, filtres,
pagination, aide FR/EN et touche configurable.
- Service de lecture et notifications pour les futurs consommateurs ;
aucune récompense ni restriction de recette ajoutée par ce ticket.
- Sélection de palettes pour les futures structures : critères de connaissance
combinables et familles de matériaux définies par tags, dont une base naturelle.
- Terrain chargé par dimension et chunk : comptage progressif de 16 sections
non vides par tick, couverture et actualité explicites, sans charger ni
régénérer de chunk absent.
- Stocks courants séparés entre joueurs connectés, conteneurs blocs,
conteneurs entités et objets au sol. Tables de butin laissées intactes ;
stocks hors couverture et contenus imbriqués explicitement exclus.
- API commune `WorldCensusService` et diagnostics opérateur `/sanctuary census`.
Le shop et les règles de consommation ou dexpansion ne sont pas activés.
- Collecte datée : unités minées, posées, ramassées, jetées et
fabriquées par jour UTC, avec persistance séparée et sauvegarde tous les
600 ticks. Ni reconstruction rétroactive ni journal transactionnel de ballast.
- Contrat additif de persistance v1 documenté dans [Blocodex](blocodex.md),
conservation des identifiants et refus d’écraser une mémoire invalide.
- Vérification des actions natives, de lisolement des joueurs, de la persistance,
du rendu et des messages réseau ; contrôle des catégories de stocks, des tables
de butin et de la couverture du terrain ; `check build assemblePack`.
**État : Blocodex, recensements et relevés datés implémentés et vérifiés localement.
Publication demandée, préparation des artefacts en cours.** `check build assemblePack`, les 24 GameTests natifs
et le client graphique avec connexion réelle passent. Voir
[les preuves alpha.23](testing-alpha23.md).
## Réserve de thèmes futurs
Ces thèmes servent à retrouver la vision, pas à demander leur implémentation immédiate. On en extrait un ticket seulement lorsqu'il devient utile au prochain incrément jouable.
@@ -729,7 +913,7 @@ Ces thèmes servent à retrouver la vision, pas à demander leur implémentation
| Économie | gemmes, mailbox, shop, offres horaires, bourse du navet (achat à la loterie du dimanche, revente au shop pendant la semaine), black market, catalogue, coffre-fort et drill |
| Groupes et métiers | couleurs, équipes temporaires, factions et slots liés aux prestiges, cloches et bannières, villageois et copper golems |
| Production et construction | convoyeurs, stockage, terminaux, ordinateur 8 bits, vein mining/building, plans et prefabs |
| Dimensions | cavernes, Alpha, Backrooms, indoors, salles secrètes et récupération des objets perdus |
| Dimensions et espaces | cavernes, Alpha ; Indoors artificiels intentionnels et référencés ; Backrooms orphelines liées à lhistoire économique, avec hébergement technique et récupération des objets perdus à articuler selon la [cosmologie](cosmologie.md) |
| Faune et combats | zombies, fantômes, baleine, creepers, poules rares, Mooblooms, noms, armes et explosifs |
| Mobilité | waystones, téléporteurs, ziplines, grappin, aéronefs, Magic Carpet et interactions physiques |
| Temps et histoire | temps réel, calendrier, événements, loterie, étoiles, constellations, cube et sept boules |
@@ -741,3 +925,32 @@ Ces thèmes servent à retrouver la vision, pas à demander leur implémentation
## Définition pratique d'un ticket terminé
Un ticket contient un résultat observable, un périmètre limité et des critères d'acceptation vérifiables. Sa conclusion indique le comportement livré, la version testée, les vérifications réellement exécutées et les limitations qui subsistent. Les nouveaux besoins découverts deviennent de nouveaux tickets plutôt que des ajouts implicites à tous les systèmes.
### WG-27 — Île naturelle, sanctuaire minéral et structures natives — alpha.23.1
**Implémenté et vérifié localement.** Branche `codex/natural-secrets-alpha23`,
depuis le Blocodex alpha.23. MRpack alpha.23.1 produit ; publication et
synchronisation Prism en attente de laccès Git.
Le [contrat](generation-alpha23.1.md) conserve les secrets, le hall et les voies,
retire les bâtiments Sanctuary ordinaires de l’île initiale, introduit le
sanctuaire minéral et les océans dexpansion. Le catalogue vanilla est conservé
et étendu, avec registre persistant et priorités figées. Chaque expansion doit
prévoir au moins une structure adaptée avant réservation, sans placement forcé.
Deep Dark et cités anciennes attendent les Cavernes.
Les [preuves de validation](testing-alpha23.1.md) séparent intentions, tests
exécutés et état de la distribution. Aucun boss, portail actif ou raid livré ici.
### WG-28 — Les lieux suspendus de Sanctuary — alpha.24
Branche `codex/aerial-sanctuary-alpha24`. [Contrat](generation-alpha24.md) :
une montgolfière, deux navires avec villageois vanilla, deux îlots riches avec
coffres enterrés et exactement une cascade. Sites planifiés avant expansion,
réservations locales, vrais départs de structures dans le vide et scopes
opérateur distincts. Les îlots ont une surface végétalisée, avec petits arbres,
herbes et fleurs. [Validation](testing-alpha24.md) réussie sur Mac : 14 scénarios,
écoulement et admission réels, 24 GameTests généraux, build et MRpack vérifiés.
Release alpha.24 publiée ; canal packwiz et instance Prism existante
synchronisés deux fois. Les 925 fichiers personnels et réglages suivis sont
conservés. Aucun essai Windows natif revendiqué.
+255
View File
@@ -0,0 +1,255 @@
# Blocodex natif — connaissances, matière et stocks
Ticket META-01, branche `codex/blocodex-natif`, cible Minecraft **26.3-pre-2**.
Le Blocodex appartient au mod Sanctuary. Il est disponible sans appareil,
recette ni déblocage. La mémoire personnelle, le recensement du terrain et
celui des stocks forment un socle commun aux futures palettes de structures,
règles dexpansion et règles du shop. Les relevés datés ajoutent la temporalité
nécessaire à une future lecture de lhistoire économique du serveur.
## Contrat des informations
La mémoire est personnelle : une identité de joueur (UUID), dans une sauvegarde
de serveur, partagée entre ses dimensions. Les indicateurs sont indépendants.
| Information | Sens |
| --- | --- |
| Inconnu | Aucune observation, possession, pose ni statistique de minage ou de jet. C'est un état calculé, pas une étape enregistrée. |
| Vu | Le premier bloc rencontré par le regard du joueur, à au plus 32 blocs, observé côté serveur tous les 5 ticks. Les obstacles arrêtent le rayon ; les chunks absents ne sont pas chargés. Ce relevé ne couvre pas chaque pixel du champ de vision. |
| Miné | Compteur vanilla `mined` du bloc. Ses règles sont conservées : notamment pas de minage créatif, ni de minage sans l'outil requis. |
| Déjà possédé | Le bloc, sous forme d'item, a été présent dans l'inventaire personnel ou sur le curseur d'un menu. Les statistiques vanilla de ramassage et de jet prouvent aussi une possession passée. |
| Jeté | Compteur vanilla `dropped` de l'item du bloc. Il compte les unités volontairement jetées, pas les objets éjectés à la mort ni le butin produit par le bloc cassé. |
| Posé | Nombre de placements réussis par le joueur avec un item de bloc, y compris en créatif. Une tentative refusée, une interaction ou un placement par commande ne compte pas. |
| En inventaire | Quantité actuellement dans l'inventaire personnel, équipement et curseur compris. Les coffres ouverts, ender chests et contenus imbriqués des boîtes ne sont pas inclus. |
Les blocs sont identifiés par leur identifiant de registre ; les propriétés
d'état (orientation, humidité, etc.) ne créent pas une nouvelle fiche. Deux
identifiants restent distincts : une torche murale peut être vue et posée tandis
que l'item possédé correspond à la torche debout. Les noms des blocs inconnus
restent consultables, comme un catalogue ; cette version ne décide pas d'une
politique de secrets ou de verrouillage des recettes.
L'inventaire est observé chaque tick serveur, après les transactions de menus
et avant la consommation d'un item placé. Les statistiques de ramassage/jet
complètent ce relevé. Une mutation d'inventaire entièrement réalisée entre ces
points par un autre mod ne peut pas être reconstruite après coup.
Les spectateurs ne découvrent pas de blocs par le regard.
## Persistance et arrivée dans une sauvegarde antérieure
Contrat additif v1 : fichiers `data/sanctuary-blocodex/<uuid>.json`, enveloppe
avec `schema: 1` et UUID, puis observations, possessions et poses par identifiant.
Les statistiques vanilla restent dans leurs propres fichiers et sont seulement
lues. Aucune conversion du terrain, des paramètres de génération, des chunks,
du journal d'expansion ou des statistiques existantes n'est effectuée.
Sans fichier Blocodex, les observations et poses commencent vides. Le minage,
le jet et les preuves de possession sont disponibles depuis les statistiques
vanilla déjà présentes. On n'invente pas les observations ni les poses passées
à partir de `used`, qui compte aussi d'autres usages.
Les changements sont sauvegardés tous les 600 ticks, lors de la sauvegarde du
serveur, à la déconnexion et à l'arrêt. Un crash peut perdre les dernières
observations ou poses non sauvegardées. Un fichier invalide ou d'une version
inconnue désactive la mémoire concernée et n'est jamais remplacé silencieusement.
Les identifiants de mods absents sont conservés sur disque. Les anciennes
versions de Sanctuary ignorent ce répertoire additionnel ; les poses réalisées
pendant un retour à une ancienne version ne peuvent pas être récupérées.
## Réutilisation
`BlockKnowledgeService.knowledge(player, block)` et `snapshot(player)` renvoient
des valeurs immuables calculées côté serveur. Le service expose aussi sa santé ;
un consommateur doit refuser une condition de progression si la mémoire est
indisponible, plutôt que d'interpréter une panne comme une découverte vierge.
`BlockKnowledgeEvents.CHANGED` permet de réagir aux observations, possessions,
poses et changements de statistiques. Les écouteurs s'exécutent sur le thread
serveur ; ils ne constituent pas un journal transactionnel de récompenses.
Cette livraison ne verrouille aucune recette et n'accorde aucune récompense.
Palettes disponibles, recherches, progression et structures pourront ensuite
définir leurs propres conditions en utilisant cette API commune.
### Palettes de structures
`BlockKnowledgeService.palette(player, condition, tag)` croise les connaissances
du joueur avec une famille de matériaux. Les critères `BlockKnowledgeFacet`
sont combinables avec `and` et `or` : vus **ou** déjà possédés, minés **et**
actuellement portés, etc. Le résultat immuable garde les identifiants triés et
les quantités réellement portées.
```java
var candidates = BlockKnowledgeService.palette(player,
BlockKnowledgeFacet.SEEN.or(BlockKnowledgeFacet.POSSESSED),
BlockKnowledgeService.NATURAL_STRUCTURE_MATERIALS);
```
Le tag `sanctuary:structure_palette/natural` fournit une première famille de
pierres, terres, bois, feuilles et autres matériaux naturels, modifiable par
datapack. D'autres tags pourront définir des palettes minérales, végétales ou
propres à un biome. Le choix des couleurs, les contraintes physiques des blocs,
les quantités nécessaires et la construction de la structure appartiendront au
moteur de structures : une sélection de palette ne réserve aucun stock et ne
place aucun bloc.
## Recensement du terrain chargé
`TerrainCensus.snapshot(server)` expose une distribution des blocs par dimension ;
`TerrainCensus.chunkSnapshot(level, pos)` fournit le détail dun chunk déjà
chargé. Ces lectures se font sur le thread serveur et renvoient des valeurs
immuables. Elles parcourent les chunks `FULL` déjà accessibles, sans charger
de chunk absent ni attendre la fin dune génération.
Le comptage utilise les palettes des sections de 16 × 16 × 16 blocs. Il regroupe
les orientations et autres propriétés sous lidentifiant du bloc. Les blocs
dair sont exclus ; leau et la lave conservent leurs identifiants de blocs.
Le budget est de **16 sections non vides par tick serveur**, partagé entre les
dimensions et les demandes du même tick. Une section entièrement vide est
reconnue par ses métadonnées, sans parcourir sa palette.
Le relevé est progressif. Une section modifiée est retirée des totaux dès que
sa révision change, puis remise dans la file de comptage. Les sections et
chunks déchargés sortent du relevé. Un consommateur dispose, par dimension ou
chunk, du nombre de sections chargées et comptées, du tick de capture, du tick
du dernier comptage effectué et de l’âge de la plus ancienne attente.
`complete()` et `coverage()` décrivent la couverture **des zones chargées**.
Une couverture de 100 % ne signifie jamais que toute la sauvegarde a été lue.
Pendant un rattrapage, zéro bloc compté ne prouve donc pas labsence de ce bloc.
Les futures règles dexpansion devront fixer leur couverture minimale et leur
zone d’étude avant de prendre une décision. Les résultats en mémoire sont
reconstruits après redémarrage ; ce service n’écrit pas dans les chunks et ne
modifie pas leur génération.
## Recensement des stocks présents
`StockCensus.snapshot(server)` photographie les objets accessibles au tick de
la demande, sur le thread serveur. Le parcours des slots et des entités chargées
est effectué à la demande ; aucun abonnement ne le relance à chaque tick.
Les quantités sont des unités ditems, tous
types confondus : un lingot, un outil ou une graine sont comptés même sils ne
correspondent pas à un bloc. Les composants, lusure et les enchantements ne
créent pas de nouveaux identifiants de stock dans cette version.
| Catégorie | Couverture |
| --- | --- |
| `playerItems` | Joueurs connectés : inventaire personnel, équipement et curseur du menu. Le contenu du menu ouvert nest pas additionné. |
| `blockContainerItems` | Entités de blocs déjà instanciées dans les chunks chargés, qui exposent linterface `Container` : coffres, hoppers, fours, pots décorés, etc. Chaque moitié dun double coffre est comptée une fois. |
| `entityContainerItems` | Entités chargées qui exposent `Container`, notamment les wagonnets et bateaux à coffre. |
| `groundItems` | Quantités des `ItemEntity` chargées et encore présentes au sol. |
Le relevé ne déballe **aucune table de butin**. Tout conteneur dont la table
reste à résoudre est ignoré avant la lecture de ses slots ; son existence
augmente `pendingLootContainers`. Les entités de blocs encore stockées sous
forme de NBT et non instanciées sont également laissées intactes, avec leur
nombre dans `pendingBlockEntities`. Leur contenu nest pas remplacé par une
estimation.
La couverture exclut les joueurs hors ligne, les chunks et entités déchargés,
les ender chests, le contenu imbriqué des shulker boxes et bundles, les grilles
de fabrication en cours dans linventaire ou l’établi (`menuInputsIncluded()`
est faux), les inventaires et équipements des créatures. Le résultat de
fabrication affiché avant validation ne constitue pas un stock supplémentaire.
Elle exclut aussi les objets internes
des entités de blocs qui nexposent pas `Container`, comme les aliments dun feu
de camp ou le livre dun pupitre. Une shulker box portée compte comme un item ;
une shulker box posée et chargée expose son conteneur. Les exclusions figurent
dans le contrat de couverture ; `partial()` reste vrai.
Ce relevé indique où se trouvent les unités observées. Il ne les réserve pas,
ne les transfère pas et n’évalue pas leur prix. Ajouter le terrain aux stocks
ne donne pas une quantité immédiatement vendable : une roche dans un chunk
reste différente dun item dans un coffre.
## Consultation et futurs consommateurs communs
`WorldCensusService.capture(server)` rassemble les deux relevés pour une même
lecture côté serveur. Le consommateur réutilise cet instantané pendant son
calcul et conserve ses informations de couverture. Le shop et les expansions
pourront ainsi consulter la même base, avec leurs propres règles de fraîcheur,
de portée géographique, de prix, de consommation et de réservation.
Les diagnostics suivants sont réservés aux opérateurs :
```text
/sanctuary census
/sanctuary census block minecraft:stone
/sanctuary census item minecraft:gold_ingot
```
Le résumé présente la couverture du terrain par dimension, les quatre catégories
de stocks et les conteneurs au butin encore indéterminé. Le Blocodex personnel
accessible par **B** conserve les données du joueur ; ces diagnostics ne diffusent
pas les inventaires agrégés aux autres joueurs.
## Relevés datés — portée de lalpha.23
La collecte regroupe les activités observées par **jour civil UTC**, pour
lensemble du serveur, sans attribution à un UUID ou à une dimension :
| Action | Registre et unité |
| --- | --- |
| `mined` | `block` : nombre de blocs minés, selon les règles des statistiques vanilla. |
| `placed` | `block` : placements réussis par le joueur, créatif inclus et spectateurs exclus. |
| `picked_up` | `item` : unités ramassées observées par les statistiques vanilla. |
| `dropped` | `item` : unités volontairement jetées observées par les statistiques vanilla. |
| `crafted` | `item` : unités fabriquées créditées au joueur par les statistiques vanilla. |
Chaque combinaison conserve son action, son registre, son identifiant et une
quantité entière sur 64 bits, saturée à la valeur maximale en cas de dépassement.
Un compteur de minage nest pas transformé en nombre ditems produits. Les actions
antérieures à linstallation ne sont pas réparties artificiellement entre des
dates à partir des statistiques cumulées de Minecraft.
`MaterialActivityHistory.snapshot(server, day)` expose un instantané immuable
avec le jour, les compteurs, `recorded` et `unsaved`. Un jour sans données
enregistrées demeure inconnu ; il ne constitue pas un historique dactivité nulle.
Les opérateurs peuvent consulter `/sanctuary history` ou préciser une date avec
`/sanctuary history 2026-09-10`. La commande affiche au plus les 16 lignes aux
quantités les plus élevées et indique combien ont été omises ; lAPI conserve
lensemble des compteurs du jour.
La persistance additionnelle utilise `data/sanctuary-activity/YYYY-MM-DD.json`,
avec un schéma 1 et des dates UTC. Elle est séparée de la mémoire personnelle
et des chunks. Les écritures ont lieu tous les **600 ticks**, à la sauvegarde
et à larrêt du serveur, ainsi quau changement de jour observé par le service.
Un crash peut perdre les derniers relevés non sauvegardés, soit normalement
jusqu’à 30 secondes de jeu à 20 ticks par seconde. Le fichier conserve les
identifiants de mods absents ; il refuse les schémas inconnus, doublons,
compteurs invalides et changements externes. Une erreur arrête la collecte et
rend le service indisponible jusqu’à réparation puis redémarrage, en conservant
le fichier en cause. Les limites sont de 100 000 tuples et 16 Mio par jour.
Ces relevés permettent de distinguer des périodes dactivité. Ils ne conservent
pas dhistorique des instantanés de stocks, ne couvrent pas la fabrication automatique des
machines et n’établissent ni consommation, ni perte, ni destination des objets.
Ils ne retracent pas chaque transfert entre coffres ou chaque changement de propriétaire, et
ne constituent pas un journal transactionnel dachats, de récompenses ou de
réservations. Le **ballast**, ses unités, ses conversions et sa traduction en
géologie restent à concevoir dans le [cahier de cosmologie](cosmologie.md).
Aucun objet nest consommé et aucune Backroom nest générée par ces mesures.
## Référence WSMC retrouvée
La recherche locale a retrouvé une partie du travail précédent dans
`/Users/koka/Documents/sanctuary/Gameplay/blocodex` :
`ClientVoxelProjectBuilder` convertit des apparences de blocs et ditems en
volumes/couches de blocs ; `BlocodexStudioPalettes` sélectionne des matériaux
opaques appris. Le projet conserve aussi un instantané WSMC de 1 881 entrées
(1 168 blocs, 490 items, 223 mobs), issu de Minecraft 1.21.11.
Cette référence reste historique. La nouvelle API fournit la connaissance et
les candidats de palette ; la traduction de modèles dentités en structures
(visage de creeper, squelette géant, etc.) reste un chantier distinct. Aucun
générateur dentités ni import de ce snapshot ancien nest ajouté au registre
de Minecraft 26.3 par le socle méta.
## Validation
Validation locale du 10 septembre 2026 : `./gradlew check build assemblePack`
passe, avec **24 GameTests natifs réussis**. Le client graphique vérifie les
widgets et les messages réels dans un nouveau monde intégré, avec B sans droit
opérateur, observation serveur et possession conservée après retrait du stock.
Les tests de stockage passent. Les [preuves et limites](testing-alpha23.md)
détaillent le protocole, lartefact et son empreinte. Alpha.23 assemblée localement ;
publication du MRpack et mise à jour du canal demandées, en préparation.
+275
View File
@@ -0,0 +1,275 @@
# Sanctuary — matière, espaces et mémoire
**Cahier de conception issu du texte de lauteur.** Il prolonge
[la vision](vision.md) et [la conception des lieux](structures-conception.md).
Les citations conservent les formulations fournies ; les points à préciser
sont identifiés comme tels. Ce document ne livre aucun générateur ni système
économique. **Backrooms, Indoors, shop et ballast ne sont pas implémentés.**
Le Blocodex natif de lalpha.23 locale fournit un premier socle de données
pour des mécaniques futures, avec les limites décrites dans [son contrat](blocodex.md).
## Minecraft comme archéologie dun autre Minecraft
Steve, Ari, Sunny, Kai, Zuri, Alex, Mefe, Makena et Noor sont les neuf personnages
disparus de la partie. Ils ont découvert quelque chose de révolutionnaire et ont
continué parce que ça marchait. Jusquau moment où ça na plus marché.
> Ils nont pas ouvert quelque chose quils nauraient pas dû ouvrir. Ils ont
> construit quelque chose quils ne savaient plus refermer.
Lhistoire part de quelque chose qui a fonctionné : des besoins satisfaits, des
lieux devenus utilisables, des opérations répétées et des installations agrandies.
Une ruine doit permettre de retrouver cette ancienne solution. Ses circulations,
ses raccords, ses réparations et ses extensions racontent un fonctionnement avant
de raconter son interruption.
> Chaque ruine de sanctuary doit être une trace dune ancienne solution pas
> seulement la trace dun problème.
La découverte précise, la succession des expériences et les rôles des neuf
personnages restent à écrire. Aucun métier, aucune responsabilité individuelle
ni cause définitive de leur disparition ne leur sont attribués ici. On conserve
en particulier **Mefe**, tel que nommé par lauteur. Il nest pas décidé que
chacun possède un bâtiment, une machine, une gemme ou une quête correspondante.
## Deux devenirs de la même technologie
Les Indoors et les Backrooms appartiennent à la même logique de fabrication des
espaces. Ce qui les distingue est leur relation à une intention et à une
référence : sait-on encore pourquoi cet espace existe et comment le retrouver ?
| Espace | Direction donnée par lauteur | Conséquence pour la conception |
| --- | --- | --- |
| **Indoor** | « espace artificiel référencé et contrôlé » | Un intérieur est produit intentionnellement et reste associé à son accès et à son usage. Une porte peut mener à un espace dix fois plus grand que le bâtiment extérieur. |
| **Backroom** | « espace artificiel orphelin » | Un espace demeure alors que sa destination ou sa raison d’être a disparu. Sa forme peut conserver les restes dune fonction sans retrouver lensemble auquel elle appartenait. |
Lauteur décrit chaque Indoor comme une petite dimension instanciée. Les
computers utilisent **probablement** cette technologie pour leurs expériences :
cette relation est une piste forte, sans avoir encore de protocole ni de machine
définitivement assignée.
Les Backrooms, dans cette cosmologie, ne sont pas une destination demandée au
même titre quun monde. Elles sont ce que la machine produit lorsquelle ne sait
pas où mettre quelque chose :
- un espace généré puis annulé ;
- une expérience interrompue ;
- deux règles demandant deux états incompatibles ;
- une room qui nest plus référencée ;
- une coordonnée quaucun monde ne revendique.
> La machine ne détruit pas parfaitement les espaces. Elle les laisse derrière.
> Backrooms.
Limage du « garbage collector raté de Minecraft » exprime cette accumulation.
Larborescence proposée par lauteur la représente ainsi :
```text
WORLD
├── overworld
├── caverns
├── nether
├── end
├── sanctuary
└── /unreferenced/
└── backrooms
```
Cest une représentation narrative, pas une arborescence de fichiers ni la
liste des dimensions actuellement enregistrées par le mod. **Lespace orphelin
est une fiction à mettre en scène.** Une réalisation Minecraft devra conserver
des espaces correctement sauvegardés, identifiés et référencés côté serveur.
Le choix entre dimensions techniques et régions dun espace partagé reste à
concevoir. Rien ici ne prévoit de perdre de vraies références de sauvegarde,
de supprimer des chunks ou de provoquer leur corruption pour obtenir cet effet.
## De la matière sans contexte
Le système reçoit des matériaux et tente de les réorganiser en espace valide :
```text
stone stone dirt copper oak_planks iron stone glass ...
```
Il possède de la matière, mais aucune destination na été demandée. Il tente
alors de lencoder sous forme de chunks : un couloir, une salle, encore une
salle, un plafond, une porte donnant sur rien, une plomberie sans bâtiment,
un entrepôt infini.
> Les Backrooms sont la tentative du générateur de Minecraft de faire quelque
> chose avec de la matière sans contexte.
La **matière** donne une palette et une présence physique. Le **contexte** lui
donnait un usage, des dimensions, des voisins, un accès ou une destination.
Cette distinction permet de dessiner un lieu dont les parties sont intelligibles
alors que leur assemblage ne retrouve plus son intention : une plomberie garde
sa logique de raccord, mais le bâtiment quelle devait desservir nest plus là.
Elle ne justifie pas de remplacer la conception par des blocs placés au hasard.
Il reste à définir ce que la machine conserve de ce contexte : seulement les
matériaux, des fragments de plans, des types de pièces, des relations entre
espaces ou des traces dopérations. Aucun catalogue de rooms ni algorithme de
recomposition nest arrêté. Les palettes limitées et le futur moteur de
traduction en structures peuvent servir cette direction ; le moteur nest pas
créé par ce document.
## Le ballast de l’économie
La direction fournie relie directement cette cosmologie aux actions des joueurs.
Utiliser le shop, téléporter, ouvrir des Indoors, déplacer de la matière ou
expandre le monde produit du **ballast**. Plus ces opérations se multiplient,
plus les Backrooms se remplissent. Elles deviennent lexternalité du système
économique de Sanctuary : une activité visible laisse ailleurs des conséquences
que ses utilisateurs pourront découvrir beaucoup plus tard.
Le ballast relie donc production, échanges et géographie. Un objet déplacé ne
change pas seulement de propriétaire ou de position dans cette vision ; son
passage par ces technologies peut laisser une empreinte dans le monde.
La forme de cette empreinte reste à décider pour chaque opération.
| Opération envisagée | Articulation à définir avant de limplémenter |
| --- | --- |
| Shop et échanges | Quel événement produit du ballast : vente, achat, consommation par un service, autre opération ? Quelle matière lui correspond et à quel moment l’échange est-il terminé ? |
| Téléportation | Que laisse le déplacement : trace de matière transportée, coût matériel ou autre résidu à choisir ? Les types de téléportation partagent-ils cette règle ? |
| Ouverture dun Indoor | Que produit la création dun espace contrôlé ? Que deviennent ensuite une fermeture, un transfert daccès ou une expérience interrompue ? |
| Déplacement de matière | Quelles opérations participent à cette économie ? Un transport manuel, un convoyeur, un stockage ou une transformation ne sont pas encore déclarés équivalents. |
| Expansion | Comment la préparation et la création dun continent se rattachent-elles aux matériaux engagés et aux espaces laissés derrière ? |
« Ballast » ne fixe pas encore une quantité, une unité, un coût ou une monnaie.
Il faut notamment décider sil représente une matière effectivement retirée,
une trace de son traitement ou les deux selon les opérations. Aucun taux de
conversion ni conservation quantitative stricte nest établi. Le document
nautorise donc pas à prélever des objets dans les inventaires ou à les dupliquer
dans des coffres au nom de cette cosmologie.
## Lire lhistoire économique dans la géologie
La génération doit dépendre du comportement réel des joueurs. Lauteur donne
trois exemples qui fixent la direction sans constituer des recettes de génération :
| Histoire du serveur | Trace possible dans les Backrooms |
| --- | --- |
| Le serveur consomme énormément de pierre. | Les premières couches deviennent minérales. |
| Une phase dindustrialisation utilise beaucoup de cuivre. | Des zones techniques apparaissent. |
| Des tonnes de bois sont sacrifiées. | Un gigantesque Indoor abandonné peut émerger. |
Des mois après, les joueurs peuvent descendre et reconnaître dans les lieux
traversés une période de lhistoire du serveur. Le choix des matériaux doit
pouvoir conserver une différence entre une phase minérale, une phase industrielle
et dautres formes de production, au lieu de tout réduire à la dernière palette
dominante.
Cette intention demande une temporalité explicite. Deux histoires se rencontrent :
celle des neuf personnages, antérieure à larrivée des joueurs, et celle que le
serveur écrit pendant la partie. La première fournit les vestiges dun autre
Minecraft ; la seconde peut produire de nouveaux dépôts et de nouvelles traces.
La présence de strates initiales issues de lancienne partie, leur contenu et
leur articulation avec les apports actuels restent à décider.
Il faut encore choisir la durée des périodes observées, ce qui forme un dépôt,
le moment où un dépôt devient un lieu et la manière dont les périodes se
succèdent dans lespace. Une couche nest pas nécessairement un niveau vertical :
la géographie de cette chronologie reste à dessiner. La règle devra préserver
les espaces déjà visités et construits ; changer les statistiques actuelles ne
doit pas devenir une régénération implicite danciens chunks.
## Ce que le socle méta peut fournir
Le Blocodex appartient à Sanctuary et peut être consulté par plusieurs mécaniques.
Il distingue actuellement connaissance, observation, minage, possession passée,
jet, pose et stock porté. Les critères de connaissance et les familles de
matériaux peuvent aider un futur moteur à constituer une palette utilisable.
Ces informations personnelles ne suffisent cependant pas à décrire lhistoire économique :
- **miné** ne veut pas dire consommé par le shop ou engagé dans une machine ;
- **jeté** compte les jets volontaires vanilla, pas les objets brûlés ou perdus
dans le vide ;
- **déjà possédé** est une preuve persistante, pas un stock encore disponible ;
- **posé** ne fournit pas à lui seul le plan dune construction ni sa date ;
- le stock personnel ne recense pas tous les coffres et réseaux du serveur.
Le socle méta distingue maintenant trois relevés :
| Relevé | Ce quil permet de connaître |
| --- | --- |
| Mémoire personnelle | Ce quun joueur a vu, miné, possédé, jeté ou posé ; des critères pour ses palettes. |
| Distribution actuelle | Les blocs du terrain chargé et les stocks accessibles, comptés séparément avec leur couverture. |
| Activité datée du serveur | Les quantités minées, posées, ramassées, jetées et fabriquées, agrégées par jour UTC depuis linstallation. |
Le troisième relevé conserve une trace des périodes dactivité après le
déplacement des stocks. Il peut montrer une période de minage intense de cuivre,
mais ne prouve pas que ce cuivre a été sacrifié à une machine. Il ne reconstruit
pas les mois précédant son installation et ne calcule aucun ballast. Les limites
de persistance et de couverture figurent dans le [contrat méta](blocodex.md).
Avant la génération historique, il faudra donc définir quels faits les systèmes
économiques enregistrent : action réalisée, matériau, quantité, moment ou période,
origine et destination utiles à cette action. Leur traitement devra distinguer
une opération terminée dune tentative refusée et éviter de compter plusieurs
fois la même opération après reprise. La granularité, la conservation et le
format de ces transactions restent à concevoir dans un contrat propre. Les
agrégats journaliers déjà disponibles ne sont pas un journal transactionnel :
ils ne doivent autoriser ni prélèvement, ni récompense, ni restitution dobjet.
## Machines, références et anciennes solutions
Les computers pourraient expérimenter dans les Indoors parce quils offrent
des espaces contrôlés. Il reste à préciser ce quils demandent à un espace et
comment cette demande le rattache à une installation, une porte ou un usage.
La cause dun orphelin dans la fiction devra être compréhensible sans demander
au joueur de connaître les fichiers de sauvegarde.
Lassembleur, le contrôleur, le terminal, le dépôt et lancre spatiale restent
des concepts en discussion dans [le cahier des machines](structures-conception.md#ordinateurs-et-installation-dexpansion).
Les questions communes sont maintenant plus précises : qui formule la demande
despace, qui fournit sa matière, qui conserve sa référence, qui présente son
état, et comment une fermeture ou une interruption est représentée ? Cette liste
ne répartit pas arbitrairement cinq fonctions entre cinq blocs.
Les ruines peuvent rendre ces questions visibles par leurs usages passés.
Lobservatoire, la salle dexpansion rééquipable, latelier caché et la grande
traversée restent la sélection de conception actuelle. Pour chacun, il faut
retrouver ce qui y était observé, préparé, fabriqué ou relié, puis montrer les
extensions et le travail interrompu. Ce sont des questions de fiche de lieu,
pas la confirmation de machines présentes ni dun accès aux Backrooms dans
chaque ruine. Le donjon majeur conserve son boss et son objet de quête à définir ;
il nest pas automatiquement identifié au réacteur ou à lorigine de la panne.
La même ligne relie le passé et la partie actuelle :
> une infrastructure dont lauteur a progressivement disparu derrière son
> fonctionnement.
## Accès, retour et récupération : règles encore ouvertes
La vision antérieure envisage le lit pour entrer dans les Backrooms, des sources
de lumière pour les explorer et des coffres recueillant des objets perdus dans
le vide ou brûlés. Elle prévoit aussi des accès dIndoors liés à la mailbox de
leur propriétaire. Ces intentions doivent être raccordées à la cosmologie du
ballast ; elles ne définissent pas encore un parcours complet.
Avant un ticket jouable, il faut notamment arrêter :
1. Les moyens dentrer, les destinations darrivée, le retour et le comportement
lors dune mort ou dune déconnexion.
2. La propriété et le partage des Indoors, la perte de leur objet daccès, leur
fermeture et la distinction entre un accès absent et un espace fictionnellement
devenu orphelin.
3. Le rapport entre ballast, géologie et objets récupérables : quelle part fait
décor ou ressource, quelle part demeure un objet identifiable, et comment sa
récupération évite une restitution multiple.
4. Les opérations qui alimentent réellement les dépôts, leurs unités, leur
cadence et les limites de stockage et de génération.
5. Les indices permettant de lire une période économique ou une ancienne solution,
sans attribuer davance une cause à tous les lieux et aux neuf disparus.
Les pool rooms et salles étranges physiques sous l’île restent des structures
de lOverworld. Leur ressemblance éventuelle avec les Backrooms ne les transforme
pas automatiquement en espaces orphelins. Les passages entre ces lieux, les
Indoors et les Backrooms devront être décidés explicitement.
La prochaine étape de conception peut prendre un seul cas complet : une
opération économique réelle, la trace quelle conserve, son dépôt, puis le lieu
que ce dépôt permettrait de générer et dexplorer. Ce cas permettrait de fixer un
premier contrat vérifiable sans prétendre que toute la cosmologie est déjà jouable.
+105
View File
@@ -0,0 +1,105 @@
# Alpha.23.1 — île naturelle, secrets et continents
Contrat du ticket **WG-27**, branche `codex/natural-secrets-alpha23`.
Cette livraison réunit le Blocodex alpha.23 et la génération `sanctuary:island_v23`.
Les résultats des essais sont consignés dans [le rapport](testing-alpha23.1.md).
## Sanctuary Island
Le point de départ reste naturel. Les quartiers Lost City et les infrastructures
ordinaires Sanctuary ne sont plus demandés sur l’île initiale. Leur code et les
anciennes générations sont conservés ; les expansions Lost City restent possibles.
Les structures vanilla ne sont pas supprimées : leur admission dépend du biome,
du relief, des appuis et des volumes déjà occupés.
La sélection des lieux Sanctuary conserve lobservatoire, un atelier caché,
la grande traversée et la salle dexpansion indépendante. La piscine attenante
est retirée ; les égouts et leurs ouvertures restent possibles. Les anciennes voies
ferrées peuvent exister sans gare ni bâtiment au bout : leur remise en service
reste une construction des joueurs.
Le **sanctuaire minéral** est une grande cavité géologique explorable, dominante
pierre et cobblestone, avec voûtes, piliers, corniches et filons minables. Il peut
affleurer sur les flancs ou sous l’île avec des attaches rocheuses. Lenveloppe
maximale est 79 × 73 × 27 ; ce volume ne désigne pas une façade rectangulaire.
Il ne comporte ni briques, mobilier, machines, ni éclairage manufacturé.
Le mot laboratoire reste réservé à la future structure des Mooblooms.
Le gardien et le portail des Cavernes ne sont pas activés ici. Lintention de
progression demeure : vaincre le gardien stabilisera laccès aux Cavernes pour
tout le serveur, puis chacun pourra construire son portail. Le Deep Dark et les
cités anciennes appartiendront à ces Cavernes. Le lieu des raids appartient aux
expansions et nest pas construit dans cette livraison.
## Structures natives et garantie dexpansion
Le catalogue sappuie sur les 52 identifiants de structures présents dans le JAR
Minecraft 26.3-pre-2 : 47 de lOverworld, dont la cité ancienne réservée aux Cavernes,
et cinq propres au Nether ou à lEnd. Les petits donjons `monster_room` sont des
features et font lobjet dun suivi séparé. Les modèles, identifiants, coffres,
spawners et comportements natifs sont conservés.
Les placements distinguent surface, sous-sol et milieu aquatique. Les hauteurs
souterraines vanilla ne sont pas recopiées sous Y=0 : elles sont adaptées à la
roche de l’île. Les monuments conservent aussi leur hauteur lors du rechargement.
Ladaptation ne remplace pas les définitions `minecraft:*` du registre.
Les ruines océaniques et les trésors enterrés conservent leur ajustement fin
vanilla au fond réel pendant la pose des blocs : leur hauteur peut encore se
préciser après lacceptation du départ.
**Correction approuvée après le plan initial : chaque nouvelle expansion 23 avec
les structures activées doit disposer dau moins une structure adaptée avant sa
réservation.** Les familles absentes restent préférées, sans rendre obligatoire
une famille incompatible. Une recherche infructueuse ne publie pas une île vide
de structures et ne contourne pas les exigences dappui. Aucune structure nest
ajoutée rétroactivement aux îles déjà enregistrées.
Les yeux de lEnder, cartes dexploration et `/locate` recherchent les vrais
départs dans les îles ouvertes. Une recherche nactive jamais dexpansion.
Le compteur natif de références cartographiques nindique pas une visite réelle.
## Océans
Le relief `ocean` est réservé aux expansions 23 de 512 ou 1024 blocs. Les grandes
expansions rapides sans relief explicite peuvent le choisir dans un tirage
déterministe denviron un tiers. Le climat et la taille restent ceux du descripteur.
Le champ océanique commun au terrain, à leau et aux biomes produit une mer
intérieure irrégulière, des hauts-fonds, des côtes et une zone profonde, en
conservant une majorité de terres. Cible : 3045 % de surface aquatique nominale,
profondeurs maximales 3256 blocs. Une coque rocheuse continue retient leau ;
le reste du vide conserve lair. Le niveau deau est local à lexpansion.
Ce niveau accompagne aussi les décorations natives, notamment les icebergs ;
il est rétabli après chaque passe pour ne pas affecter les autres îles.
Les températures utilisent les biomes natifs océan, océan froid, gelé, tiède et
chaud, avec les variantes profondes disponibles. Il nexiste pas de
`deep_warm_ocean` natif. Les monuments exigent un océan profond réellement adapté.
Aucun océan ni Deep Dark nest ajouté à Sanctuary Island.
## Mémoire serveur et sauvegardes
Le registre des structures est persistant et distinct du census des seuls chunks
chargés. Il distingue départ accepté, bâtiment partiellement ou complètement
généré, région encore inconnue et absence confirmée dans une région vérifiée.
Une démolition ne supprime pas le fait historique de génération. Une erreur de
lecture ne transforme pas le registre en liste vide.
`/sanctuary census structures` expose les relevés aux opérateurs ; loption
`island <id>` précise la région et `dimension <id>` distingue les dimensions natives.
L’île propriétaire se déduit des descripteurs immuables du journal dexpansion.
Le suivi automatique ne charge pas la carte et
ne révèle pas les secrets dans le Blocodex personnel.
Le champ optionnel plat `structure_priorities` dune nouvelle expansion 23
contient les familles bonifiées. Il est capturé une fois avant linspection,
figé dans le journal et lié au certificat de réutilisation du vide. Bonus initial
de sélection : × 4. Une reprise réutilise ce profil même si le registre a évolué.
Les générations historiques restent neutres, sans champ ajouté à leurs écritures.
Les mondes existants gardent leur codec et leur terrain. Les nouvelles règles
nécessitent un nouveau monde ; aucune conversion ou régénération nest effectuée.
Petit 512, Moyen 724 par défaut et Grand 1024 restent dans Personnaliser. Loption
expérimentale commande les secrets optionnels et les rails ; le hall et la
traversée restent dans le socle. Les structures vanilla en sont indépendantes.
La désactivation générale des structures reste prioritaire.
+90
View File
@@ -0,0 +1,90 @@
# Alpha.24 — les lieux suspendus de Sanctuary
## Contrat
Le nouveau codec `sanctuary:island_v24` ajoute cinq lieux au monde initial :
une montgolfière, deux bateaux de fret et deux îlots minéraux. Ils sont planifiés
à la création et construits seulement lorsque leurs chunks sont générés.
La génération de terrain reste celle de lalpha.23.1 : mêmes trois tailles
512 / 724 / 1 024, Moyen par défaut, bouton Minecraft **Personnaliser**,
secrets minéraux, voies abandonnées, structures vanilla, océans dexpansion,
registre et admission dau moins une structure adaptée par expansion acceptée.
Les cinq sites cherchent la périphérie, hors de lemprise du terrain initial.
Chaque site possède un point de rivage vérifié, à moins de 512 blocs, et un
segment de vue dégagé dans le relief et hors des volumes des secrets déjà
planifiés. Les distances de rendu du client et du
serveur déterminent sa visibilité en jeu ; les arbres ultérieurs peuvent
masquer une partie de cette vue. Le relief nest pas prolongé vers les sites.
Aucun pont, quai, pilier jusquau sol ou accès automatique nest fourni.
## Formes et ressources
La montgolfière possède une enveloppe arrondie de laine claire, un accent,
une nacelle en chêne et des suspentes attachées. Elle reste sous Y350.
Les deux navires ont une coque biseautée, des voiles, un pont protégé,
une cabine et une cale. Le navire de vivres accueille un fermier adulte ;
celui de matériel, un outilleur adulte. Leurs postes de travail sont vanilla.
Il faut construire pour atteindre les lieux et ramener les villageois.
Les îlots ont une roche irrégulière, une base effilée et des filons connectés.
Leur surface reçoit une couverture de terre et dherbe majoritaire, des fleurs,
des herbes et quelques petits chênes ou bouleaux. Le volume réservé comprend
les couronnes ; l’épaisseur rocheuse reste de 20 à 35 blocs.
Leur profondeur relative détermine les minerais : charbon, fer et cuivre
abondants ; poches dor, lapis, redstone, diamant et émeraude. Ces réserves
sont finies. Le recensement existant compte les blocs dans les chunks
observés, sans charger toute la région ni lire le butin encore fermé.
Exactement un îlot possède une source deau qui s’écoule suivant les règles
Minecraft et peut tomber dans le vide.
Chaque lieu contient exactement un coffre dédié. Ceux des îlots sont enterrés
sous une couverture stable, à l’écart de la cascade. Les tables de butin
utilisent exclusivement des objets vanilla, sans récompense future ni Anomaly.
Ni le butin, ni les habitants, ni les blocs détruits ne se renouvellent au
rechargement dun chunk déjà généré.
## Persistance et expansions
La recherche est déterministe, limitée à 64 candidats par site et 8 192
colonnes de terrain mises en cache pour lensemble du plan. Les plans des
secrets racine sont calculés auparavant, puis réutilisés au démarrage ; une
voûte ou une galerie ne peut ainsi recouvrir le point côtier certifié. Cette
planification ne demande aucun chunk. Un échec interrompt explicitement la création du plan plutôt que
de livrer un monde avec un satellite silencieusement manquant.
Le manifeste immuable est écrit dans `data/sanctuary-aerial-v24/` avant le
premier journal `data/sanctuary-world-v24/`. Son SHA-256 est lié au journal24.
Un rechargement relit le plan stocké ; il ne le recalcule pas. Un manifeste
manquant, altéré ou incompatible bloque la reprise avant toute expansion.
Ce contrat ne sapplique quaux nouveaux mondes24 ; les anciens journaux,
codecs, profils et terrains restent lisibles sans migration.
Les réservations couvrent exactement les chunks des cinq structures et un
petit masque local autour de la sortie deau, sans marge sur tout le pourtour
des sites. Elles ne deviennent pas des propriétaires de terrain :
les biomes du vide et le bruit de génération restent inchangés. Ladmission
dexpansion, le journal et la reprise de vide vérifient ces emprises même si
aucun joueur na encore exploré le site. Les passages entre elles restent libres.
Les départs et pièces sont natifs, enregistrés sous `aerial_balloon`,
`aerial_merchant_ship` et `aerial_mineral_islet`. Les écritures sont limitées
au site et au chunk courant. Le registre distingue les sites planifiés,
les départs acceptés et la génération observée. Les scopes aériens ne servent
pas à conclure quune famille de structures vanilla manque au serveur.
`/locate structure` et `/sanctuary census structures aerial [site]` les
référencent sans ouvrir dexpansion ni transformer une consultation en visite.
Les options **Structures expérimentales** et **Générer les structures**
contrôlent les satellites. Leur désactivation produit un plan vide, sans
réservations aériennes. Les options sont sauvegardées avec le monde.
## Limites de cette livraison
Minecraft reste en 26.3-pre-2. Les nuages et la hauteur du monde sont inchangés.
Aucun véhicule pilotable, bateau-poule, temple, commerce Sanctuary, boss,
portail des Cavernes ou raid nest ajouté. Deep Dark et cités anciennes
restent réservés aux futures Cavernes.
Les vérifications réellement exécutées, les témoins et l’état de distribution
figurent dans [testing-alpha24.md](testing-alpha24.md).
+319
View File
@@ -0,0 +1,319 @@
# Alpha.25 — reliefs et profondeurs de Sanctuary
## Portée et sauvegardes
La génération `sanctuary:island_v25` concerne les **nouveaux mondes Sanctuary**.
Les trois diamètres nominaux restent **512 / 724 / 1 024 blocs**, avec **Moyen**
par défaut et le bouton Minecraft **Personnaliser**. Le monde conserve sa
hauteur de 384 blocs et les nuages existants. Les sommets naturels peuvent
approcher Y358 ; cette borne n'est pas une hauteur imposée à chaque massif.
Les codecs et réglages historiques restent enregistrés. Le preset24 est
conservé sous `sanctuary:sanctuary_v24` ; charger une sauvegarde24 ne transforme
pas son générateur en25. Aucun chunk existant n'est régénéré, aucun monde
personnel n'est migré et aucun portail vers une nouvelle dimension n'est activé.
La validation demandée porte désormais uniquement sur les **nouveaux mondes
de test** : la compatibilité avec les anciennes versions n'est plus un
critère de livraison de cette alpha. Les preuves historiques déjà produites
restent documentées, sans prolonger cette campagne de compatibilité.
Les modifications de relief, de cavités et de géologie décrites ci-dessous
s'appliquent à **l'île initiale uniquement**. Les continents d'expansion
conservent leurs champs de terrain et leurs océans précédents ; la nouveauté
pour leurs océans est le contrat de monument détaillé plus bas.
## Relief, cavités et ressources
Le terrain initial utilise les réglages distincts `unified_25_5`,
`unified_25_10` et `unified_25_20`. Des groupes de pics dispersés étirent
verticalement les masses existantes, avec de la calcite dans leurs hauteurs.
L'étirement reste monotone et s'atténue à l'arrivée ; il ne crée pas un plateau
central artificiel. L'emprise horizontale initiale reste bornée par la taille
choisie. Aucun bambou supplémentaire n'est associé aux massifs de calcite.
Un plan déterministe ajoute des cavités sèches, des refuges à champignons et
des propositions de lacs souterrains, ainsi que des chambres basses de lave.
Les chambres d'un même groupe sont reliées par des galeries sinueuses ; les
failles courtes dérivent avec la hauteur. Les cavités existent indépendamment
du choix de placer un village ou un donjon. Elles ne garantissent pas qu'une
structure y trouvera la place nécessaire.
Les cavités sèches cherchent désormais leur assise dans la masse rocheuse
intacte. `DryFit` examine au maximum **48 propositions** de position et de
profondeur, sans modifier leurs rayons et sans consulter une structure ou un
village. Un premier contrôle de sol et de voûte précède la vérification dense
de **trois couches de roche existante** sous toute l'empreinte ouverte du
plancher. Les bassins et leurs membranes passent en priorité. Un candidat
refusé conserve sa proposition de cavité, avec un résultat de certification
négatif explicite : il n'obtient aucune semelle artificielle.
Une assise certifiée est conservée contre les seules nouvelles excavations,
connexions et fractures25. Cette membrane retire des coupes prévues ; elle
n'ajoute jamais de roche dans un vide du champ naturel. Le certificat du
plancher n'est ni une certification de toute la voûte ni une admission de
village. Les coupes hydrologiques héritées, les secrets, les accès et le
volume complet des pièces doivent encore passer les contrôles de structures.
Les diagnostics exposent pour chaque cavité sèche le nombre de propositions,
le statut du plancher et le motif final de refus éventuel.
Les lacs ne sont remplis qu'après admission de leur fond et de leurs berges
dans la densité sculptée. Une proposition non retenue reste une cavité sèche.
Les plans hérités d'eau et de lave participent également aux exclusions.
Il n'y a ni couche d'océan globale sous l'île, ni obligation de remplir tous
les volumes creusés. Les profondeurs de cette première passe sont de cinq
blocs pour les nouveaux lacs d'eau et de trois pour les bassins de lave.
Les refuges combinent mycélium, mousse et podzol. Ils reçoivent des tentatives
de grands champignons vanilla et de mooshrooms aux emplacements habitables.
Leur quantité effective dépend du terrain, de la place disponible et des
décorations voisines. Les biomes `terrain25_*` gardent les forêts tempérées,
les grottes luxuriantes, les dripstones et le sulfure.
Le marais suit certaines eaux calmes et leurs berges réelles, **en surface
comme sous l'île**. Il remplace le secteur elliptique à altitude fixe.
La recherche de surface examine au plus 64 candidats par région, sur les
étangs et lacs déjà retenus, et garde au plus trois petites poches. Une
seconde recherche indépendante examine au plus 64 candidats pour toute
l'île et garde au plus trois poches sur les lacs souterrains déjà admis.
Ce plan inférieur est partagé par les régions, sans nouveau tirage selon
le chunk demandé. La sélection n'est calculée qu'une fois par contexte de
monde ; les régions combinent ensuite son index avec leur sélection de
surface. Un site inférieur peut donc figurer dans plusieurs diagnostics
régionaux : son identifiant doit être dédupliqué pour compter les lieux,
et les compteurs régionaux ne sont pas à additionner comme des recherches
indépendantes. Les rivières, terrasses, cascades, écoulements chauds et
refuges à mooshrooms restent exclus de cette sélection.
Le marais ne crée aucun plan d'eau, cavité ou plateforme pour une hutte.
Il retient des fonds existants de un à quatre blocs en surface, jusqu'à
six blocs dans les lacs inférieurs, et des berges sur trois couches
rocheuses, entre le niveau d'eau et quatre blocs au-dessus. Les voisins
sont raccordés par des pas d'un bloc au plus. Une poche exige au moins
douze colonnes d'eau et quarante-huit colonnes de berge en surface,
trente-deux en sous-sol ; une hutte peut néanmoins être refusée par ses
propres contrôles de hauteur, largeur, profondeur d'eau et pilotis.
Une poche couvre au maximum un rayon de 28 blocs ; ses berges s'étendent
jusqu'à 18 pas d'une eau réelle. Le biome possède une épaisseur locale :
deux blocs sous le fond humide ou trois sous le sol sec, jusqu'à douze
blocs au-dessus de l'eau ou du sol. En sous-sol, elle s'arrête avant le
premier plafond rocheux et doit conserver au moins quatre blocs libres.
Plusieurs marais peuvent se superposer au même X/Z : chaque colonne garde
son niveau d'eau et l'identité de son bassin. Le biome est l'union de ces
petites enveloppes, jamais une colonne continue traversant tous les étages.
Les grottes hors de ces enveloppes gardent leurs biomes. Le rayon de
96 blocs autour de l'arrivée et les massifs sont exclus **en surface** ;
un lac inférieur peut se trouver sous leur X/Z sans changer l'arrivée ni
les sommets. Les réservations d'écoulement restent préservées. Le terrain existant reçoit
des plages de terre humide, de boue et d'argile, sans déplacer les surfaces.
Le même plan immuable fournit les colonnes de terrain, le biome 3D et les
candidats de hutte ; il est indépendant des options de structures. Les
couleurs et la flore vanilla du marais sont conservées, sans ses placements
supplémentaires de lave, sources ou fossiles à altitude absolue.
Les placements vanilla fondés sur la surface ne suffisant pas sous une
voûte, une petite passe botanique utilise directement les berges inférieures.
Elle tente des herbes, orchidées bleues, buissons à lucioles, petits
champignons, cannes à sucre, nénuphars et lianes. Les plantes nécessitent
de l'air et leur condition native de survie ; les lianes commencent sur
une vraie paroi ou un plafond. Les écritures restent dans le chunk courant
et ne remplacent ni sol, ni eau, ni construction. Aucun arbre n'est forcé
pour remplir une cavité. La quantité visible est mesurée dans les témoins
natifs ; un candidat de plante ne constitue pas une plante effectivement
générée.
Pour les petits champignons de ces marais, le contrôle natif de survie est
complété avant l'éclairage final : une canopée partielle peut laisser arriver
un niveau de lumière 13 qui n'était pas encore calculé pendant la décoration.
Une garde conservative inspecte au plus treize colonnes de ciel et vingt-cinq
cellules d'émission dans un rayon Manhattan de deux blocs. Elle ne simule ni
ne déclenche l'éclairage ; les obstacles sur les courts trajets de propagation
sont ignorés pour ne pas sous-estimer la lumière. Les champignons réellement
abrités et les substrats bénéficiant de l'exemption lumineuse vanilla restent
possibles. Cette garde est limitée aux petits champignons des marais25.
Le test pur vérifie notamment deux lacs sous une même colonne, la séparation
de leurs enveloppes, l'ordre de recherche inversé, les appuis et les refus
du vide et de la lave. Compilation et tests purs passent après cette
extension. Le témoin natif **512 / graine 0** valide deux poches inférieures
à Y160 et Y170, leurs sols et 38 blocs de végétation réellement présents
sous roche. Les témoins antérieurs limités aux marais de surface restent
distingués de cette vérification dans les [essais de l'alpha.25](testing-alpha25.md).
La présence d'une hutte demeure conditionnelle ; ce témoin n'en admet aucune.
Les nouveaux filons se mesurent à la **profondeur relative de la colonne
naturelle intacte**, entre son sommet et sa base, et non à la hauteur du sol
d'une grotte. Charbon, fer et cuivre accompagnent les couches accessibles ;
or, lapis, redstone et émeraude ont leurs bandes de profondeur. Les nouvelles
poches de diamant sont recherchées au-delà de 65 % de profondeur relative.
Ce sont des réserves physiques finies, sans quota garanti par minerai. Les
filons propres aux anciens secrets et aux îlots aériens conservent leur
distribution locale distincte.
## Rivières ouvertes et réservation des écoulements
Pour chaque extrémité admissible d'une rivière, la génération cherche une
sortie proche sur 32 directions, entre 8 et 64 blocs. Elle conserve un lit
supporté et des berges jusqu'à la lèvre. La sortie peut ensuite s'ouvrir sur
le flanc de l'île et laisser l'eau tomber dans le vide suivant les règles
Minecraft. Aucune paire de sorties n'est forcée : une rivière peut avoir
zéro, une ou deux extrémités adaptées.
Les réservations d'expansion sont établies avant l'exploration. Pour les
nouvelles sorties initiales25, elles couvrent les cellules du chenal et un
élargissement local de sept blocs pour la propagation de l'eau, de Y0 à son
niveau de départ. Elles sont projetées sur les chunks parce qu'une expansion
génère des colonnes complètes. Ce masque ne réserve pas tout le rectangle
englobant une diagonale ni une nouvelle couronne autour de l'île.
## Secrets, lieux aériens et nouvelles structures
La grande traversée, la salle d'expansion et les secrets conservés utilisent
le terrain25 pour chercher leurs appuis. Le sanctuaire minéral garde son
volume de 79 × 73 × 27 blocs et sa chambre centrale. Son nouveau rendu résout
les angles des escaliers en pierre et en cobblestone avant le découpage par
chunk. Un cadre central de blackstone de **7 × 9 blocs**, ouvert sur
**5 × 7 blocs**, reste **inactif**. Il ne contient aucun bloc de portail et
ne rend pas la blackstone utilisable comme cadre Nether ailleurs.
Les cinq lieux aériens24 restent présents dans le plan initial lorsqu'ils
sont activés : montgolfière, deux navires marchands et deux îlots minéraux,
dont un avec cascade. Leur géométrie et leurs limites propres restent
inchangées ; les points de rivage cherchent les secteurs compatibles avec
le nouveau relief. La recherche reste bornée et échoue explicitement si
elle ne peut pas établir les cinq sites, au lieu d'en omettre un en silence.
La nouvelle sélection souterraine recherche, sans les forcer :
- au plus un village abandonné étendu dans une cavité sèche, avec les pièces
et habitants vanilla des variantes abandonnées ;
- une mine native, éventuellement accompagnée d'une petite cabane de mineur
et d'un raccord réellement praticable ;
- une hutte native dans le secteur de marais, à proximité d'eau ;
- jusqu'à trois salles de monstres utilisant la feature vanilla, ses
conditions d'ouverture, ses coffres et son spawner.
Le village recherche au moins six bâtiments et un accès vers une grotte
naturelle. Les fondations, la couverture rocheuse, les fluides et les
réservations des secrets sont vérifiés avant son admission. Les ajustements
de sol et de voûte restent dans les volumes acceptés. La sélection générale
des structures natives ne rajoute pas de village de surface sur l'île25 ;
les villages des expansions gardent leur catalogue.
Les départs et les pièces sont enregistrés comme structures natives. Les
salles de monstres restent des features et sont recensées séparément. Le
chargement d'un chunk terminé ne reconstruit pas son mobilier, son butin ni
ses habitants retirés. Les structures facultatives refusées sont indiquées
dans les diagnostics de planification ; leur absence peut être normale.
## Ordre de préparation et options
Avant de demander les nouveaux chunks, le serveur prépare le contexte de
terrain25 et ses plans de cavités/hydrologie, puis les réservations des
secrets existants. Le plan aérien s'appuie sur ces colonnes finales et ces
protections. Son manifeste immuable est lié au journal25. La sélection
souterraine25 vient ensuite : elle respecte les secrets et les sites déjà
réservés. Les projections de sol admises sont partagées entre les colonnes
consultées par les pièces natives et la production du terrain.
Les options **Générer les structures** et **Structures expérimentales**
contrôlent les secrets expérimentaux, les satellites et les nouveaux lieux
souterrains. Leur désactivation n'enlève pas le terrain naturel, les cavités,
les lacs, les minerais ou leur végétation. Les structures natives générales
restent soumises à l'option Minecraft **Générer les structures**. La grande
traversée et la salle d'expansion conservent leur règle antérieure : elles
restent recherchées avec cette option Minecraft active, même si les structures
expérimentales sont désactivées. Ces options sont enregistrées avec le monde.
Sans structures expérimentales, le catalogue général conserve ses candidats
de mines et de huttes de surface ; les salles de monstres des biomes restent
également possibles. Les recherches supplémentaires du plan25 sont désactivées,
notamment les huttes des marais inférieurs à hauteur locale, la sélection de
mine accompagnée d'une cabane et les salles complémentaires planifiées.
La présence des mêmes structures dans les deux configurations n'est donc pas
garantie.
Les calculs naturels sont dérivés de la graine, de la taille et du générateur.
Leurs caches bornés ne dépendent pas de l'ordre de visite : cache de relief
par thread, cache de 32 768 colonnes naturelles par contexte et plans
hydrologiques régionaux réutilisés. La sélection souterraine limite ses
candidats et ses sondages ; un budget épuisé refuse la famille concernée.
Ces bornes n'impliquent pas une durée de chargement garantie.
Les recherches du sommet et de la base naturels excluent des altitudes dont
la densité est mathématiquement non positive. L'érosion inférieure exclut
**Y123** ; l'érosion supérieure exclut les coordonnées ramenées à
`naturalY >= 304`, et le fondu25 exclut Y358 et au-delà. Le balayage commence
ainsi au plus à **Y303 sans étirement**, ou **Y357 avec relief**, puis s'arrête
au plus bas à Y24. Les arrondis conservent exactement le calcul de
`naturalY`. Les bosquets réutilisent ce sommet naturel mis en cache comme
plafond supplémentaire de leur recherche ; les nouvelles cavités ne peuvent
que retirer de la matière sous ce plafond. Les hauteurs trouvées et les
profondeurs utilisées pour les filons restent identiques.
La matrice native Mac du 11 septembre 2026, après `DryFit`, vérifie ces bornes
sur **864 colonnes** et **2 592 plafonds de recherche des bosquets**. Les
résultats des anciens et nouveaux balayages sont identiques : 253 651 contre
197 616 sondages pour les extrema naturels (**22,1 %**), et 223 403 contre 6 501
pour les recherches de bosquets (**97,1 %**). Ces nombres concernent ces
boucles précises, pas une réduction mesurée du chargement complet du monde.
La même matrice valide 16 584 positions de sol après les nouvelles coupes :
**26 planchers certifiés sur 33 propositions sèches**. Les grandes cavités
sèches sont certifiées sur 512/graine 0 et 512/graine 42 ; elles restent refusées
sur les sept autres témoins. Ces contrôles utilisent les samplers natifs et
ne constituent pas des essais de blocs chargés, de fluides sous ticks ou de
villages fonctionnels. Ils n'apportent aucune validation Windows.
## Monument garanti des nouvelles expansions océaniques25
Avec **Générer les structures** activé, toute nouvelle expansion25 de type
`ocean`, de diamètre **512 ou 1 024**, doit trouver un monument natif adapté
**avant sa réservation**. La recherche s'inscrit dans le délai d'admission
existant de 30 secondes. Si aucun départ ne convient, la demande est refusée
sans publier cette expansion. Une expansion non océanique garde l'admission
d'au moins une structure native adaptée, avec une priorité souple aux
familles absentes des régions réellement vérifiées.
Le départ exact du monument, ses pièces, son enveloppe, son algorithme et
son empreinte SHA-256 sont figés dans `structure_plan25`. Le journal25 utilise
le schéma3 et inclut ces données dans l'identité du certificat de réemploi
du vide. Les champs restent absents des descriptors historiques. La reprise
valide et restaure ce départ ; elle ne retire pas un autre monument au hasard.
Le même départ sert à la génération réelle et à la localisation. Son volume
protégé empêche d'autres départs de le recouvrir. Le comportement des pièces,
des gardiens et des grands gardiens reste celui de Minecraft. Le plan n'est
pas un mécanisme de restauration après destruction. Une expansion encore
réservée ne devient pas localisable comme si sa préparation était terminée :
la localisation attend son véritable état `ready`.
Lorsqu'une carte d'exploration sélectionne un départ avec `skipReferenced`,
le compteur natif est incrémenté par `addReference`, puis **seul le chunk du
départ gagnant** est marqué à sauvegarder. Cela permet de conserver la
référence même si ce chunk distant était déjà sauvegardé. Les autres départs
ne sont pas référencés ; une recherche ordinaire n'incrémente aucun compteur.
Ces références servent à la sélection des cartes et ne représentent pas des
visites de joueurs. Le correctif n'ajoute aucune migration et ne reconstruit
pas rétroactivement les compteurs déjà perdus. Le cycle natif corrigé de
création et de rechargement est consigné dans les vérifications de l'alpha.25.
Avec **Générer les structures** désactivé, aucun monument n'est imposé ni
enregistré. Ce changement ne modifie pas les océans et admissions des mondes23
ou24. Le manifeste aérien25 réside dans `data/sanctuary-aerial-v25/` et son
journal dans `data/sanctuary-world-v25/` ; les chemins historiques restent lus
selon leur version.
## Limites et vérifications
Ce document décrit le contrat présent dans les sources, pas une annonce de
publication. Les tests ajoutés couvrent les plans déterministes, la géologie
relative, le rendu minéral par chunks, les réservations d'eau, le codec du
monument et les journaux historiques. Les scénarios natifs de terrain, de
structures conditionnelles et de cycle de vie du monument sont distincts des
tests purs. Les résultats, graines, durées et réouvertures effectivement
exécutés doivent être consignés dans le compte rendu de validation avant
livraison ; aucune matrice native réussie n'est présumée ici.
Cette passe ne fournit pas de boss Sanctuary, de combat de progression, de
portail Cavernes fonctionnel, de nouvelle dimension ni de raid. Les grands
espaces explorables et le cadre central préparent ces usages sans les activer.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

+220 -1
View File
@@ -13,10 +13,229 @@ 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.
## Alpha.24 publiée — 11 septembre 2026
`check build assemblePack` et les essais natifs passent. Le JAR et
`build/Sanctuary-0.1.0-alpha.24.mrpack` sont vérifiés : versions, index,
empreintes et JAR intégré concordent. Minecraft 26.3-pre-2, Fabric Loader
0.19.5 et Fabric API 0.160.0+26.3 sont conservés. Voir
[les vérifications](testing-alpha24.md) et [la génération](generation-alpha24.md).
SHA-256 du JAR :
```text
f045655cef30bbaa93099a24443462f213d7851fecd03b7b00c6ca874c9d9c45
```
La [release alpha.24](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.24)
est publiée depuis le commit source `27d7cba`. Le canal packwiz est au commit
`bd9b4c4`. Le JAR, le
[MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.24/Sanctuary-0.1.0-alpha.24.mrpack)
et le ZIP damorçage ont été vérifiés par téléchargement public.
Deux installations isolées, puis deux synchronisations dans la même instance
Sanctuary Beta réussissent. Un seul JAR Sanctuary alpha.24 est actif ; les
**925 fichiers personnels et réglages suivis** conservent leurs empreintes.
Le dossier reste `Sanctuary-0.1.0-alpha.1`. Sauvegarde ciblée :
`sanctuary-backups/before-0.1.0-alpha.24/`, hors de `mods/`.
Reçus locaux : `build/alpha24-artifacts.json`,
`build/alpha24-isolated-validation.json`, `build/alpha24-prism-validation.json`.
Le jeu est fermé pendant la mise à jour ; aucun monde personnel nest ouvert,
converti ou régénéré. Le lanceur est resté ouvert et ses réglages sont inchangés.
Pour essayer : créer un **nouveau monde Sanctuary**, avec les structures et
loption expérimentale activées. Les cinq lieux sont référencés dans le
[guide des témoins](testing-alpha24.md). Le ZIP damorçage suit le canal stable ;
il ne sert pas à réimporter linstance existante. Aucun essai Windows natif
nest revendiqué.
## Alpha.23.1 locale vérifiée — 11 septembre 2026
Le JAR et `build/Sanctuary-0.1.0-alpha.23.1.mrpack` sont construits et vérifiés
après `check build` et `assemblePack`. Les versions Minecraft 26.3-pre-2,
Fabric Loader 0.19.5 et Fabric API 0.160.0+26.3 sont conservées.
Voir [les validations](testing-alpha23.1.md) et
[le contrat de génération](generation-alpha23.1.md).
SHA-256 du JAR :
```text
906fd2e12be4233676a1a561556508eee7412183698c2cac704dc1191324f136
```
**Publication en attente :** le gestionnaire didentifiants Git ne fournit pas
lauthentification nécessaire. Le canal public reste à lalpha.22 ; aucune
synchronisation de linstance Prism na été effectuée pour lalpha.23.1.
Le MRpack local peut être conservé pour les essais. Le ZIP damorçage préparé
suit encore le canal public alpha.22 et ne doit pas être présenté comme une
installation publiée de lalpha.23.1. Les étapes de publication puis de
synchronisation ci-dessous restent à exécuter après rétablissement de laccès.
## Alpha.22 publiée — 10 septembre 2026
[Sanctuary 0.1.0-alpha.22](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.22)
est publiée depuis le commit source `d6e0c14`. Le canal stable est au commit
`0019178`. Le JAR, le
[MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.22/Sanctuary-0.1.0-alpha.22.mrpack)
et le ZIP damorçage Prism ont été vérifiés par téléchargement public.
SHA-256 du JAR :
```text
10d7ef32ac2e81a977ea4854503bafa29e0bc10929984b76df01d6f5f61f9056
```
Les bâtiments de surface recherchent des terrains adaptés ; la grande traversée
et la salle dexpansion restent des éléments de base, indépendants dune ville.
Les galeries peuvent chercher un détour et de nouveaux accès. Le hall peut
accueillir des égouts ailleurs dans l’île. Les trois tailles natives, le cas OFF,
la réouverture et la comparaison21 passent. `check build assemblePack` passe
avec75 tâches et les huit GameTests généraux. Voir [les essais22](testing-alpha22.md).
Deux synchronisations isolées puis deux dans la même instance Prism réussissent.
Un seul JAR Sanctuary alpha.22 est actif ; les **925 fichiers personnels et
réglages suivis** gardent leurs hashes. Le dossier reste
`Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ». Le JAR21 et le suivi
packwiz sont sauvegardés sous `sanctuary-backups/before-0.1.0-alpha.22/`.
Reçus locaux : `build/alpha22-isolated-validation.json` et
`build/alpha22-prism-validation.json`.
Pour tester : **nouveau monde Sanctuary, Moyen, graine0**. Les portiques de la
traversée sont `-170 253 -98` et `170 254 -98` ; la trappe du hall indépendant
est `2 252 0`. `/sanctuary structures` liste les lieux. Les villes et bâtiments
facultatifs peuvent être refusés. Les graines vérifiées ne prouvent pas la
présence des infrastructures sur toutes les graines ; les recherches restent
bornées. Aucun monde personnel na été ouvert, converti ou régénéré. Aucun
essai client visuel ni essai Windows natif nest revendiqué.
## Alpha.21 publiée — 10 septembre 2026
[Sanctuary 0.1.0-alpha.21](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.21)
est publiée depuis le commit source `bb63fab`. Le canal stable utilisait cette version au commit
`8002fce`. Le JAR, le
[MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.21/Sanctuary-0.1.0-alpha.21.mrpack)
et le ZIP damorçage Prism ont été vérifiés. SHA-256 du JAR :
```text
e528e42d99f9e1ea221f780ea089b49b42a1a4e9e24e4ddd001d045183202c21
```
Palettes sobres, toitures portées, quartiers reliés, placements patrimoniaux
assouplis et premières galeries industrielles avec fronts rocheux et filons.
Voir le [catalogue](architecture-alpha21.md) et les [essais](testing-alpha21.md).
`check build assemblePack`, les huit GameTests généraux, les créations et
réouvertures natives passent. Le cas expérimental OFF passe. Les fichiers
historiques et les témoins natifs de la génération20 sont conservés.
Deux synchronisations isolées puis deux dans la même instance Prism réussissent.
Un seul JAR Sanctuary alpha.21 est actif ; les **925 fichiers personnels et
réglages suivis** gardent leurs hashes. Le dossier reste
`Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ». Le JAR20 et le suivi
packwiz sont sauvegardés sous `sanctuary-backups/before-0.1.0-alpha.21/`.
Reçus locaux : `build/alpha21-isolated-validation.json` et
`build/alpha21-prism-validation.json`.
Pour tester : **nouveau monde Sanctuary, Moyen, graine0**, **Structures
expérimentales** activées dans **Personnaliser**. Entrées des galeries :
`-135 253 -178` et `-135 249 162`. `/sanctuary structures` liste les lieux.
Les raccords dépendent du relief ; certains sites restent isolés et les réseaux
souterrains ne sont pas tous reliés. Aucun monde personnel na été ouvert,
converti ou régénéré. Aucun essai Windows natif ni essai visuel client nest
revendiqué. Une sauvegarde21 nécessite le binaire21 ou un futur binaire compatible.
## Alpha.20 publiée — 10 septembre 2026
[Sanctuary 0.1.0-alpha.20](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.20)
est publiée depuis le commit source `0edac29`. Le canal stable utilisait cette
version au commit `4e0967d`. Le JAR, le
[MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.20/Sanctuary-0.1.0-alpha.20.mrpack)
et le ZIP damorçage Prism ont été vérifiés par téléchargement public.
SHA-256 du JAR :
```text
9ea10576e641d83c13e1277960a375448a22d416a98a6b327c4d6d2a204c423a
```
Passe de détails sur six bâtiments de ville, quatorze infrastructures,
espaces publics et réseaux souterrains : palettes propres aux usages,
boiseries, tuf ciselé, corniches, biseaux, verrières et raccords des vitres.
Voir le [catalogue illustré](architecture-alpha20.md).
`check build assemblePack` passe, avec huit GameTests généraux. Les créations
et réouvertures natives conservent les structures, accès, supports et coffres.
Le bassin et les sorties d’égout restent fonctionnels. Les rapports de la
version19 restent identiques à ceux de la release publiée. Le mode désactivé
et une petite île passent ; les quatorze infrastructures sont rencontrées sur
les deux témoins. Les limites des raccords au terrain sont détaillées dans
[les preuves alpha.20](testing-alpha20.md).
Deux synchronisations isolées puis deux dans la même instance Prism Mac
réussissent. Un seul JAR Sanctuary alpha.20 est actif ; les **925 fichiers
personnels et réglages suivis** conservent leurs hashes. Le dossier reste
`Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ». Le JAR19 et le suivi
packwiz sont sauvegardés dans `sanctuary-backups/before-0.1.0-alpha.20/`.
Reçus locaux : `build/alpha20-isolated-validation.json` et
`build/alpha20-prism-validation.json`.
Pour voir cette architecture, créer un **nouveau monde Sanctuary, Moyen,
graine 0**, avec **Structures expérimentales** activées dans **Personnaliser**.
`/sanctuary structures` liste les entrées. Petit, graine42, possède la
cartothèque à `-101 251 -25`. Loption désactivée dun autre nouveau monde
conserve la ville sans les réseaux expérimentaux. Les anciens mondes gardent
leur génération ; aucune sauvegarde personnelle na été ouverte, convertie ou
régénérée. Une sauvegarde20 ne se rétrograde pas vers le binaire19.
Les vues du catalogue viennent des modèles et ne sont pas des captures de
partie. Aucun essai client visuel ni essai Windows natif na été effectué.
Les nouvelles fonctions de la bêta restent à construire.
## Alpha.19 publiée — 9 septembre 2026
[Sanctuary 0.1.0-alpha.19](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.19)
est publiée depuis le commit source `c23e72b`. Le canal stable utilisait alors
cette version, au commit `ea883b7`. JAR, [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.19/Sanctuary-0.1.0-alpha.19.mrpack)
et ZIP damorçage Prism ont été vérifiés par téléchargement public.
SHA-256 du JAR :
```text
a159b00a2778020800e81381e0608495a4d7909f1f853f0b8812cd4017865b0b
```
Première passe de quatorze architectures et de leurs voies de fret ou chemins
abandonnés sur l’île initiale. Les trois tailles sont vérifiées nativement ;
les quatorze types ont chacun été rencontrés et parcourus sur ces témoins.
Créer un **nouveau monde Sanctuary, Moyen, graine 0**, puis garder
**Structures expérimentales** activées dans **Personnaliser** : ce témoin
contient treize lieux et sept liaisons, dont un fret de 499 blocs.
`/sanctuary structures` relève les entrées. La présence des bâtiments et les
raccords dépendent du relief ; voir [les preuves et la carte](testing-alpha19.md).
`check build assemblePack` et les huit GameTests généraux passent.
Création et réouverture natives conservent 112 pièces et 13 coffres ; 68 cibles
sont accessibles. Le mode désactivé passe. Les empreintes de la génération 17
restent identiques aux preuves publiées, y compris après réouverture.
Deux synchronisations isolées puis deux dans la même instance Prism Mac
réussissent. Un seul JAR Sanctuary alpha.19 est actif ; les **767 fichiers
personnels et réglages suivis** conservent leurs hashes. Aucune sauvegarde
personnelle na été ouverte, convertie ou régénérée. Le dossier reste
`Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ».
Le JAR 17 et le suivi packwiz sont sauvegardés dans
`sanctuary-backups/before-0.1.0-alpha.19/`. Reçus locaux ignorés :
`build/alpha19-isolated-validation.json` et `build/alpha19-prism-validation.json`.
Pour comparer, créer un autre nouveau monde avec loption désactivée : le socle
urbain demeure, sans les expériences 17/19. Cette option ne supprime pas de
structures dans une sauvegarde existante. Les anciens mondes conservent leur
génération ; une sauvegarde 19 ne doit pas être ouverte avec lancien binaire 17.
Les bâtiments préparent les futurs usages, mais mailbox, caméra, réacteur et
progression restent pour la bêta. Aucun essai visuel dans le client ni essai
Windows natif na été effectué.
## Alpha.17 publiée — 9 septembre 2026
[Sanctuary 0.1.0-alpha.17](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.17)
est publiée depuis le commit source `0969ed5`. Le canal stable utilise cette
est publiée depuis le commit source `0969ed5`. Le canal stable utilisait alors cette
version, au commit `6e0c297`. JAR, [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.17/Sanctuary-0.1.0-alpha.17.mrpack)
et ZIP damorçage Prism ont été vérifiés par téléchargement public.
SHA-256 du JAR :
+258
View File
@@ -0,0 +1,258 @@
# Performances alpha.25 — mesures disponibles
État du relevé : **11 septembre 2026**. Les **neuf créations FULL** de la
matrice 512 / 724 / 1024 × graines 0 / 42 / 2026 ont réussi après les marais 3D.
Les mesures de ces créations sont présentées en premier ; les anciens témoins
et l'hydrologie antérieure sont identifiés séparément. L'ordre inversé et les
deux OFF complètent maintenant les **12/12 scénarios terrain** ; l'hydrologie
finale à 1 800 ticks passe aussi. **Les benchmarks ont été arrêtés à la demande
du joueur, qui souhaite tester le pack.** Ces mesures sont informatives et
ne bloquent pas la fabrication ; aucune comparaison finale n'est requise
ni promise. Voir le [reçu d'arrêt](../build/alpha25-evidence/performance-stopped-user-request.json).
Ce relevé n'annonce ni la fin du build ni un pack.
Les essais ont été réalisés sur **Mac**, avec le serveur Fabric GameTest,
Minecraft **26.3-pre-2** et Java 25, sans client graphique. Ils ne mesurent
ni les FPS, ni les shaders, ni les performances sous Windows. La baseline
alpha.24 ne conserve pas les conditions détaillées de matériel, de heap
et de caches pour chaque scénario.
## Neuf créations FULL après les marais 3D
Les neuf cas passent sur la même empreinte de sources
`4b0d9eb4456dc26ba75686322040eef02187b253db52277d5999a9b6eca36c8d`.
Les [reçus synthétisés](../build/alpha25-evidence/matrix-final-summary.json)
référencent pour chacun le rapport, le log et leurs empreintes. Le manifeste
comprend douze scénarios : **9/9 créations et leurs trois contrôles
complémentaires passent, soit 12/12**. Les contrôles de l'ordre inversé et des
OFF ne sont pas présentés comme neuf mesures supplémentaires de création.
| Taille / graine | Appels FULL | Dans les appels FULL (s) | Boucle jusqu'aux entités prêtes (s) | Attente entités (ticks) | Heap après FULL (Mio) | Invocation complète (s) |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| 512 / 0 | 133 | 49,888 | 64,552 | 396 | 533,0 | 136,525 |
| 512 / 42 | 132 | 31,515 | 43,176 | 237 | 427,4 | 108,908 |
| 512 / 2026 | 115 | 31,427 | 45,224 | 319 | 437,8 | 110,286 |
| 724 / 0 | 138 | 80,599 | 109,251 | 803 | 836,8 | 210,333 |
| 724 / 42 | 129 | 44,222 | 63,178 | 465 | 438,3 | 152,651 |
| 724 / 2026 | 116 | 74,249 | 106,925 | 963 | 458,2 | 197,658 |
| 1024 / 0 | 143 | 105,473 | 141,287 | 1163 | 855,1 | 278,374 |
| 1024 / 42 | 152 | 75,549 | 104,950 | 939 | 850,0 | 249,329 |
| 1024 / 2026 † | 141 | 123,031 | 161,201 | 1084 | 978,2 | 306,724 |
Les appels FULL portent sur des empreintes ciblées différentes, de 115 à 152 chunks,
avec satellites et cavités ; ils ne génèrent pas l'intégralité de chaque île.
`full_get_chunk_ms` inclut l'attente interne des dépendances ;
`full_loop_elapsed_ms` ajoute notamment la cadence du harness et l'attente
des entités. Leur différence n'est pas un chronomètre isolé de cette attente.
L'invocation complète inclut Gradle, JVM, préparation et assertions. Les
rapports n'isolent pas un temps total autonome de planification : les anciens
diagnostics analytiques plus bas conservent leur périmètre propre.
**† Concurrence possible :** le dernier Grand 2026 a pu partager la machine
avec des serveurs de vérification utilisant des sources et mondes isolés.
L'ordre inversé et les deux OFF suivants peuvent aussi être concernés, selon
[le relevé de concurrence](../build/alpha25-evidence/native-concurrency.json).
Aucune de ces durées ne constitue une comparaison contrôlée ; ne pas attribuer
un écart au diamètre ou à une modification du code sur cette seule mesure.
Une comparaison contrôlée demanderait une machine sans autre serveur natif
concurrent ; elle n'est pas poursuivie pour cette livraison.
Ces créations relèvent également l'ordonnanceur vanilla de fluides :
| Taille / graine | Mises à jour de fluide exécutées | Temps écoulé cumulé (ms) | Maximum d'un appel (ms) |
| --- | ---: | ---: | ---: |
| 512 / 0 | 18 | 2,681 | 0,262 |
| 512 / 42 | 88 | 28,087 | 13,281 |
| 512 / 2026 | 2 | 8,157 | 6,964 |
| 724 / 0 | 5 | 13,252 | 7,031 |
| 724 / 42 | 19 | 3,376 | 0,279 |
| 724 / 2026 | 36 | 27,271 | 10,660 |
| 1024 / 0 | 5 | 4,709 | 0,736 |
| 1024 / 42 | 17 | 4,734 | 0,371 |
| 1024 / 2026 † | 3 | 42,271 | 27,550 |
Ce sont des snapshots du serveur de test depuis le début de leur instrumentation,
pas les différences d'une fenêtre hydrologique fixe. Certains fluides hors des
zones activées ne tournent pas ; ces faibles comptes ne prouvent ni l'absence
de cascades ni le coût des bassins entièrement simulés. Ne pas comparer ces
snapshots aux 600 ticks des témoins hydrologiques ci-dessous comme une charge
équivalente. Les chronomètres utilisent du temps écoulé, jamais du temps CPU.
Les pics des pools mémoire restent disponibles dans chaque `create.json` ;
ils ne sont ni simultanés ni additionnables en pic global.
L'ordre inversé 512/0 demande 133 chunks, avec 82,534 s dans les appels FULL et
102,518 s pour la boucle ; son invocation dure 216,802 s. Ces valeurs ne sont
pas comparées en pourcentage à l'ordre normal : concurrence possible,
ordonnancement et différences réelles de certains blocs de terrain rendent
une attribution causale injustifiée. La [comparaison fonctionnelle](../build/alpha25-evidence/reverse-comparison-final.json)
se limite aux plans, satellites, pièces et observations contrôlées ; elle
signale explicitement les différences de comptage dans `terrain_blocks`.
## Hydrologie finale après les marais — observation de 1 800 ticks
Le [témoin 512/0](../build/alpha25-evidence/hydrology-512-0-wetlands-long-final1/hydrology25/512-0/full.json)
passe sur la même source que les neuf créations. Les 176 appels FULL cumulent
**105,781 s** ; la boucle jusqu'aux zones activées dure **173,686 s**, avec 1 712 ticks
d'attente. Le scénario avec 1 800 ticks d'observation dure **227,718 s** ;
l'invocation complète dure 359,184 s. Le heap final occupé vaut 661,1 Mio.
La différence des snapshots contient **20 498 mises à jour de fluides** et
**1 071,803 ms** écoulées dans l'ordonnanceur vanilla, maximum 35,581 ms pour un
appel. Cette fenêtre inclut chargement, attente et observation. Un trajet
connecté atteint Y0, l'autre est observé sur le flanc à Y199 ; les surfaces
des bassins restent complètes. Le détail fonctionnel et les bornes de lecture
sont dans [le compte rendu](testing-alpha25.md).
Ce serveur pouvait tourner en parallèle des scénarios terrain dans une copie
isolée. Les différences de temps face à l'ancien run de 600 ticks ne mesurent
donc ni un coût propre aux marais ni une régression de génération. Les deux
autres scénarios hydrologiques ci-dessous conservent leur date et leur scope
antérieurs ; ils ne sont pas remplacés par cette seule observation prolongée.
## Comparaison antérieure avec l'alpha.24
Les valeurs 25 de ce tableau précèdent les marais 3D ; elles ne remplacent pas
les neuf créations plus récentes.
**Aucun gain global alpha.24 → alpha.25 n'est établi.** Les empreintes de
chunks, le terrain, les recherches et les étapes instrumentées diffèrent.
Une durée absente signifie « non mesurée », jamais zéro.
| Mesure | Alpha.24 | Alpha.25 intermédiaire |
|---|---:|---:|
| Hydrologie région `(0,0)`, diamètre 512, graine 0 | 14,150 s ; 9 421 064 sondes | 9,017 s ; 5 015 684 sondes |
| Création FULL, 512 / 0 | 57 chunks ; durée FULL non mesurée | 133 chunks ; appels `getChunk` 48,017 s ; boucle jusqu'aux entités prêtes 64,657 s |
| Attente des entités, 512 / 0 | 127 ticks | 385 ticks |
| Création FULL, 512 / 42 | 61 chunks ; durée FULL non mesurée | 132 chunks ; appels 30,080 s ; boucle 39,779 s ; attente 192 ticks |
| Deux réouvertures alpha.25, 512 / 0 | Pas de témoin équivalent instrumenté | 133 chunks ; appels 0,601 / 0,593 s ; boucle 5,126 / 5,008 s ; attente 4 / 5 ticks |
| Heap occupé après FULL, créations 512 / 0 et 42 | Non mesuré | 621,5 / 445,8 Mio |
| Diagnostic analytique de neuf contextes 25 | Pas de mesure équivalente | 8,96139,374 s par contexte, préparation et assertions incluses |
Les compteurs de planification hydrologique régionale correspondent au
même périmètre instrumental, mais les formes et résultats recherchés
changent : leur différence ne prouve pas une optimisation à résultat
constant. Les chronomètres des autres planificateurs peuvent contenir des
appels hydrologiques imbriqués ; ils ne doivent pas être additionnés pour
reconstituer une durée totale.
L'alpha.24 conserve aussi des durées d'invocation complètes. Par exemple,
le scénario 512 / 0 dure 62,640 s, dont 10,840 s pour sa suite native.
Ces durées incluent des périmètres différents de la boucle FULL 25 :
Gradle, démarrage de la JVM et du serveur, préparation du monde et
assertions. Les diviser par le nombre de chunks ne donne pas un coût de
génération par chunk.
Sources : [baseline alpha.24](../build/alpha25-evidence/performance-baseline24.json),
[neuf plans25](../build/alpha25-evidence/diagnostics-final1/plans.json),
[terrain512/0](../build/alpha25-evidence/terrain-512-0-create-final1/create.json),
[terrain512/42](../build/alpha25-evidence/terrain-512-42-create-final1/create.json),
[réouverture1](../build/alpha25-evidence/terrain-512-0-reload1-final1/reload1.json)
et [réouverture2](../build/alpha25-evidence/terrain-512-0-reload2-final1/reload2.json).
## Fluides et attente des voisins — avant les marais 3D
| Hydrologie physique 25 | Chunks FULL | Appels `getChunk` | Jusqu'à simulation prête | Attente | Observation | Mises à jour de fluide | Temps cumulé du planificateur |
|---|---:|---:|---:|---:|---:|---:|---:|
| 512 / 0, deux sorties | 176 | 64,337 s | 93,626 s | 880 ticks | 600 ticks | 14 582 | 262,613 ms |
| 512 / 42, zéro sortie | 99 | 31,511 s | 59,161 s | 899 ticks | 600 ticks | 3 213 | 149,320 ms |
| 1 024 / 2026, une sortie | 247 | 135,398 s | 286,287 s | 5 226 ticks | 600 ticks | 24 200 | 451,869 ms |
`full_get_chunk_ms` cumule le temps écoulé dans les appels explicites
`getChunk(FULL)`. Ces appels peuvent eux-mêmes attendre leurs dépendances.
`full_loop_elapsed_ms` inclut ensuite l'attente des voisins et des entités,
ainsi que la cadence du harness. La différence entre ces deux valeurs
n'est donc pas un chronomètre isolé d'attente. Les ticks d'attente ne
doivent pas être convertis en secondes en supposant systématiquement
20 ticks par seconde : la cadence des serveurs de test est configurée.
Le temps du planificateur de fluides utilise `System.nanoTime()` autour de
l'appel vanilla : c'est un **temps écoulé instrumenté**, pas une mesure
de temps CPU. Les valeurs du tableau sont les différences entre les deux
snapshots. Leur intervalle inclut le chargement et l'attente, puis les
600 ticks d'observation. Le nombre de ticks de jeu est distinct du nombre
de mises à jour individuelles de fluides exécutées.
Le premier témoin 512/0 utilisait une baseline filtrée sur les fluides
présents. Un [recomptage indépendant](../build/alpha25-evidence/hydrology-512-0-attempt1/waterline-completeness.json)
a confirmé que les 3 343 / 1 357 / 1 195 cellules prévues étaient toutes
présentes avant et après observation. Il s'agit d'une preuve complémentaire
sur ce run, pas d'une réexécution de la fixture corrigée.
L'alpha.24 vérifiait une cascade d'îlot jusqu'à Y8 après 1 180 ticks
d'attente et une retenue générale pendant 1 800 ticks. Elle ne mesurait
ni le nombre de mises à jour de fluides ni leur temps cumulé. Ces durées
d'observation ne sont pas comparables aux compteurs supplémentaires 25.
Sources : [hydro512/0](../build/alpha25-evidence/hydrology-512-0-attempt1/full.json),
[hydro512/42](../build/alpha25-evidence/hydrology-512-42-final1/512-42/full.json)
et [hydro1024/2026](../build/alpha25-evidence/hydrology-1024-2026-final1/1024-2026/full.json).
## Mémoire, sondages exacts et limites
La mémoire occupée après FULL est un instantané du heap Java, pas la
mémoire résidente du processus. Les rapports 25 conservent également les
pics des différents pools mémoire. Ces pics peuvent survenir à des
instants différents : **ne pas les sommer en pic global**. L'alpha.24 ne
conserve pas de mesure correspondante.
Une optimisation locale 25 possède une comparaison exacte à résultat
identique, sur 864 colonnes natives et 2 592 recherches de plafonds :
- scans de roche naturelle : **253 651 → 197 616 sondes**, soit 22,09 % ;
- plafonds de bosquets : **223 403 → 6 501 sondes**, soit 97,09 %.
Ces chiffres comparent les anciens et nouveaux parcours de sondage sur
le même terrain 25. Ils ne constituent ni une comparaison alpha.24/25,
ni une réduction démontrée du temps de chargement global.
Les témoins spécialisés des marais, avec huit à treize chunks demandés,
contrôlent sols, biomes, plantes et options. Leurs invocations ne constituent
pas un benchmark apparié et ne remplacent pas les chronomètres de terrain.
La comparaison 24/25 prévue sur **289 + 80 chunks fixes**, même taille 724 et
graine 0, a été interrompue avant une paire de résultats complète. Aucun gain
ni coût relatif n'en est déduit. Les critères de heap, ordre des chunks, état
neuf et cadence identiques restent une limite méthodologique, pas un chantier
supplémentaire promis. Aucun résultat Windows ou de rendu client n'est
disponible dans ce relevé.
## Reproduire les chronomètres existants
Le harness natif existant `Terrain25WorldGameTests` mesure les appels
FULL, la boucle jusqu'aux entités prêtes, les attentes, le heap et les
pools, puis fournit un snapshot du planificateur de fluides. Il travaille
dans les mondes de développement jetables du serveur GameTest, jamais
dans une sauvegarde Prism personnelle.
Depuis la racine du dépôt :
```sh
./gradlew :sanctuary:runGameTest \
-PsanctuaryDensityMaps=false \
-PsanctuaryTerrain25Tests=true \
-PsanctuaryTestDiameter=512 \
-PsanctuaryTestSeed=0 \
-PsanctuaryTerrain25Phase=create
```
Pour relire le même monde de test après cette création :
```sh
./gradlew :sanctuary:runGameTest \
-PsanctuaryDensityMaps=false \
-PsanctuaryTerrain25Tests=true \
-PsanctuaryTestDiameter=512 \
-PsanctuaryTestSeed=0 \
-PsanctuaryExpansionReload=true \
-PsanctuaryTerrain25Phase=reload1
```
La seconde réouverture utilise `-PsanctuaryTerrain25Phase=reload2`.
Conserver le rapport JSON et le journal du run avec la révision exacte
des sources. Rejouer ces commandes sur les sources finales produira de
nouvelles mesures ; cela ne rendra pas rétrospectivement identiques les
conditions des témoins intermédiaires. Aucun nouveau benchmark n'est
introduit par ce document.
Les liens vers `build/` désignent des preuves locales ignorées par Git ;
ils ne constituent pas des artefacts publiés du dépôt.
+27
View File
@@ -25,6 +25,33 @@ Sources locales : [planificateur17](../mods/sanctuary/src/main/java/fr/koka/sanc
[rendu17](../mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsRenderer17.java),
[preuves alpha.17](testing-alpha17.md), [vision](vision.md).
## Ligne de conception retenue après cet audit
**Minecraft comme archéologie dun autre Minecraft.** Steve, Ari, Sunny, Kai,
Zuri, Alex, Mefe, Makena, Noor sont les personnages disparus de la partie. Ils
ont découvert quelque chose de révolutionnaire et ont continué parce que ça
marchait, jusquau moment où ça na plus marché.
> Ils nont pas ouvert quelque chose quils nauraient pas dû ouvrir. Ils ont
> construit quelque chose quils ne savaient plus refermer.
Chaque ruine doit conserver la trace dune ancienne solution : une infrastructure
dont lauteur a progressivement disparu derrière son fonctionnement. Ce canon
ne définit pas la découverte, les rôles individuels ou une catastrophe confirmée.
Il guide les prochaines structures ; il najoute aucun personnage, récit jouable
ou système de progression aux versions déjà livrées. L’état alpha.17 décrit
ci-dessus reste le constat historique de cet audit.
Pour valider la conception dun lieu, relever concrètement :
- **Un usage ancien lisible** dans les pièces, les outils et les circulations.
- **Une implantation fonctionnelle** expliquée par le terrain, une ressource
ou une destination réellement présente.
- **Des traces dentretien, dextension ou de chantier** qui montrent comment
linstallation a été utilisée et adaptée.
- **Un lien futur à la progression**, explicitement décrit comme une intention
tant que son système et ses interactions ne sont pas implémentés.
## Faire comprendre les usages avant dintroduire leurs systèmes
Chaque lieu devrait répondre à trois questions : pourquoi était-il construit
+192
View File
@@ -0,0 +1,192 @@
# Sanctuary — définir les lieux avant de reprendre la génération
**Document de travail, en discussion.** Ticket WG-26, branche
`codex/conception-territoire`. Ce document prépare la prochaine passe ; il ne
décrit pas une nouvelle version livrée. Lalpha.22 reste la version publiée.
Le périmètre est lorganisation de Sanctuary Island, la sélection des vestiges,
leurs usages, leurs relations et les besoins des futurs ordinateurs qui
influencent leurs espaces. Les règles détaillées des autres systèmes de la
bêta restent dans leurs futurs chantiers.
## Décisions retenues dans la discussion
| Sujet | Décision |
| --- | --- |
| Arrivée | Commencer dans un endroit naturel et découvrir les premières installations en explorant. |
| Présence ancienne | Retenir quelques lieux isolés : observatoire, salle dexpansion et atelier caché, avec la grande traversée souterraine. Renforcer leur importance et leur mystère. |
| Villes et regroupements | Garder villes, clusters, satellites et autres bâtiments en réserve. La proposition dun noyau garepostedépôt nest pas la sélection actuelle. |
| Réappropriation | Les fonctions des ruines sont reproductibles dans les installations des joueurs, avec quelques lieux collectifs uniques encore à définir. |
| Expansion | La grande salle ancienne est rééquipable. Les joueurs peuvent construire une autre installation dexpansion ailleurs ; la salle historique nest pas un monopole géographique. |
| Donjon majeur | Ajouter au moins un donjon difficile, essentiel à la progression, avec un boss et un objet de quête. Son objectif précis, son boss et son fonctionnement restent à définir. Dautres tunnels sont souhaités ; leur organisation reste ouverte. |
| Architecture | Préférer les blocs de verre pour les grandes baies. Conserver les matériaux sobres appréciés ; rendre les usages lisibles dans les volumes et leurs abords. |
| Récit | Minecraft comme archéologie dun autre Minecraft : chaque ruine témoigne dune ancienne solution, dinstallations utilisées et de chantiers interrompus. |
| Méthode | Définir ensemble avant de reprendre le code. Aucun nouveau binaire, aucune modification de monde ou mise à jour dinstance dans ce chantier documentaire. |
La demande antérieure de conserver **la grande traversée souterraine et la salle
dexpansion indépendamment dune ville** reste une contrainte de travail. Leur
place exacte dans cette sélection restreinte doit être dessinée ; on ne déduit
pas du retrait de la ville la suppression de ses infrastructures souterraines.
La nature de la découverte des anciens joueurs, leurs rôles et la cause précise
de leur disparition restent ouverts. Les neuf noms et le canon sont conservés
dans [la vision](vision.md#minecraft-comme-archéologie-dun-autre-minecraft).
## Ce que change cette direction
Le test alpha.22 Moyen, graine 0, conserve quatorze infrastructures alors que la
ville est refusée. Cela explique quun placement plus exigeant puisse encore
donner une impression d’île très occupée. La prochaine conception doit choisir
une petite sélection avant de chercher ses emplacements : le catalogue entier
nest pas une liste à remplir à chaque génération.
Le projet de regroupements reste disponible pour la suite. La sélection actuelle
privilégie les découvertes isolées et lespace laissé aux nouvelles constructions.
Les bâtiments mis en réserve restent documentés et réutilisables ; cette mise
en réserve ne signifie pas effacer des structures dans des sauvegardes existantes.
## Sélection retenue et variantes en réserve
**Sélection validée :** observatoire, salle dexpansion et atelier caché, avec la
grande traversée souterraine. Un donjon difficile avec boss et objet de quête
sajoute à cette sélection. Les plans et la fréquence des trois lieux restent
à définir. Excavation et piscine sont des variantes en réserve, sans ajout
automatique à la sélection.
| Lieu ou variante | Ancienne solution et implantation | Utilité à définir pour lexploration | Prolongement possible en bêta |
| --- | --- | --- | --- |
| Observatoire | Observer depuis une hauteur avec un ciel dégagé ; instruments et traces de relevés. | Panorama, orientation, emplacement dobservation et objet vanilla cohérent. | Comprendre les étoiles et les constellations, que les joueurs pourront observer et tracer depuis leurs propres lieux. |
| Salle dexpansion | Volume souterrain ayant accueilli une installation collective ; accès et raccordements à comprendre. | Découverte majeure et espace rééquipable. Son état initial et les éléments récupérables restent à choisir. | Assembler une installation dexpansion ici ou ailleurs. Larchitecture exacte des machines reste à définir. |
| Atelier caché | Petit espace personnel aménagé derrière une entrée secondaire ou dans un ouvrage. | Abri, outillage ou équipement utile ; indices dun travail interrompu. Contenu exact à choisir. | Modèle dune installation reproductible ; aucune caméra ou machine encore absente promise dans les coffres. |
| Donjon majeur | Ancienne fonction, implantation et cause de danger à concevoir ; le lieu doit conserver la trace dune ancienne solution. | Objectif de conception : une exploration difficile, une rencontre avec un boss et un objet de quête nécessaire à la progression. Aucune rencontre nest implémentée par ce document. | Étape de progression à choisir ; règles collectives, répétition du combat et attribution de lobjet encore ouvertes. |
| Excavation rare — alternative | Ancien front dextraction, lié à une masse rocheuse et à un gisement réel. | Descendre dans des grottes contenant des ressources encore exploitables. Richesse et rareté à équilibrer. | Extraction, transport et production construits par les joueurs. |
| Piscine ou pool rooms — alternative | Installation hydraulique avec un premier usage identifiable, puis des espaces plus mystérieux. | Exploration, circulations humides et sèches, passages vers les profondeurs. | Éventuel donjon physique ; distinct des Backrooms, espaces artificiels orphelins dont lhistoire et lhébergement restent à concevoir selon la [cosmologie](cosmologie.md). |
La traversée est une infrastructure de circulation, pas un quota de bâtiments
annexes. Ses sorties peuvent être isolées. Les égouts peuvent exister ailleurs
que sous une ville ; leur sélection et leurs liens avec les lieux retenus restent
à décider. Plusieurs réseaux peuvent se rejoindre ponctuellement, conformément
à la direction déjà retenue.
Restent notamment en réserve : logements, hôtel, restaurant, bibliothèque,
caserne, places et parcs urbains ; gare, maintenance, poste, dépôt, cartothèque,
quai, relais, serre, pompage, sous-station, club photo, scierie et autres chantiers.
Ce regroupement sert à organiser la conception, sans modifier leurs identifiants
ou affirmer que chaque nom correspond à un type de structure distinct déjà codé.
## Définir le territoire
Les points suivants doivent devenir des règles explicites avant limplémentation.
Ils nont pas encore de valeurs chiffrées acceptées.
| Règle | Choix à fixer |
| --- | --- |
| Départ naturel | Ce que le joueur peut voir depuis son arrivée et les espaces proches réservés à sa première construction. Traiter aussi les trappes et portiques souterrains. |
| Première découverte | Type dindice, distance dexploration souhaitée, visibilité dun lieu ou de son entrée. Une vue cachée par le relief nest pas équivalente à un simple éloignement. |
| Nombre de lieux | Lieux présents à chaque graine, lieux variables, remplacements possibles et limites de répétition. |
| Espacement | Distances entre destinations, secteurs naturels continus et proximité éventuelle dentrées de sous-sols. |
| Tailles d’île | Base actuelle : Petit 512, Moyen 724 par défaut, Grand 1024. Décider si la sélection reste la même selon la taille ou si seuls les espacements changent. Aucun agrandissement supplémentaire nest décidé. |
| Relations | Pour chaque chemin ou galerie, deux destinations réelles ou une extrémité de chantier lisible ; choix des connexions directes, interrompues et seulement suggérées. |
| Refus | Règle pour un lieu facultatif sans terrain adapté ; recherche dun autre accès ou emplacement pour une infrastructure de base. Les garanties et les budgets devront être conciliés. |
| Ressources | Ce que la ruine offre comme butin et comme matériaux démontables ; ce que le joueur doit encore produire lui-même. |
## Fiche à remplir pour chaque lieu retenu
Une fiche complète doit permettre de dessiner et de tester le lieu sans inventer
sa fonction pendant le codage.
1. **Ancien usage :** quelle solution apportait-il, à quoi et à qui servait-il ?
2. **Implantation :** quel terrain ou quelle ressource justifie sa présence ici ?
3. **Découverte :** premier indice, silhouette, cheminement, entrée principale et éventuel secret.
4. **Plan :** pièces nécessaires, dimensions, cours ou abords, circulations, accès aux sous-sols.
5. **Architecture :** palette, structure porteuse, toiture, vitrage, détails qui expliquent lusage.
6. **État :** parties entretenues autrefois, réparations, chantier arrêté, dommages localisés ; aucun défaut de raccord ne fait office de ruine.
7. **Usage dans la prochaine livraison :** ce quun joueur peut réellement faire dès sa découverte.
8. **Lien futur :** système bêta envisagé, connaissances transmises, éléments reproductibles et éventuelle exception collective.
9. **Valeur :** butin, matériaux récupérables, gisement restant, répétition et effets pour plusieurs joueurs.
10. **Admission et vérification :** placement acceptable, cas de refus, accès praticables, limites deau et de lave, exemples à voir en jeu.
## Ordinateurs et installation dexpansion
**Décision acquise : linstallation est reproductible ailleurs.** Cela ne décide
pas encore son coût, ses prérequis ou les permissions pour ouvrir un continent.
La reproduction dune fonction nimplique pas quelle soit disponible dès le début.
Lutilisateur réfléchit aux éléments suivants : **assembleur, bloc contrôleur,
bloc terminal, bloc de dépôt et ancre spatiale**. Ce sont des concepts à définir,
pas cinq blocs déjà implémentés avec des rôles figés. La vision évoque aussi un
ordinateur 8 bits programmable avec six ports de redstone ; il faut préciser
comment ces éléments se composent.
Avant de fixer le plan intérieur de la salle, décider :
- ce que chaque élément reçoit, conserve, exécute ou affiche ;
- ce que désigne exactement lassembleur et comment le joueur programme ;
- la différence entre terminal informatique et terminal de stockage en titane ;
- la différence entre dépôt de machine, deposit box collective et mailbox personnelle ;
- les connexions physiques et lencombrement dune installation complète ;
- la fonction de lancre : rattachement à un lieu, orientation, destination ou autre rôle à définir ;
- les conditions de découverte, construction et activation, dont les ressources et autorisations côté serveur ;
- ce quil reste dans la salle ancienne : installation incomplète, pièces récupérables, schéma, emplacement ou traces seulement.
On doit pouvoir décrire le parcours « découvrir → comprendre → construire ou
rééquiper → préparer une expansion » avant de dessiner une interface ou de fixer
des recettes. Cette séquence est un outil de conception, pas une campagne imposée.
## Donjon majeur et tunnels supplémentaires
Le donjon doit compter dans la progression et comporter un boss ainsi quun objet
de quête. **Le blocage levé par cet objet nest pas encore choisi** : première
expansion, étape avancée ou objectif collectif final sont des possibilités en
discussion, pas des règles cumulatives.
Sa nécessité impose de concevoir sa présence et son accessibilité sur l’île.
Un lieu indispensable ne peut pas être traité comme une décoration facultative
dont labsence serait sans conséquence. Les contraintes de génération devront
être définies avec cette exigence avant son implémentation.
À préciser ensemble :
- **Fonction ancienne :** quavait-on résolu ou protégé dans ce lieu ? Ne pas décider par défaut que cest le réacteur.
- **Découverte :** indices depuis la surface, tunnels dapproche, entrée principale et éventuels raccourcis.
- **Difficulté :** type de préparation attendu, dangers des salles et possibilités de retraite.
- **Boss :** identité, relation au lieu, comportements reconnaissables et conditions de victoire.
- **Objet de quête :** ce quil est, ce quil débloque et sil est consommé, conservé ou installé.
- **Coopération :** victoire et déblocage à l’échelle du joueur, du groupe ou du serveur ; accueil des joueurs arrivés plus tard.
- **Persistance :** combat unique ou rejouable, partage du butin, conséquence dun objet perdu ou dun site endommagé.
- **Après la victoire :** usage de la structure, exploitation éventuelle des spawners, accès aux galeries et ressources restantes.
La direction antérieure des donjons prévoit des reliques collectives uniques et
des récompenses personnelles, avec des spawners potentiellement exploitables
après exploration. Elle ne fixe pas encore lattribution de cet objet précis.
Des tunnels supplémentaires peuvent accompagner cette destination. Leur nombre,
leurs connexions et leur étendue restent ouverts : on conserve lidée de réseaux
parfois connectés, sans imposer une galerie continue sous toute l’île. La grande
traversée utile aux déplacements et le parcours difficile du donjon doivent être
distingués lors du dessin de leurs accès.
## Décisions ouvertes et ordre de discussion
| Étape | Résultat attendu | État |
| --- | --- | --- |
| 1. Fonctions reproductibles | Règle commune et place des exceptions collectives. | Principe retenu ; exceptions à nommer. |
| 2. Salle dexpansion | Lieu rééquipable et installation reproductible ailleurs. | Retenu ; contenu initial à définir. |
| 3. Sélection | Observatoire, salle dexpansion, atelier caché, grande traversée et donjon majeur. | Retenue ; tunnels supplémentaires et fréquences à définir. |
| 4. Objet du donjon | Étape de progression débloquée, puis nature du boss et de lobjet. | Question en cours. |
| 5. Territoire | Arrivée, découverte, quantités, espacements et différences entre les trois tailles. | À définir. |
| 6. Fiches des lieux | Plans, utilité immédiate, indices, matériaux et état dabandon. | À définir pour la sélection retenue. |
| 7. Machines | Rôles et connexions nécessaires pour dimensionner la salle. | Concepts en discussion. |
| 8. Contrat de génération | Garanties, refus, ressources, exemples visuels et critères dessai. | À définir avant de créer les tickets de code. |
Le document pourra alors servir de cahier de conception validé. Chaque future
livraison prendra un périmètre vérifiable dans ce cahier ; une discussion ou une
fiche incomplète nest pas une fonctionnalité implémentée.
## Références et constats
- [Vision](vision.md) : intentions de jeu et canon narratif.
- [Audit des structures](structures-audit.md) : audit historique et catalogue de possibilités.
- [Architecture alpha.20](architecture-alpha20.md) : formes de bâtiments construites lors de cette passe.
- [Essais alpha.22](testing-alpha22.md) : état livré, graines et limites vérifiées.
- Les difficultés Windows signalées restent un sujet de diagnostic distinct ; leur cause nest pas attribuée à la préversion sans preuve.
+173
View File
@@ -0,0 +1,173 @@
# Alpha.19 — anciennes infrastructures de Sanctuary
**État : alpha.19 publiée, même instance Prism synchronisée et vérifiée.** Ticket WG-22, branche
`codex/heritage-worldgen-alpha19`. Minecraft 26.3-pre-2, mêmes dépendances que
lalpha.17. La numérotation 19 est demandée explicitement ; aucune 18 nest
intercalée dans ce dépôt.
## Périmètre et réversibilité
Première passe des quatorze lieux du [catalogue](structures-audit.md) : gare de
marchandises, centre postal, dépôt communal, cartothèque, observatoire,
maintenance ferroviaire, pompage, sous-station, serre, quai dexpédition,
relais radio, club photo, vestiaires/salles étranges et refuge de chantier.
Les plans recherchent des sites admissibles et des voies de fret ou chemins
entre destinations réelles ; ils ne garantissent pas tous les lieux sur chaque
graine. Les mesures et coordonnées des essais figurent ci-dessous.
Créer un **nouveau monde Sanctuary** et utiliser **Personnaliser** :
Petit 512, Moyen 724 par défaut ou Grand 1 024, avec **Structures expérimentales**.
Loption est activée par défaut et enregistrée avec le générateur 19. La désactiver
pour un autre nouveau monde conserve le socle urbain sans les réseaux
expérimentaux 17 et les infrastructures 19. Elle ne retire aucun bloc ni ne change
les règles dune sauvegarde existante.
Les mondes 17 et antérieurs gardent leurs codecs, plans, pièces et futurs chunks.
Le preset 17 public est conservé octet par octet sous un identifiant historique
caché à la création. Le générateur 19 et son journal sont distincts. Il nexiste
aucune conversion ou rétrogradation de sauvegarde 19 vers un ancien binaire.
Lancienne release reste disponible pour ses propres mondes, et loption dun
nouveau monde permet de comparer les deux configurations du nouveau binaire.
Les nouveaux lieux sont réservés à l’île initiale. La ville et les anciens
réseaux servent de base ; leurs protections et celles de lhydrologie restent
respectées. Les liaisons vers de futurs continents seront construites après
leur apparition, sans présumer leurs emplacements dans cette génération.
## Objets et systèmes
Les bâtiments suggèrent leurs usages futurs par leurs pièces et aménagements.
Mailbox, deposit boxes, caméra, réacteur, progression et récompenses
personnelles ne sont pas activés. Chaque nouveau lieu dispose dune table
de coffre propre à la génération 19, avec un outil vanilla lié à son usage et
quelques fournitures. Les coffres sont partagés et ne se renouvellent pas.
La valeur des rails, équipements et blocs récupérables du décor sajoute au
butin. Leur distribution doit être constatée sur les témoins, sans transformer
cette première passe en une garantie d’équilibrage économique pour la bêta.
## Vérifications effectuées
- Placement des quatorze architectures, ouvertures, circulation, orientation
et appuis du mobilier ; accès réel au coffre et retour possible.
- Plans bornés sur les trois tailles, terrain non occupé, supports, continuité
des tracés et distinction entre ballast praticable et rails retirés.
- Pièces natives et références locales, y compris pour les longues voies ;
géométrie, orientation et rails sauvegardés dans un schéma strict.
- Création et réouverture dun monde 19 avec modification témoin ; conservation
des starts, butins différés, accès, routes et supports.
- Option désactivée, paramètres sauvegardés, génération 17 comparée aux preuves
de la version publiée, refus des migrations implicites de journaux.
- `./gradlew check build assemblePack`, artefacts et index, publication immuable
puis canal packwiz et deux synchronisations de la même instance Prism.
Les smokes des quatorze architectures, de leurs quatre orientations, des
plans sur les trois tailles et des pièces natives passent. Le mode désactivé
est vérifié dans un monde 19 neuf : aucun site ni liaison expérimental.
`./gradlew check build assemblePack` passe, avec les huit GameTests généraux,
125 chunks FULL, 5 923 blocs deau conservés et 1 800 ticks de fluides.
Le JAR, le MRpack, les hashes du pack et le ZIP Prism sont vérifiés.
La release et le canal packwiz sont publiés. Deux synchronisations isolées puis
deux dans la même instance Prism passent ; 767 fichiers personnels et réglages
suivis conservent leurs hashes. Le JAR précédent et le suivi packwiz sont
sauvegardés dans `sanctuary-backups/before-0.1.0-alpha.19/`.
Voir [le reçu de distribution](packwiz.md#alpha19-publiée--9-septembre-2026).
Aucun essai visuel dans le client ni essai Windows natif na été effectué.
## Témoin natif Moyen, graine 0
Créer un nouveau monde avec **Structures expérimentales activées**.
`/sanctuary structures` liste les entrées prévues sans charger de chunks ;
`/locate structure sanctuary:heritage_networks` retrouve lentrée dun site.
Le relevé ci-dessous provient des placements et parcours vérifiés par le moteur.
![Entrées et tracés du témoin alpha.19](images/heritage19-seed0.svg)
Treize des quatorze lieux apparaissent. **La cartothèque est absente de cette
graine** : la recherche na pas trouvé demplacement admissible. La ville et les
trois réseaux souterrains 17 sont conservés. Les raccords recherchés vers la
ville et lancienne mine ne sont pas admis sur ce témoin.
Les sept liaisons comprennent un tracé de fret **dépôt → pompage de 499 blocs**,
dune étendue de 245 blocs sur X et 181 sur Z. Son ballast est praticable ;
478 rails sont présents et 21 sont volontairement manquants. Les autres voies
et chemins relient gare, dépôt, atelier, poste, club photo, serre et vestiaires.
Le quai, lobservatoire, le relais, la sous-station et le refuge gardent leurs
propres accès au terrain, même sans liaison avec ce réseau.
La création et la réouverture dans deux JVM distinctes passent chacune les deux
GameTests : **112 pièces natives, 111 chunks FULL, 80 487 blocs contrôlés,
2 882 contrôles de portance, 13 coffres différés et 68 cibles accessibles**.
Trois tirages de chacune des quatorze tables de butin sont vérifiés
séparément, sans ouvrir les coffres persistants. Une modification témoin à
`-204 / 255 / -13` est conservée à la réouverture.
Empreinte stable du plan :
`7f476c2ecca703a05bf88a550c61cc5cbeff2594024c43ccd9f3620bbc2ad3de`.
JVM de création `36366`, de réouverture `36481`. Reçus locaux ignorés :
`build/alpha19-evidence/heritage19-create.json` et `heritage19-reload.json`.
La planification supplémentaire utilise 15 144 colonnes natives, dans un plafond
de 16 000, en 20,7 s sur le Mac de développement. Ce temps concerne ce plan,
pas le chargement total du jeu. Les routes sont découpées par chunk ; aucun
start ne couvre toute leur longueur ni ne charge une destination lointaine.
La prospection 19 consulte les plans hydrologiques de toute lenveloppe de
départ, avant de publier les sites. Le contrôle général utilise donc pour 19
une limite géographique calculée : 5 régions possibles pour Petit, 9 pour
Moyen/Grand, avec les budgets de colonnes et de sondes contrôlés séparément.
Le témoin Moyen en consulte 5 ; les quatre régions périphériques représentent
3,38 s de calcul hydrologique supplémentaire dans le relevé du build.
Ces consultations ne matérialisent aucun chunk. Les générations historiques
conservent le contrôle initial de 4 régions maximum autour du spawn.
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryHeritage19Tests=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runGameTest -PsanctuaryHeritage19Tests=true -PsanctuaryExpansionReload=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
```
## Autres tailles et couverture du catalogue
Les deux GameTests natifs passent aussi sur Petit et Grand, graine 42.
Les quatorze architectures ont ainsi chacune été rencontrées et parcourues
sur le terrain réel, en complément des rotations vérifiées par les smokes.
| Taille et graine | Lieux | Liaisons | Pièces natives | Chunks FULL |
| --- | ---: | ---: | ---: | ---: |
| Petit 512, 42 | 10 | 2 | 35 | 44 |
| Moyen 724, 0 | 13 | 7 | 112 | 111 |
| Grand 1 024, 42 | 13 | 4 | 47 | 61 |
Pour visiter la cartothèque : **Petit, graine 42**, entrée
`-101 / 251 / -25`, ou **Grand, graine 42**, entrée `-252 / 249 / 146`.
Le Petit testé na ni poste, ni observatoire, ni atelier ferroviaire, ni quai.
Le Grand testé na pas de quai. Aucun grand axe nest admis sur ces deux témoins :
les portées maximales de leurs liaisons sont respectivement de 32 et 55 blocs.
Le témoin Moyen, graine 0, reste celui conseillé pour découvrir le grand fret.
Ces résultats rendent explicite la variation de cette première passe ; ils ne
constituent pas une garantie de présence ou de connectivité pour chaque graine.
Reçus locaux ignorés : `build/alpha19-evidence/small42-create.json` (JVM 36591)
et `large42-create.json` (JVM 36669).
## Compatibilité 17 vérifiée
Deux JVM de développement distinctes créent puis rouvrent le témoin 17
**Moyen, graine 0**, avec le nouveau binaire. Les deux GameTests passent dans
chaque exécution. Le codec, les starts natifs, les butins différés et les
parcours restent identiques aux preuves de lalpha.17 publiée. Le bloc dor
placé par le test est conservé à la réouverture.
Empreinte du plan 17 :
`7358ecd7841c03be1977a7f45f37c0c3ef83a32dc27e8d3ec4f0d85d6ee5d3ef`.
Les trois starts restent ceux documentés dans [le témoin 17](testing-alpha17.md).
Reçus locaux ignorés : `build/alpha19-evidence/legacy17-create.json` et
`legacy17-reload.json`.
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryRuins17Tests=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runGameTest -PsanctuaryRuins17Tests=true -PsanctuaryExpansionReload=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
```
Ces commandes visent uniquement le monde de développement ignoré. La première
le recrée ; la seconde conserve ce même monde et lit ses preuves précédentes.
+327
View File
@@ -0,0 +1,327 @@
# Alpha.20 — identité architecturale
**État : alpha.20 publiée, même instance Prism synchronisée et vérifiée.**
Ticket WG-23, branche `codex/architecture-alpha20`.
Minecraft 26.3-pre-2, dépendances identiques à la version 19 publiée.
## Intention
Chaque fonction reçoit une palette et une composition propre : bois clairs et
grandes baies des lieux publics, pierre et tuf des installations techniques,
loggias et retraits des logements. Les détails utilisent les blocs vanilla :
tuf ciselé, briques de tuf ciselées, briques de pierre sculptées, bois et
planches de chêne/bouleau, granit, diorite et andésite. Moulures, liserés,
encadrements, murs bas et corniches utilisent escaliers, dalles et trappes.
Les fondations restent en briques de pierre. Le [catalogue architectural](architecture-alpha20.md)
décrit les formes et les matériaux effectivement construits pour chaque usage.
La passe concerne les quatorze infrastructures, la ville et ses sous-sols,
ainsi que les petits réseaux oubliés. Elle ne déplace pas les sites ni les
routes pour obtenir ses silhouettes. Les plans 19/17/16 servent demprises
certifiées aux nouveaux rendus 20.
## Connexions des blocs
La géométrie complète dune pièce est assemblée avant le découpage par chunk.
Les états des vitres, grilles, clôtures et murets sont calculés à partir des voisins
réellement présents dans ce plan ; les escaliers reçoivent leurs angles.
Cela évite d’écrire des vitres sans raccord malgré leur juxtaposition, ou de
dépendre de lordre de chargement des chunks voisins. Aucun chunk extérieur
nest chargé pour compléter ces états.
## Sauvegardes et réversibilité
Créer un **nouveau monde Sanctuary** pour voir la génération 20. Le bouton
natif **Personnaliser** garde Petit 512, Moyen 724 et Grand 1 024.
**Structures expérimentales** reste activé par défaut, enregistré avec le
monde ; le désactiver dans un autre nouveau monde garde la ville sans les
réseaux expérimentaux. Les détails urbains de la version 20 restent présents.
Le nouveau codec `sanctuary:island_v20`, son journal et ses types de pièces
distinguent les nouveaux rendus des précédents. Les mondes 19 et antérieurs
gardent leurs codecs, leurs pièces et leurs futurs chunks. Aucun monde
personnel nest ouvert ni rénové par la mise à jour. Une sauvegarde 20 ne se
rétrograde pas vers un ancien binaire ; la release 19 reste disponible pour
ses propres mondes.
## Vérification avant livraison
- Parcours des entrées, pièces et coffres, quatre orientations, marches,
supports et limites d’écriture pour chaque type.
- Connexions des vitres et grilles, angles descaliers, comparaison du rendu
complet et du rendu découpé selon plusieurs ordres de chunks.
- Création et réouverture natives, option désactivée, ancien témoin 19.
- `check build assemblePack`, intégrité des artefacts, publication immuable,
deux synchronisations isolées puis deux dans la même instance Prism.
## Reçus natifs : infrastructures et réseaux oubliés
**Création et réouverture réussies**, dans deux JVM distinctes, pour un monde
jetable **Moyen 724, graine 0**, structures et structures expérimentales activées,
codec `sanctuary:island_v20`. Les rapports locaux conservés sont
`build/alpha20-evidence/heritage20-create.json` (processus 39381) et
`build/alpha20-evidence/heritage20-reload.json` (processus 39736).
Les journaux correspondants sont `build/alpha20-heritage-create-2.log` et
`build/alpha20-heritage-reload.log`. Ces fichiers de développement restent
ignorés par Git ; les valeurs utiles sont transcrites ci-dessous.
| Vérification | Création | Réouverture |
| --- | --- | --- |
| Sites expérimentaux naturels | 13 types présents sur 14 | Mêmes 13 sites |
| Structures natives des sites et voies | 85 starts, 112 pièces | Mêmes empreintes NBT et références |
| Positions de blocs contrôlées pour sites et voies | 80 487 | 80 487, hors modification témoin autorisée |
| Chunks `FULL` contrôlés pour sites, voies et réseaux oubliés | 120 | 120 |
| Contrôles de roche porteuse | 2 882 | 2 882 |
| Coffres des sites | 13 butins différés, non ouverts | Tables et graines conservées |
| Réseaux oubliés | 3 réseaux, 15 pièces, 7 639 positions contrôlées | Mêmes plans et structures natives |
| Coffres des réseaux oubliés | 3 butins différés, non ouverts | Tables et graines conservées |
| Parcours depuis les entrées | 68 cibles dans les sites et 49 dans les réseaux oubliés | Parcours revérifiés |
| Modification témoin | Bloc dor placé en `-203 255 -13` | Bloc dor conservé |
La cartothèque dexpédition (`EXPEDITION_OFFICE`) na pas trouvé demplacement
sur cette graine. Elle fait partie du catalogue et de ses smokes, mais ce reçu
ne constitue pas une preuve de sa génération naturelle. Les quatorze tables
de butin sont échantillonnées séparément sans consommer les coffres sauvegardés.
Les portes et trappes ouvertes pendant le parcours retrouvent leur état initial.
Aucune expansion ni sauvegarde personnelle nest ouverte dans ces essais.
Le plan des infrastructures conserve lempreinte
`7f476c2ecca703a05bf88a550c61cc5cbeff2594024c43ccd9f3620bbc2ad3de`
entre création et réouverture. Celui des réseaux oubliés conserve
`7358ecd7841c03be1977a7f45f37c0c3ef83a32dc27e8d3ec4f0d85d6ee5d3ef`.
Les pièces passent aussi un aller-retour NBT exact et refusent un numéro de
schéma inconnu.
### Portée des vérifications de connexion
Les états complets de **594 blocs détaillés** des infrastructures sont comparés
au rendu : 362 vitres ou barreaux, 49 clôtures, 18 murets et 165 escaliers.
Sur ce total, **565** sont aussi confrontés aux règles natives de mise à jour
des voisins (`updateShape`), avec les tags du serveur chargés. **Onze raccords
de vitres ou barreaux traversent une frontière de chunk** et restent connectés.
Les **29 autres blocs détaillés** ont un voisinage extérieur au plan : leur
état sauvegardé est bien comparé au rendu, mais ils sont exclus de cet oracle
natif. Le compteur `externalNeighbors=29` désigne ces 29 blocs contrôlés,
non un nombre de voisins uniques ; aucune stabilité complète vis-à-vis de ce
voisinage extérieur nest revendiquée. Les réseaux oubliés passent pour leur
part **211 comparaisons d’états et 211 vérifications natives** : 6 vitres ou
barreaux, 1 clôture et 204 escaliers. Ces résultats sont identiques à la
réouverture.
### Coordonnées du monde témoin
Créer un nouveau monde **Moyen, graine 0**, avec les deux options de structures
activées. Les coordonnées indiquent les pieds à lentrée, pas le centre dun
toit. `/sanctuary structures` liste les lieux ;
`/locate structure sanctuary:heritage_networks`, recherché depuis `0 250 0`
dans le test, désigne lentrée du refuge de chantier.
| Lieu | Entrée X Y Z |
| --- | --- |
| Gare de fret | `-110 252 -144` |
| Dépôt communal | `-75 254 -184` |
| Atelier ferroviaire | `-83 253 -83` |
| Château deau | `164 252 -3` |
| Serre | `173 252 -42` |
| Observatoire | `51 251 210` |
| Quai sur le vide | `184 181 218` |
| Refuge de chantier | `-10 251 -15` |
| Réseau minier oublié | `78 252 179` |
| Salles étranges oubliées | `-194 254 -22` |
| Réseau technique oublié | `116 253 -157` |
Sept liaisons sont parcourues dans le monde natif. La voie dépôtchâteau deau
compte **499 positions**, 478 rails présents et 21 rails volontairement absents,
avec une emprise de 245 blocs en X et 181 en Z. Le test prouve un parcours à pied,
le ballast et les formes de rails sauvegardées ; il ne prouve pas un trajet
ininterrompu en wagonnet sur ces voies abandonnées.
## Reçu natif : ville, expansion et grands sous-sols
**Création réussie ; réouverture encore en cours.** Le rapport
`build/alpha20-evidence/city20-create.json` (processus 40221), associé au journal
`build/alpha20-city-create.log`, couvre deux villes de génération 20 dans un
monde de développement jetable. L’île initiale est **Moyen 724, graine 0**.
Après trois prospections en lecture seule, une expansion de diamètre **256**
est effectivement préparée : `city16_0_2`, graine `9111000990819250332`, centre
`2000 -608`, climat `lost_city`. Les deux premiers prospects sont restés sans
ville et nont pas été activés. Lidentifiant de test historique ne change pas
la version 20 enregistrée dans cette expansion.
| Mesure | Ville initiale | Ville dexpansion |
| --- | --- | --- |
| Pièces natives | 48 | 23 |
| Chunks `FULL` inspectés autour des districts | 43 | 19 |
| Bâtiments parcourus | Immeuble, hôtel, bibliothèque | Immeuble, hall |
| Cibles intérieures et de toit atteintes | 65 | 46 |
| Entrées reliées par les rues et les porches seuls | 5 | 2 |
| Pièces de rue ou pont traversées | 33 | 17, dont un pont |
| Appuis contrôlés | 71 | 3 |
| Coffres gardant leur butin différé, non ouvert | 3 | 2 |
| Témoins de biome urbain | 38 | 19 |
Les **71 pièces** sont issues des plans naturels des deux îles. Le parcours
urbain ne prend aucun raccourci par le terrain naturel, les intérieurs ou les
toits : il confirme la continuité des entrées par les rues dessinées.
La table native dapparition des monstres est vérifiée comme limitée aux
zombies vanilla ; cela ne mesure pas un nombre de zombies effectivement apparus.
Le pont de lexpansion se traverse de `2000 226 -613` à `2000 224 -597`.
Aucune pile de viaduc nest présente dans ces deux districts.
Vingt-quatre pièces de test supplémentaires couvrent la sauvegarde des six
modèles de bâtiments dans quatre orientations. Ce sont des témoins de
sérialisation, pas vingt-quatre bâtiments naturels. Restaurant et caserne,
ainsi que la piscine extérieure, ne sont pas présents dans ces deux villes ;
leurs smokes ne constituent pas une preuve dapparition naturelle ici.
### Grands équipements du témoin initial
| Repère | Géométrie ou coordonnées constatées |
| --- | --- |
| Entrée de limmeuble | `-30 252 21` |
| Entrée de lhôtel | `-8 252 21` |
| Entrée de la bibliothèque | `-52 252 43` |
| Trappe daccès aux sous-sols, position du bloc | `-19 252 41` |
| Grande piscine, emprise du bâtiment | De `-47 224 -26` à `9 236 4` |
| Volume deau du bassin | 50 × 25 blocs, quatre couches ; de `-44 225 -23` à `5 228 1`, avec les marches dans cette emprise |
| Grande salle sèche, emprise | De `-54 228 8` à `-30 237 32`, niveau dentrée 229 |
| Parking, emprise | De `-8 116 8` à `22 237 32` |
| Huit planchers du parking, niveaux Y | `117, 133, 149, 165, 181, 197, 213, 229` |
| Bouche de sortie de l’égout | `-50 117 20` |
La piscine conserve ses 40 appuis, la grande salle 16, le parking 9 et la
sortie d’égout 6. Ces présences sont constatées sur cette graine ; elles ne
sont pas garanties sur toutes les îles.
### Fluides et connexions en création
Lessai observe **120 ticks du monde** après la mise en activité des chunks.
Dix-neuf petits témoins temporaires fermés, placés dans le ciel, démontrent
lexécution réelle des fluides par une descente deau observée dans chaque
chunk concerné. Ils sont retirés avant la sauvegarde et exclus des quantités
deau des structures.
Après cette simulation, le grand bassin garde **4 976 cellules deau** dans
son enceinte, et chaque égout conserve ses cinq cellules contrôlées. Les deux
bouches gardent leur source et produisent de leau à lextérieur. Leur écoulement
rencontre cependant du terrain : premier bloc solide sous les sorties aux
niveaux **113** et **191**. Ce témoin prouve des sorties deau actives, pas une
chute libre continue jusquau vide.
Les chiffres de connexion suivants sont ceux de linspection initiale des
pièces, avant la simulation des fluides. Le contrôle de réouverture ci-dessous complète ces premières mesures.
| États détaillés | Ville initiale | Expansion | Total |
| --- | --- | --- | --- |
| Vitres ou barreaux | 1 457 | 577 | 2 034 |
| Clôtures | 36 | 14 | 50 |
| Escaliers | 1 451 | 699 | 2 150 |
| Comparaisons complètes au rendu | 2 944 | 1 290 | 4 234 |
| Vérifications supplémentaires par règles natives | 2 277 | 1 276 | 3 553 |
| Blocs exclus de cet oracle pour voisinage extérieur | 667 | 14 | 681 |
| Raccords de vitres/barreaux à travers des frontières de chunks | 82 | 22 | 104 |
Les **681 exclusions** gardent leur comparaison d’état au rendu. Elles ne
prouvent pas la stabilité de la connexion face au voisinage extérieur, comme
pour les infrastructures. Aucun muret nest représenté dans cette partie du
contrôle urbain. Le bloc dor témoin est placé en `-38 252 21` et le journal
est sauvegardé avec lempreinte
`e776dcab879458b3268ad34f44a003cb53b8b03cfcb67378559d40732f8ce73f` ;
ils sont conservés à la réouverture.
### Réouverture de la ville et raccords avec le terrain
La réouverture passe dans une seconde JVM (40687), avec **71 pièces natives**,
**5 coffres différés**, **74 appuis**, **111 cibles accessibles**, le bloc dor et
le journal conservés. Reçu : `build/alpha20-evidence/city20-reload.json` ; journal
`build/alpha20-city-reload-2.log`. La piscine et les sorties deau sont revérifiées.
Le test confronte les raccords au voisinage réellement présent. Au coin du
parking `-8 118 8`, les grilles touchent de la deepslate au nord et à louest :
Minecraft ajoute légitimement ces raccords après une mise à jour de blocs.
Le contrôle refuse tout changement de matériau, orientation, demi-bloc ou
waterlogging ; seules les propriétés de raccord peuvent suivre les règles
natives. À lintérieur dune géométrie connue, l’état du rendu reste exigé.
Sur les **4 234 blocs détaillés**, **4 178** sont alors des états stables selon
loracle Minecraft ; **640** de ces états ont un voisinage extérieur ou un
voisin ayant lui-même évolué. **56** autres gardent exactement leur état de
plan et attendent une mise à jour native extérieure : ils sont comptés
séparément, sans revendiquer leur stabilisation. Les **104 raccords entre
chunks** restent présents. `externalNeighbors=696` compte les blocs dont le
voisinage nest plus entièrement identique au plan, dont les 640 + 56 cas ;
ce compteur a donc une portée plus large que les 681 exclusions initiales.
## Compatibilité avec la version 19 publiée
Avec le binaire 20, les deux rapports natifs 19 sont **identiques intégralement**
aux reçus publiés, en retirant seulement le PID et le chemin du monde de test.
La comparaison porte sur plans, pièces, paramètres, parcours, supports, butins
et modification témoin, à la création comme à la réouverture.
- Création canonique SHA-256 : `3b055a3bc0fc2ddf4146aef035f5103bb7fe20d55a86fa1d4b87aeedc02f7879`.
- Réouverture canonique SHA-256 : `aed0962f8dfbfbbf69da1b2153861801c58a3ea2376e6bbec2ef44b90d555d63`.
- Référence source publiée : `c23e72b8e6fd8308855459f26c08082a68e81b61`.
- Reçus : `build/alpha20-evidence/legacy19-create.json` et `legacy19-reload.json`.
Les renderers, modèles et pièces historiques restent inchangés ; le preset 19
caché est identique octet par octet au preset public de la version publiée.
Les journaux refusent les chargements croisés 19/20 au lieu de convertir un monde.
## Option désactivée et petite île
Le scénario **Moyen, graine 0, option désactivée** passe : plan expérimental
vide, aucun start natif expérimental et aucune entrée localisable. Le paramètre
est enregistré. Reçu : `build/alpha20-evidence/experimental-off.json`.
Le témoin **Petit 512, graine 42, option activée** passe avec dix types de lieux,
35 pièces de sites/voies et 50 chunks `FULL` contrôlés, réseaux oubliés compris.
La cartothèque est présente, entrée **`-101 251 -25`**, et ses cinq cibles sont
accessibles. Les **436 états détaillés** des infrastructures passent loracle
natif, dont un avec voisin extérieur ; vingt raccords traversent des frontières
de chunks. Reçu : `build/alpha20-evidence/small42-create.json`.
Les deux témoins 20 réunis rencontrent et parcourent les **quatorze types
dinfrastructures**. Ils ne garantissent pas leur présence sur toutes les îles.
La génération native 20 Grand nest pas rejouée ici : ses réglages sont couverts
par les contrôles de codec et les plans de placement conservés. Les six modèles
urbains sont vérifiés dans les quatre orientations ; leur apparition naturelle
est distinguée des fixtures ci-dessus.
## Prévisualisation et état de livraison
Le [montage du catalogue](architecture-alpha20.md) provient des exports de
blocs des renderers et des modèles Minecraft. Il permet de comparer les
silhouettes, matières, escaliers, vitres et corniches. Il ne montre ni le
terrain final ni le rendu dun client avec ses shaders ; ce nest pas une
capture de partie et il ne remplace pas les essais de circulation ou de lumière.
| Étape | État de ce reçu |
| --- | --- |
| Infrastructures et petits réseaux : création/réouverture natives | Réussi, dans le périmètre détaillé ci-dessus |
| Ville 20, espaces publics et grands sous-sols : essais natifs | Création et réouverture réussies, 71 pièces et 5 coffres conservés |
| Compatibilité avec le témoin 19 publié | Rapports complets identiques à la création et à la réouverture |
| Autres configurations | Petit 512/graine 42 et option désactivée passent ; Grand 20 non rejoué nativement |
| `check build assemblePack` final et intégrité des artefacts | Réussi ; huit GameTests généraux, JAR/MRpack/ZIP et hashes vérifiés |
| Publication, canal packwiz et synchronisations Prism | Réussi ; deux passages isolés puis deux dans la même instance, 925 fichiers personnels et réglages préservés |
`./gradlew check build assemblePack --max-workers=1` passe en 4 min 39 s sur
la machine de développement : 62 tâches, dont 55 exécutées, huit GameTests,
125 chunks `FULL`, 5 923 blocs deau conservés et 1 800 ticks de fluides.
Les tests des sept nouveaux composants 20 et des anciennes générations passent.
Les réglages des trois tailles et leur sauvegarde passent également.
Journal : `build/alpha20-check-build-pack.log`.
Le JAR de production ne contient aucune classe de test. Son identité est
vérifiée dans le MRpack et le pack local ; les index et le bootstrap officiel
Prism sont contrôlés. SHA-256 du JAR :
`9ea10576e641d83c13e1277960a375448a22d416a98a6b327c4d6d2a204c423a`.
Reçu : `build/alpha20-artifact-validation.log`.
Release immuable depuis `0edac29`, canal packwiz `4e0967d` et deux
synchronisations isolées vérifiés. Les deux passages dans la même instance
Prism conservent les hashes de 925 fichiers personnels et réglages. Un seul
JAR20 est actif ; JAR19 et suivi packwiz sont sauvegardés avant remplacement.
Voir le [reçu de distribution](packwiz.md#alpha20-publiée--10-septembre-2026).
Aucun essai client visuel ni essai Windows natif na été effectué.
+140
View File
@@ -0,0 +1,140 @@
# Alpha.21 — contrat et vérification
Ticket WG-24, branche `codex/palettes-reseaux-alpha21`.
Minecraft 26.3-pre-2, Fabric Loader 0.19.5, Fabric API 0.160.0+26.3,
Java 25. Les dépendances restent celles de lalpha.20.
## Résultat recherché
La ville reprend une palette de pierre lisse, pierre, briques de pierre,
andésite et chêne. Les plans intérieurs sont conservés ; les corrections
portent sur les matériaux, les appuis et les couvertures. Chaque infrastructure
emploie un bois cohérent. Le tuf reste surtout au sol et dans les sous-sols.
La ville peut accueillir deux petits quartiers supplémentaires reliés à son
cœur, avec du terrain naturel entre eux. La réserve centrale existante nest
pas agrandie. Les infrastructures reçoivent davantage de chemins entre des
entrées réelles. Les recherches respectent des budgets de colonnes et refusent
un quartier si sa liaison entière ne peut pas être construite.
Les galeries industrielles expérimentales ont cinq blocs de chaussée et un
trottoir de chaque côté, des salles de maintenance et deux accès par escaliers.
Le plan est recherché après les structures précédentes et évite leurs volumes,
lhydrologie. Un tablier continu peut franchir une cavité entre ses appuis ;
les travées sans appuis complets sont limitées à 34 blocs, avec des piliers
locaux dau plus 16 blocs et deux couches de roche intacte sous les appuis. Chaque tronçon a une pièce native
locale ; aucun démarrage de structure ne charge toute l’île.
## Sauvegardes et comparaison
Créer un **nouveau monde Sanctuary**, taille **Moyen**, graine **0**, avec
**Structures expérimentales** activées. Petit 512, Moyen 724 et Grand 1024 restent
les tailles proposées par le bouton natif **Personnaliser**.
Le nouveau codec est `sanctuary:island_v21`. Les codecs et pièces 20 et
antérieurs restent disponibles, sans modifier leurs rendus. Le journal 21
réutilise le schéma de persistance existant avec une génération distincte ;
les ouvertures entre versions incompatibles sont refusées. Aucune conversion
ni rénovation automatique des mondes précédents nest effectuée.
Pour comparer sans les infrastructures, créer un autre monde avec loption
expérimentale désactivée. Une sauvegarde 21 nécessite le binaire 21 ou un futur
binaire compatible ; elle ne se rétrograde pas en remplaçant simplement le JAR.
## Compatibilité vérifiée
373 fichiers de production historiques sont identiques au commit de publication
20 (`0edac29`). Le preset caché `sanctuary_v20` est une copie exacte de son
ancien preset public. Création et réouverture natives de la génération 20 donnent
les mêmes plans, structures, appuis, accès et coffres que les rapports publiés.
La comparaison ignore seulement lidentifiant du processus, le chemin du monde
et deux nouveaux compteurs de détail ajoutés à loracle de connexion des tests.
## Galeries vérifiées dans Minecraft
Moyen, graine 0 : 23 modules dont deux accès et une salle, sur un axe de 340 blocs.
Les entrées sont `-135 253 -178` et `-135 249 162` ; chaussée à Y 216. La portée
sans appuis complets la plus longue du témoin est 17 blocs. Le plan emploie
8 686 colonnes de bruit, avec un budget maximal de 12 000 ; cette recherche prend
environ 11 secondes sur la machine de développement, hors reste du monde.
La création et la réouverture dans deux JVM distinctes passent : 34 chunks
FULL, 57 635 blocs vérifiés, mêmes pièces natives, mêmes coffres différés et
modification témoin conservée. Loracle de collision de Minecraft vérifie un
corps de 0,6×1,8 bloc, 74 cibles et 520 pas entre les deux portiques ; il utilise
les vrais escaliers et les demi-marches des trottoirs.
Une alcôve dexcavation inachevée ouvre le parement de la salle sur une paroi
irrégulière de tuf, pierre et deepslate. Certains tronçons exposent de petits
filons ordinaires. Le témoin contient cinq minerais de charbon, dix-neuf de
fer, cinq de redstone, un de diamant et un d’émeraude ajoutés dans ces fronts.
Cuivre, or et lapis font aussi partie des variantes possibles. Ce sont des
blocs finis, sans mécanisme de réapparition après minage. Ces quantités excluent
les minerais naturels de l’île et ne constituent pas une garantie par graine.
## Autres vérifications
La création et la réouverture natives de la ville passent. Le témoin possède
un cœur et deux quartiers reliés, 65 pièces de rue, trois immeubles de logements,
un hôtel, une bibliothèque, un jardin et une place. La réserve centrale nest
pas étendue. Les deux nouveaux chemins sont longs de 51 et 38 cellules. Une
expansion de test distincte est également vérifiée ; aucune expansion personnelle
nest activée.
La piscine couverte, le garage, la salle dexpansion, leurs accès et les égouts
restent présents. Les contrôles natifs vérifient les bassins et sorties après
exécution observée de la simulation des fluides.
Les quatorze types de lieux patrimoniaux apparaissent sur Moyen, graine 0,
avec douze liaisons et 127 segments. Leur création native passe : 141 pièces,
91 271 blocs, 140 chunks FULL et quatorze coffres différés. Les trois réseaux
oubliés sont également parcourus. Les chemins rejoignent deux entrées de ville.
Lobservatoire et le quai du vide restent isolés sur ce témoin : leurs chemins
nont pas trouvé de tracé admissible dans le budget. La commande `/sanctuary structures` indique leurs entrées ; aucun raccord
construit complet nest revendiqué.
Les smokes des renderers, plans, pièces enregistrées et raccords passent.
La réouverture des lieux patrimoniaux conserve également le plan, les pièces,
les coffres et la modification témoin. Loption expérimentale désactivée passe :
elle ne publie aucun module de galerie. `check build assemblePack` passe, avec les huit GameTests généraux. Le
MRpack, son index et le JAR embarqué sont vérifiés. La release et le canal
packwiz sont publiés. Deux synchronisations isolées puis deux dans la même
instance Prism conservent les 925 fichiers personnels et réglages suivis.
Voir le [reçu de distribution](packwiz.md#alpha21-publiée--10-septembre-2026).
Les chemins de quartier regroupent leurs pentes à lintérieur de rectangles ;
leurs frontières et raccords sont des paliers plats. Les structures restent
conditionnées par le terrain admissible, dans un budget borné. La traversée
industrielle ne relie pas encore tous les réseaux souterrains historiques.
Les vues de modèles éventuelles ne remplacent pas une inspection dans le client.
Aucune validation Windows native ni charge multijoueur nest revendiquée.
## Reproduire les contrôles
Depuis ce dépôt, avec Java 25, les mondes de test restent sous `mods/sanctuary/build/`.
Pour chaque scénario, exécuter la création puis sa réouverture avant tout autre
scénario qui nettoie ce monde de développement :
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryTransit21Tests=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --max-workers=1
./gradlew :sanctuary:runGameTest -PsanctuaryTransit21Tests=true -PsanctuaryExpansionReload=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --max-workers=1
```
Remplacer `sanctuaryTransit21Tests` par `sanctuaryLostCity21Tests` pour la ville
ou `sanctuaryHeritage21Tests` pour les infrastructures. Le cas OFF ajoute
`-PsanctuaryExperimentalStructures=false` à une création du scénario transit.
Les rapports de cette livraison sont conservés localement dans
`build/alpha21-evidence/`. `./gradlew check build assemblePack` constitue la
vérification complète avant export.
## Artefacts vérifiés
MRpack : `Sanctuary-0.1.0-alpha.21.mrpack`. Son JAR est identique au JAR du build.
Les versions et hashes de lindex packwiz sont contrôlés, ainsi que le ZIP
damorçage Prism. SHA-256 du JAR :
```text
e528e42d99f9e1ea221f780ea089b49b42a1a4e9e24e4ddd001d045183202c21
```
+153
View File
@@ -0,0 +1,153 @@
# Alpha.22 — placement sélectif
Ticket WG-25, branche `codex/placement-selectif-alpha22`.
Minecraft 26.3-pre-2, Fabric Loader 0.19.5, Fabric API 0.160.0+26.3 et Java25,
sans changement de dépendances.
## Contrat
Les bâtiments de surface redeviennent sélectifs : une ville peut être absente
si aucun emplacement nest adéquat. Deux éléments de base sont prioritaires :
**la grande traversée souterraine et la salle dexpansion**, même sans ville.
Leurs emplacements sont réservés avant les petits réseaux et le catalogue
expérimental. Une recherche bornée ne prouve pas quaucun autre emplacement
possible nexiste dans toute l’île.
- Ville : aucune réserve artificielle de secours, aucun pilier de rattrapage,
terrassement/remblai limité à deux blocs par colonne. Les fondations sont
entièrement recertifiées dans la roche. Les quartiers supplémentaires exigent
un chemin complet vers les quartiers retenus. Au plus trois propositions dans
un budget partagé de20 000 colonnes et1 120 000 contrôles de volume.
- Infrastructures : relief maximal de quatre blocs sur lemprise17×17, coupe et
remblai limités à deux blocs, deux couches de roche intacte sous chaque colonne
de fondation, sans piliers de secours. Jusqu’à24 candidats par famille, avec
les mêmes plafonds globaux de16 000 colonnes et800 000 sondages.
- Chemins patrimoniaux : coupe/remblai limité à un bloc, accès indépendant et
raccord complet requis pour publier une liaison.
- Traversée : conserve le génie civil21, avec deux accès réels, une chaussée
de cinq blocs et deux trottoirs. Les piliers sont bornés à16 blocs ; les
travées sans grille complète sont limitées à34 blocs et reposent sur les
modules adjacents. La recherche22 intervient avant les structures facultatives
pour leur réserver lemprise. Si les lignes droites sont refusées, une seconde
passe cherche des accès différents et des détours. Chaque passe est limitée à
12 000 colonnes et3 millions de sondages (24 000 et6 millions au total au plus).
Ses parois peuvent traverser des cavités existantes sans écraser les volumes
deau et de lave protégés.
- Salle : recherche autonome dun accès de surface et dune salle25×25, sans
faux quartier ni socle de ville. Une salle déjà présente sous une ville évite
un doublon. Les égouts et annexes peuvent accompagner ce lieu indépendant.
Les palettes, les plans intérieurs et les petits filons minables restent ceux
de21. Les anciens petits réseaux expérimentaux17 et les sous-sols des villes gardent
leur géométrie ; leurs protections restent respectées.
Le nouveau principe narratif est documenté dans [la vision](vision.md) et
[laudit](structures-audit.md), sans activer de système de progression.
## Sauvegardes
Le seul preset public utilise désormais `sanctuary:island_v22`. Le preset caché
`sanctuary_v21` est la copie de lancien preset public. Les anciens codecs,
planificateurs et rendus restent disponibles. Les pièces21 sont réutilisées
avec leur format existant ; les nouveaux coudes possèdent une pièce22 distincte
pour fermer leurs murs sans modifier les jonctions21 sauvegardées.
La salle indépendante possède un type de structure22
et réutilise les pièces souterraines20.
Le journal identifie la génération22 et refuse une lecture croisée avec21.
Aucun monde personnel nest ouvert, converti ou régénéré.
Les nouvelles règles nécessitent un **nouveau monde Sanctuary**. Petit512,
Moyen724 par défaut et Grand1024 restent proposés. Loption **Structures
expérimentales** reste enregistrée à la création ; en22 elle retire le catalogue
facultatif, tandis que la traversée et la salle restent des éléments de base.
Le commutateur Minecraft général des structures demeure respecté.
## Témoin natif Moyen, graine0
La création et la réouverture dans deux JVM distinctes passent. La ville est
refusée ; la traversée comporte23 modules, dont deux accès et une salle de
maintenance. Son axe s’étend sur340 blocs. Les accès sont `-170 253 -100` et
`170 254 -100`. Le hall dexpansion indépendant se rejoint par la trappe
`2 252 0` ; son réseau comprend laccès, la salle, deux passages et un égout.
La piscine est une annexe conditionnelle, absente sur ce témoin.
Les14 bâtiments patrimoniaux trouvent un terrain adapté ; sept liaisons sont
retenues. Les115 chunks de lemprise sont générés en statut FULL. Les plans,
pièces natives et une modification témoin persistent après réouverture. Le
contrôle natif de circulation vérifie la vraie trappe, les échelles soutenues,
les ouvertures et le trajet jusquau socle central. Aucun déplacement de joueur
ou chargement dun monde personnel na été utilisé pour ces essais.
## Vérifications complémentaires
**Petit512, graine42 :**17 modules, dont un coude sauvegardé avec sa pièce22,
et une salle indépendante. Les anciens alignements droits sont refusés ; le
repli trouve une paire daccès naturels et conserve une chaussée continue.
Les portiques sont `-68 250 -93` et `68 250 43`, la trappe du hall `43 244 -10`.
La marche native relie les sorties en564 déplacements et atteint53 points de
contrôle. Huit bâtiments patrimoniaux et six liaisons sont admis ; aucune ville
nest forcée. Les88 chunks de lemprise sont vérifiés en statut FULL.
**Grand1024, graine42 :** un quartier naturel est admis avec son réseau de
sous-sols : salle dexpansion, piscine, égout et parking. Aucun hall autonome
supplémentaire nest créé. La traversée comporte31 modules et s’étend sur476
blocs ; portiques `-272 249 -8` et `204 250 -8`. La marche native les relie
en740 déplacements et atteint98 points de contrôle. Quatorze bâtiments
patrimoniaux et huit liaisons sont admis. Les179 chunks de lemprise sont
vérifiés en statut FULL ; la salle et ses17 ouvertures sont accessibles.
Les tests purs des trois tailles passent sur des terrains plats adaptés ;
falaises, eau/protections, vides et appuis insuffisants sont refusés pour les
bâtiments. Le hall autonome et ses annexes sont vérifiés sans fixture de ville.
Les suites historiques restent actives. Avec loption expérimentale désactivée,
le témoin Moyen conserve ses23 modules de traversée et sa salle indépendante,
sans bâtiments patrimoniaux ni petits réseaux expérimentaux. La marche native
relie les deux sorties en548 déplacements et atteint74 points de contrôle ;
les échelles et laccès au hall restent praticables.
Les créations et réouvertures de génération21 sont identiques aux rapports de
la release21, hors identifiants de processus et dossier de test. Les391 fichiers
de production historiques qui ne nécessitent pas de raccord22 restent identiques
octet pour octet ; le preset caché21 est la copie exacte du précédent preset
public. Les masques sont vérifiés à lintérieur et hors des structures.
`check build assemblePack` passe :75 tâches, dont les huit GameTests généraux.
Le JAR, lindex packwiz, le MRpack et le ZIP Prism sont vérifiés. Aucun code de
test nest embarqué dans le JAR. SHA-256 du JAR :
```text
10d7ef32ac2e81a977ea4854503bafa29e0bc10929984b76df01d6f5f61f9056
```
SHA-256 du MRpack :
```text
0b553fcdd8b6a2ec26f72059e428d2cc0ce526f36ef8580dd3e50c1dfa40ec78
```
Les reçus de publication et de synchronisation sont suivis dans
[la distribution](packwiz.md).
Ces témoins vérifient les trois tailles, avec et sans ville ; ils ne constituent
pas une preuve de présence pour toutes les graines. La recherche reste bornée
et refuse un volume protégé ou insuffisamment porté. Aucun essai visuel client
ou essai Windows natif nest revendiqué.
## Reproduire dans le dossier de développement
Avec Java25, création du témoin Moyen puis réouverture du même monde de test :
```sh
./gradlew :sanctuary:runGameTest -PsanctuarySelective22Tests=true -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runGameTest -PsanctuarySelective22Tests=true -PsanctuaryDensityMaps=false -PsanctuaryExpansionReload=true
```
Pour une nouvelle création Petit ou Grand, ajouter `-PsanctuaryTestSeed=42`
et `-PsanctuaryTestDiameter=512` ou `1024`. Le cas OFF utilise
`-PsanctuaryExperimentalStructures=false`. Chaque réouverture doit suivre la
création correspondante, avec les mêmes paramètres. Les rapports sont dans
`mods/sanctuary/build/run/gameTest/diagnostics/selective22-<graine>-<diamètre>/`.
Le témoin historique utilise `-PsanctuaryHeritage21Tests=true`, puis le même
drapeau avec `-PsanctuaryExpansionReload=true`. La vérification de livraison
reste `./gradlew check build assemblePack`. Exécuter ces JVM séquentiellement.
+433
View File
@@ -0,0 +1,433 @@
# Validation alpha.23.1
Cible : Minecraft 26.3-pre-2, Fabric Loader 0.19.5, Fabric API
0.160.0+26.3, Java 25. Branche `codex/natural-secrets-alpha23`, ticket WG-27.
**Livraison locale vérifiée le 11 septembre 2026.** Les neuf cas de la matrice
initiale, les structures natives et leurs réouvertures, les océans échantillonnés
et le cycle réel dexpansion passent. `check build`, `assemblePack` et le MRpack
sont vérifiés. **Publication et synchronisation Prism restent bloquées par
lauthentification Git indisponible.** Le [contrat de génération](generation-alpha23.1.md)
décrit le périmètre ; les limites de chaque preuve sont conservées ci-dessous.
## Environnement et preuves
Les essais utilisent un serveur Fabric GameTest sans interface graphique sur
Mac, dans des mondes de développement ignorés. Aucun monde personnel na été
ouvert ou modifié. **Windows na pas été testé ; aucune validation visuelle
client, avec ou sans shaders, na été effectuée.**
Les preuves locales sont dans `build/alpha231-evidence/matrix.json` et les
rapports `secrets-<graine>-<diamètre>.json` du même dossier. Pour le Moyen,
graine 0, les rapports finaux `final-medium.json` et `final-medium-reload.json`
remplacent le résultat intermédiaire de `secrets-0-724.json` ; leurs durées
figurent dans `postchecks.json`. Les réouvertures possèdent un rapport
`*-reload.json`. Ces fichiers de développement ne sont
pas des artefacts publics versionnés. Les journaux correspondants sont indiqués
dans la matrice ; les assertions sont dans
[Secrets23WorldGameTests](../mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Secrets23WorldGameTests.java).
Les premières exécutions précèdent la dernière optimisation du préfiltre
minéral. La distinction est conservée ci-dessous, avec la reprise du Moyen,
graine 0, et la réouverture du Petit, graine 42, sur le préfiltre final.
## Créations réellement vérifiées en FULL
Les neuf cas ci-dessous ont `passed: true` et un code de sortie nul. Dans ces
témoins, aucun quartier Lost City nest demandé sur l’île initiale ; un
observatoire et un hall indépendant sont toujours présents. Le réseau datelier
caché apparaît dans huit cas sur neuf : il nest pas retenu dans le Petit,
graine 0. Lorsquil est présent, il ne contient que son accès, un corridor et
latelier. Le hall ninclut aucune piscine. Les structures expérimentales sont
activées.
| Graine | Taille nominale | Chunks FULL contrôlés | Modules de traversée | Liaisons patrimoniales | Rails natifs vérifiés | Sanctuaire minéral | État |
| --- | --- | ---: | ---: | ---: | ---: | --- | --- |
| 0 | Petit 512 | 88 | 19 | 2 | 85 | Présent | Réussi avec le préfiltre final ; atelier caché non retenu |
| 0 | Moyen 724 | 85 | 23 | 2 | 68 | Présent | Création et réouverture finales réussies (`final-medium*.json`) |
| 0 | Grand 1 024 | 105 | 35 | 2 | 73 | Présent | Réussi avec le préfiltre final |
| 42 | Petit 512 | 88 | 17 | 1 | 23 | Présent | Création réussie ; réouverture avec le préfiltre final réussie |
| 42 | Moyen 724 | 82 | 23 | 3 | 135 | Présent | Réussi avec le préfiltre final |
| 42 | Grand 1 024 | 125 | 31 | 1 | 43 | Présent | Réussi avec le préfiltre final |
| 2026 | Petit 512 | 85 | 17 | 2 | 36 | Présent | Réussi avec le préfiltre final |
| 2026 | Moyen 724 | 79 | 23 | 1 | 54 | Présent | Réussi avec le préfiltre final |
| 2026 | Grand 1 024 | 107 | 31 | 2 | 58 | Présent | Réussi avec le préfiltre final |
Le nombre de chunks contrôlés correspond aux emprises des structures retenues,
pas à toute l’île. Ces neuf témoins ne garantissent pas le même résultat sur
toutes les graines.
Le nombre de liaisons ne signifie pas que tous les lieux sont reliés : les
tronçons ferroviaires locaux peuvent subsister quand un raccord lointain est
refusé par le terrain.
Les durées du runner consignées pour les neuf créations retenues vont de
68,38 à 148,52 secondes. Elles incluent le lancement de la JVM, la
préparation du monde, la génération FULL des emprises et les assertions.
| Graine | Petit 512 | Moyen 724 | Grand 1 024 |
| --- | ---: | ---: | ---: |
| 0 | 78,86 s | 96,19 s | 148,52 s |
| 42 | 68,38 s | 79,60 s | 113,23 s |
| 2026 | 70,20 s | 89,04 s | 133,05 s |
Le Moyen, graine 0, utilise ici la durée de sa création finale dans
`postchecks.json`, et non les 99,07 secondes de son ancien essai. Sa réouverture
finale prend 57,24 secondes.
**Ce ne sont ni des temps de rendu client, ni un benchmark Windows, ni une
mesure du gain de performance par rapport à lalpha.22.**
## Ce que le GameTest contrôle
- Chaque emplacement accepté possède un vrai départ de structure natif. Les
pièces sauvegardées retrouvent les mêmes données après sérialisation puis
lecture ; leurs emprises sont chargées jusquau statut FULL.
- La traversée est parcourue par un contrôle des collisions natives pour un
volume de joueur de 0,6 × 1,8 bloc. Les montées souterraines exigent un escalier
orienté ; les abords naturels des portiques peuvent comporter un saut normal
dun bloc. La trappe, les échelles et les ouvertures du hall sont contrôlées.
- Toutes les cellules marquées comme portant un rail possèdent réellement un
bloc de rail après décoration, avec un ballast solide dessous. Les lacunes
prévues restent réparables ; aucun trajet réel en wagon na été testé.
- Le centre du sanctuaire minéral est contrôlé par échantillonnage dans les
chunks FULL : sol solide et deux blocs dair au-dessus. Le test pur vérifie
aussi lintégralité du centre de 39 × 39 et les quatre orientations.
- Les voies de surface préservent le disque naturel darrivée de 48 blocs de
rayon. Les volumes retenus ne nécessitent aucune ville de surface.
Ces contrôles ne constituent pas une appréciation esthétique des ruines ou des
cavités. La présence de géométrie et la circulation vérifiée ne valident pas
encore l’équilibrage des ressources, lexpérience multijoueur ou les effets
visuels de leau et de la lumière.
## Repères de visite — graine 42
Ces coordonnées proviennent des géométries des rapports. Elles sappliquent à
un **nouveau monde de génération 23**, avec les structures et les secrets
expérimentaux activés, dans la taille correspondante. Ce sont des repères de
développement ; aucune téléportation dans un monde personnel na été effectuée.
| Lieu ou accès, coordonnées X Y Z | Petit 512 | Moyen 724 | Grand 1 024 |
| --- | --- | --- | --- |
| Entrée de lobservatoire | `54 243 -40` | `186 251 -110` | `204 250 -121` |
| Accès de latelier caché | `-45 244 -131` | `-116 250 -157` | `-174 251 -128` |
| Trappe du hall indépendant | `32 168 -72` | `-97 223 -53` | `46 256 146` |
| Premier portique parcouru | `-68 250 -93` | `-170 256 82` | `-272 249 -8` |
| Second portique parcouru | `68 250 43` | `170 252 82` | `204 250 -8` |
| Point de localisation intérieur du sanctuaire minéral | `-144 165 -61` | `-121 208 -23` | `-103 144 194` |
| Coin minimal de son enveloppe | `-183 161 -97` | `-160 204 -59` | `-142 140 158` |
Lenveloppe minérale conserve 79 × 73 × 27 blocs — largeur, profondeur, hauteur —
et un centre dégagé de 39 × 39. Une ouverture sur un flanc exposé peut donner
sur le vide ; les essais ne garantissent pas un chemin extérieur déjà construit
jusqu’à elle. Le point de localisation reste dans la salle.
## Réouverture et limites de ladmission minérale
Le Petit, graine 42, passe sa réouverture dans une JVM distincte : processus
7581 pour la création et 7664 pour la lecture. Les paramètres du générateur,
les plans, les départs natifs et la modification témoin de lobservatoire sont
conservés. Le sanctuaire reste présent, avec 23 rails natifs dans le monde.
La création précédait le préfiltre final ; sa réouverture, avec ce préfiltre,
conserve la même géométrie. Ce cas ne valide pas toutes les sauvegardes.
Le Moyen, graine 0, passe sa création et sa réouverture finales dans deux JVM
distinctes, processus 8650 puis 8777 (`final-medium.json` et
`final-medium-reload.json`). Le sanctuaire est présent, avec 85 chunks FULL
contrôlés et 68 rails natifs. Les paramètres du générateur, les géométries,
les départs natifs et la modification témoin sauvegardée sont identiques entre
les deux rapports. Le point de localisation intérieur du sanctuaire est
`-93 143 81`, dans une enveloppe dont le coin minimal est `-132 139 45`.
Ce résultat final remplace le refus minéral de la recherche intermédiaire.
La recherche du sanctuaire reste bornée : 48 positions, quatre orientations,
trois profondeurs, au plus 10 000 colonnes et 1 200 000 sondages. Le préfiltre
examine une grille avant la certification détaillée. Celle-ci conserve au moins
60 % de couverture et dappuis directs sur deux blocs de roche native ; chaque
colonne sans appui direct doit être à dix blocs au plus dun appui. Le volume
protégé est vérifié intégralement. Le rendu ne pose aucun pilier hors de son
enveloppe pour sauver un mauvais emplacement.
Les diagnostics distinguent ouverture obstruée, couverture ou roche
insuffisantes, appuis trop éloignés, volumes protégés et budget épuisé. Un refus
ne prouve pas quaucun emplacement imaginable nexiste ailleurs dans l’île.
Les témoins obtenus ne garantissent donc pas la présence du sanctuaire pour
toutes les graines. Les règles des autres structures nont pas été assouplies
pour faire accepter ce lieu.
## Autres vérifications
### Réalisées
Le smoke ciblé `secrets23Smoke` a passé ses témoins de géométrie, sauvegarde et
refus ; `structureCensusSmoke` possède également une réussite dans le journal
local `build/alpha23.1-corrected-smokes.log`.
La suite générale réussit ses **24 GameTests obligatoires** dans
`build/alpha23.1-general.log` : 51,84 secondes pour lexécution des tests et
117,75 secondes pour le runner complet. La dernière entrée `general` de
`postchecks.json` possède un code de sortie nul. Elle remplace le premier
essai interrompu par lancienne assertion du nombre de biomes, depuis
actualisée. Les suites spécialisées et les commandes finales sont attestées
séparément dans les sections suivantes.
Les deux modes OFF passent dans les mondes de développement de graine 0,
taille Moyen 724. Les reçus et les durées se trouvent dans
`build/alpha231-evidence/` et `postchecks.json` :
| Option | Preuve | Résultat contrôlé | Durée du runner |
| --- | --- | --- | ---: |
| `sanctuaryExperimentalStructures=false` | `optional-off.json` | Aucun observatoire, atelier, sanctuaire minéral ni rail patrimonial ; les 23 modules de traversée et le hall indépendant restent présents. 43 chunks FULL contrôlés, parcours de la traversée et accès du hall réussis. | 69,04 s |
| `sanctuaryGenerateStructures=false` | `global-off.json` | Aucun départ natif valide dans les trois chunks contrôlés ; 293 376 positions vérifiées sans les rails et blocs manufacturés ciblés par le test. | 30,50 s |
Ces essais attestent les comportements observés dans leurs emprises ; le
contrôle global OFF nest pas un inventaire de tous les chunks de l’île.
La création puis la réouverture du générateur historique
`sanctuary:island_v22` passent également pour la graine 0, taille Moyen 724,
avec les structures expérimentales activées. Les rapports `legacy22.json`
et `legacy22-reload.json` proviennent de JVM distinctes, processus 9088 puis
9208, en 130,05 et 86,11 secondes. Ils contrôlent 115 chunks FULL et conservent
14 sites patrimoniaux, sept liaisons, 23 modules de traversée, un hall indépendant
et la modification témoin sauvegardée.
`legacy22-comparison.json` compare ces résultats aux rapports alpha.22 de
référence `build/alpha22-evidence/native22-create.json` et
`native22-reload.json`. Tous les champs de référence restent identiques, à
lexception des identifiants de processus et chemins de mondes, volontairement
exclus. Les seules clés supplémentaires autorisées dans les nouveaux rapports
sont `transit_native_walk` et `hall_native_access` : ce sont des diagnostics
de parcours ajoutés au test, pas une modification du format de sauvegarde.
Ils passent aussi : les deux portiques sont reliés par un parcours de 548
pas de contrôle et le hall dispose dun accès utilisable avec 22 blocs d’échelle
et huit ouvertures libres.
Le même reçu atteste que le preset conservé `sanctuary_v22.json` est identique
octet par octet à lancien preset `sanctuary.json` du commit `e654d56`.
Cette comparaison porte sur ce témoin de génération 22 créé et rechargé avec
le code actuel ; elle ne constitue ni une migration ni un essai de tous les
anciens mondes personnels.
### Réservations réelles dexpansion
Quatre appels réels à `ExpansionRuntime.Session.create` passent dans des JVM
neuves, sans aperçu dexpansion avant lappel, sur des mondes de développement
de graine 0 et de génération 23. Chaque essai réussit dès le premier identifiant
`admission_23_0`, conserve le délai de recherche de production de 30 secondes
et termine avec une seule nouvelle île au statut `reserved`.
Les preuves sont dans `build/alpha231-evidence/admission-costs.json` et les
quatre rapports ci-dessous. La durée dadmission est celle de lappel à
`Session.create` ; la durée du runner inclut le lancement et les contrôles.
| Demande | Preuve | Admission | Runner complet |
| --- | --- | ---: | ---: |
| 512, tempéré, naturel | `reserve-512-temperate-natural.json` | 206 ms | 53,62 s |
| 1 024, tempéré, naturel | `reserve-1024-temperate-natural.json` | 698 ms | 52,33 s |
| 1 024, tempéré, océan | `reserve-1024-temperate-ocean.json` | 441 ms | 53,40 s |
| 1 024, Lost City, naturel | `reserve-1024-lost_city-natural.json` | 12 849 ms | 64,15 s |
Le contrôle vérifie la garantie native appliquée par `Session.create`, la
relecture du journal durable, la conservation du profil capturé avant lappel
et lidempotence : répéter le même identifiant ne crée pas une seconde
réservation et ne réécrit pas le journal. Le profil initial de priorités est
vide dans ces quatre témoins ; ils ne couvrent pas encore son évolution après
le recensement dun continent généré.
Pour Lost City, le journal
`build/alpha23.1-reserve-1024-lost_city-natural.log` atteste un plan accepté
comportant un quartier et un réseau souterrain ; cette planification prend
12 575 ms à lintérieur de lappel dadmission.
**Ces quatre essais sont des réservations uniquement.** Leurs reçus indiquent
`reserve_only: true` et `full_generation_checked: false`. Ils ne valident pas
la préparation complète, le passage à `ready`, les blocs posés en FULL ou leur
réouverture ; leurs durées ne sont donc pas des temps de génération de
continents. Aucune publication ni expansion dans un monde personnel nest
attestée par ces essais.
### Cycle complet dune expansion réelle
`build/alpha231-evidence/admission.json` atteste le passage complet de
`Session.create` à `ready`, processus 11335, sur un monde de graine 0 :
expansion tempérée naturelle de **256 blocs**, identifiant `admission_23_0`,
centre `2096 -160`, graine d’île `-6422943410799146666`. Cette taille est
distincte des réservations de 512 et 1 024 blocs mesurées plus haut.
La première demande est réservée en 1 034 ms sous le délai de 30 secondes.
La préparation prend 94 780 ms ; le journal `build/alpha23.1-admission.log`
confirme **418 chunks FULL** lorsque lexpansion devient prête. Le runner
complet termine avec un code de sortie nul en 160,01 secondes.
Un portail en ruine natif est effectivement généré au départ de chunk
`131 -10`. Le contrôle vérifie la couverture complète du recensement, la
présence de ce départ natif, le journal durable et lidempotence avant et après
la préparation. Le profil capturé pour cette réservation reste vide ; après
génération, le recensement propose douze familles prioritaires pour les
recherches suivantes, sans réécrire le profil de l’île déjà créée.
Le reçu indique `reserve_only: false`, `census_verified: true`,
`idempotent: true` et `passed: true`. Ce terrain distant ne nécessite pas de
certificat de réutilisation de chunks du vide.
`admission-reload.json` confirme la réouverture réussie dans le processus
11614, distinct du processus 11335 de création, en 56,07 secondes de runner.
L’île est immédiatement retrouvée à l’état `ready`, sans nouvel appel de
création (`create_called: false`) ni redémarrage de sa préparation. Le test retrouve le journal à
lidentique, le descripteur et son profil, le départ natif et ses références,
ainsi que le recensement. La preuve reste limitée à ce cycle tempéré naturel
de 256 blocs ; les grandes réservations ne deviennent pas pour autant des
témoins FULL.
### Catalogue et structures natives en FULL
`build/alpha231-evidence/native-create.json` atteste la réussite de la création
native, processus 10548 : **206 chunks FULL contrôlés**, sur cinq témoins de
structures. La dernière entrée `native-create` de `postchecks.json` possède
un code de sortie nul, en 232,40 secondes de runner. Les assertions sont dans
[Native23WorldGameTests](../mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Native23WorldGameTests.java).
Les témoins utilisent des îles et des priorités choisies pour exercer ces
structures ; ils ne constituent pas une mesure de leur fréquence naturelle.
Le catalogue contient exactement les **52 identifiants distincts** du registre
Minecraft 26.3-pre-2. Parmi eux, 46 sont classés admissibles dans lOverworld ;
la cité ancienne reste réservée aux Cavernes, quatre entrées au Nether et la
cité de lEnd à lEnd. Cette concordance du catalogue ne signifie pas que les
52 structures ont été générées en FULL dans cet essai.
La vérification dadmission est rejouée avec le descripteur Lost City du témoin
de réservation précédent : le quartier et le réseau souterrain existent dans
le plan avant et après son insertion dans la liste des îles du générateur.
Le placement natif reste accepté lorsque leurs volumes de construction sont
pris en compte. Ce contrôle porte sur une ville réellement planifiée,
pas sur un plan vide ; il ne valide pas encore son quartier entier en FULL.
| Témoin FULL | Présences contrôlées après décoration |
| --- | --- |
| Forteresse (`stronghold`) | Salle de portail native, 12 cadres de portail de lEnd, générateur de monstres et coffres. |
| Chambres des épreuves (`trial_chambers`) | Générateurs d’épreuves et coffres-forts natifs (`vault`). |
| Monument océanique | Salles immergées en prismarine, eau et trois grands gardiens chargés dans le monde ; eau de surface et roche sous le fond vérifiées au centre de l’île témoin. |
| Igloo | Neige, coffre du sous-sol natif et échelles de son puits. |
| Pyramide du désert | Grès, quatre coffres et neuf TNT du piège natif. |
Les compteurs bruts `full_blocks` parcourent les emprises des pièces, qui
peuvent se recouvrir : leurs valeurs ne forment pas un inventaire de blocs
uniques. Ainsi, la valeur 91 pour `vault` natteste pas 91 coffres-forts
distincts. Le tableau rapporte surtout les présences requises. Aucun combat,
déclenchement d’épreuve, ouverture de butin ou usage du portail na été joué.
Les départs gardent leur identité dans le registre, leur géométrie admise et
leurs données après sérialisation et lecture. Le monument est adapté au fond
océanique et son agencement intérieur se reconstruit avec la graine de l’île.
Pour les chambres des épreuves, seule la hauteur absolue de départ est adaptée :
les pools, processeurs, alias et contenus natifs restent identiques.
La localisation par les tags natifs de l’œil de lEnder et des cartes de
monument, de chambres des épreuves et de pyramide trouve les vrais départs.
Une sélection mélangeant plusieurs types vanilla réussit également. La
recherche en lecture seule conserve les compteurs de références ; avec
`skipReferenced`, seul le départ gagnant reçoit une référence supplémentaire.
Ces recherches ne changent pas la liste des îles.
La présence des trois grands gardiens est vérifiée après chargement effectif
des entités des chunks du monument. Elle ne repose pas uniquement sur leur
NBT sauvegardé. La réouverture réussit aussi : `native-reload.json`, processus
10722, contrôle de nouveau 206 chunks FULL. Les données `full_tags` des cinq
départs, incluant leurs références, et les compteurs `full_blocks` sont
identiques entre création et réouverture ; les trois grands gardiens sont
présents après rechargement des entités. Cette preuve reste limitée aux cinq
témoins retenus.
### Six profils océaniques en FULL
`build/alpha231-evidence/ocean.json` atteste la réussite de la matrice
océanique, processus 11735, graine de monde 0, génération 23 : **34 chunks
échantillons demandés au statut FULL** pour six îles temporaires. Le runner
termine en 91,39 secondes, avec un code de sortie nul. Cette sélection couvre
un bassin profond, une rive et lextérieur de chaque profil, ainsi que les
chunks voisins nécessaires aux témoins de glace ; elle ne génère pas la
totalité des six océans.
| Diamètre | Climat | Graine d’île | Eau à Y | Fond au point témoin | Biome profond stocké |
| --- | --- | ---: | ---: | ---: | --- |
| 512 | Glacial | 23 042 | 220 | 176 | `minecraft:deep_frozen_ocean` |
| 512 | Tempéré | 23 143 | 220 | 171 | `minecraft:deep_ocean` |
| 512 | Chaud | 23 244 | 220 | 176 | `minecraft:deep_lukewarm_ocean` |
| 1 024 | Glacial | 23 345 | 241 | 193 | `minecraft:deep_frozen_ocean` |
| 1 024 | Tempéré | 23 446 | 241 | 195 | `minecraft:deep_ocean` |
| 1 024 | Chaud | 23 547 | 241 | 198 | `minecraft:deep_lukewarm_ocean` |
Chaque profil conserve 16 appuis rocheux sur 16 et 16 échantillons immergés
sur 16, après décoration native. Les rives stockent `minecraft:snowy_beach`
pour les deux profils glaciaux et `minecraft:beach` pour les autres. Les
points extérieurs contrôlés restent entièrement dans lair et le biome
`minecraft:the_void`.
Les deux profils glaciaux passent également un placement explicite des
features natives diceberg et de glace bleue. Avec la graine de feature
23 041, les sommets de glace atteignent respectivement Y 223 et Y 244,
au-dessus de leur niveau marin local. Le contexte de décoration de production
fournit ce niveau au générateur et à la région, conserve la protection du fond,
puis restaure le niveau global à 64. Ces placements explicites contrôlent
la compatibilité des features ; ils nattestent pas une fréquence naturelle
dicebergs dans toutes les graines.
Lancien échec `ocean-ice-failure.json` utilisait un tirage diceberg pouvant
ne poser aucune glace dans le code vanilla. Le nouveau témoin déterministe
remplace ce tirage ; `ocean.json` constitue la preuve finale de cette suite.
La liste initiale des îles et les fichiers du journal sont inchangés après
les essais (`journal_unchanged: true`). **La réouverture des six profils dans
une autre JVM na pas été exécutée** ; le monument océanique dispose, lui,
de la preuve de réouverture décrite plus haut.
### Fabrication et distribution
`./gradlew check build` réussit en 3 min 48 s : 80 tâches, dont 73 exécutées.
Ses **24 GameTests** passent en 51,12 s, avec les régressions historiques,
le Blocodex, les priorités, le journal, le recensement et les protections.
`./gradlew assemblePack` réussit en 3 min 45 s : 81 tâches, dont 70 exécutées.
Cette seconde commande relance ses dépendances de vérification ; aucun échec.
Journaux : `build/alpha23.1-check-build.log` et
`build/alpha23.1-assemble-pack.log`.
Lexport packwiz produit `build/Sanctuary-0.1.0-alpha.23.1.mrpack`.
Le contrôle des artefacts vérifie les versions, le contenu de lindex packwiz,
le JAR embarqué identique au JAR construit, labsence de classes de test dans
le JAR et le bootstrap Prism officiel v0.0.3. Reçu :
`build/alpha231-evidence/delivery.json`.
| Artefact | SHA-256 |
| --- | --- |
| JAR Sanctuary alpha.23.1 | `906fd2e12be4233676a1a561556508eee7412183698c2cac704dc1191324f136` |
| MRpack alpha.23.1 | `d791025721ae52a3bb7b8d385fd5930cd4fc1a2dc9a4fa255ffb72a27a2455ad` |
Le MRpack est **local**, pas encore publié. Le gestionnaire didentifiants Git
ne fournit aucun accès à `git.botsu.net` ; aucune release ni mise à jour du canal
na été effectuée. Le canal public a été relu à `0.1.0-alpha.22`.
Linstance Prism existante, ses sauvegardes et ses réglages nont pas été modifiés.
Les synchronisations réelles de linstallateur et de Prism restent à effectuer
après publication. Le ZIP damorçage préparé pointe vers ce canal stable ; il ne
constitue donc pas une installation de lalpha.23.1 à ce stade.
La matrice océanique ne possède pas de réouverture complète ; le monument
océanique a son témoin de réouverture. Windows et le rendu client restent non testés.
## Reproduire les essais de secrets
Utiliser uniquement un dossier de monde de développement prévu pour la création
correspondante. Chaque nouvelle création doit disposer dun monde de test neuf ;
une réouverture suit sa création avec la même graine, la même taille et les mêmes
options. Exécuter les JVM Gradle séquentiellement.
```sh
./gradlew :sanctuary:secrets23Smoke
./gradlew :sanctuary:runGameTest -PsanctuarySecrets23Tests=true -PsanctuaryDensityMaps=false -PsanctuaryTestSeed=42 -PsanctuaryTestDiameter=512
./gradlew :sanctuary:runGameTest -PsanctuarySecrets23Tests=true -PsanctuaryDensityMaps=false -PsanctuaryTestSeed=42 -PsanctuaryTestDiameter=512 -PsanctuaryExpansionReload=true
```
Les tailles alternatives utilisent `724` et `1024`. Les scénarios OFF ajoutent
respectivement `-PsanctuaryExperimentalStructures=false` ou
`-PsanctuaryGenerateStructures=false`. Ces commandes décrivent les contrôles à
exécuter ; leur présence dans le rapport natteste pas leur réussite. Les rapports
originaux du runner sont dans
`mods/sanctuary/build/run/gameTest/diagnostics/secrets23-<graine>-<diamètre>/`.
+104
View File
@@ -0,0 +1,104 @@
# Validation alpha.23 — Blocodex et socle méta
Relevé du **10 septembre 2026**, branche `codex/blocodex-natif`.
Cible : Minecraft **26.3-pre-2**, Fabric Loader **0.19.5**, Fabric API
**0.160.0+26.3**, Java **25**, Sanctuary/pack **0.1.0-alpha.23**.
Le [contrat Blocodex](blocodex.md) définit les informations et leur couverture.
## Résultats disponibles
| Vérification | Résultat observé | Preuve locale |
| --- | --- | --- |
| Client natif : widgets, interface et connexion au serveur intégré | **Réussie**, `BUILD SUCCESSFUL in 34s` | `build/blocodex-client-network-tests.log` |
| Compilation et persistance quotidienne des activités | **Réussies**, `BUILD SUCCESSFUL in 9s` | `build/blocodex-meta-compile.log` |
| Persistance de la mémoire personnelle | Smoke réussi dans la passe complète | `build/blocodex-meta-check-build.log`, ligne `Block knowledge smoke passed` |
| GameTests natifs | **24/24 réussis**, dont 16 pour le socle méta | `build/blocodex-meta-check-build.log`, `All 24 required tests passed` |
| Passe complète et assemblage du pack | **Réussis**, `BUILD SUCCESSFUL in 4m 18s` | `build/blocodex-meta-check-build.log` |
Les tests de stockage couvrent les instantanés immuables, les compteurs exacts,
lisolement des UUID ou des journées, les identifiants de mods absents, le JSON
strict et le refus d’écraser silencieusement un fichier invalide. Leur succès
est complété par les tests natifs ci-dessous.
Les neuf tests de connaissance vérifient poses réussies et refusées, variantes
murales, statistiques de minage et jets volontaires, distinction des morts,
regard occulté, inventaire/curseur, états et notifications indépendants,
sauvegarde après déconnexion depuis un thread réseau, et palettes combinables.
Deux tests terrain vérifient pose/remplacement/retrait, exclusion immédiate des
comptages obsolètes, couverture et budget partagé entre lectures, sans charger
de chunk absent. Deux tests stocks vérifient les quatre catégories, un double
coffre ouvert sans double comptage et la conservation du butin non généré.
Trois tests dactivité vérifient les actions natives, les quantités datées,
la séparation des observations et des actions, la relecture après sauvegarde,
les journées absentes et les commandes avec identifiants complets. Le test de
fabrication appelle la complétion vanilla dun item ; il ne joue pas une recette
entière dans l’établi. Les huit tests généraux de génération passent aussi.
## Artefact local vérifié
Le JAR `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.23.jar` mesure
**1 914 798 octets**. Sa copie dans `build/packwiz/mods/` est identique.
SHA-256 :
```text
740107901c06d1c54838a944d01431e4f134f6989e1900b294cc0c0d210b1d4f
```
Le manifeste embarqué, lentrée client, les classes de recensement et
dhistorique, le tag de palette naturelle et les paramètres des traductions
FR/EN sont vérifiés. Les manifestes du dépôt et du pack assemblé indiquent
`0.1.0-alpha.23`. Le contrôle pack vérifie les versions et empreintes de lindex.
## Protocole client exécuté
Le client de développement commence par une fixture explicite de **260 blocs
en trois fragments**. Les widgets réels vérifient recherche, filtres et
pagination, assemblage atomique, rejet des réponses périmées ou invalides,
et conservation des grands compteurs dans la narration. Deux dimensions de
GUI sont exercées : **854 × 480** et **427 × 240**, avec une aide défilante.
Cette fixture ne prétend pas représenter un inventaire de joueur.
La seconde passe crée un **nouveau monde plat de développement**, graine 1,
en survie, avec les commandes désactivées. Elle utilise la véritable touche
**B**, la connexion Minecraft et les paquets du mod :
1. Préparer côté serveur un bloc dor devant le joueur et trois blocs de diamant
dans son inventaire ; laisser les observations ordinaires du serveur agir.
2. Regarder lor puis ouvrir B : lor est vu sans être possédé ; le diamant est
possédé avec un stock de 3 sans être vu ; l’émeraude reste inconnue.
3. Fermer l’écran, retirer les diamants, puis rouvrir avec B : la possession
passée reste vraie et le stock actuel vaut 0.
4. Fermer le monde et vérifier le fichier de mémoire personnelle sauvegardé.
Les deux diagnostics signalent `passed: true` dans
`mods/sanctuary/build/run/clientGameTest/diagnostics/` :
- `sanctuary-blocodex-client.json` : fixture, widgets et fragments ;
- `sanctuary-blocodex-network-client.json` : états avant/après, touche et paquets
réels, observation du regard et sauvegarde à la fermeture.
Les captures `0005_sanctuary-blocodex-network-carried-three.png` et
`0006_sanctuary-blocodex-network-sticky-possession.png` se trouvent dans le dossier
`screenshots/` du même client. La seconde a été inspectée : le diamant reste
connu et possédé, avec **Inventory: 0**, dans linterface anglaise du test.
## Reproduction et limites
Depuis la racine du dépôt, avec Java 25 et un environnement graphique :
```sh
./gradlew -PsanctuaryClientTests=true :sanctuary:runClientGameTest
./gradlew :sanctuary:materialActivitySmoke
./gradlew check build assemblePack
```
La dernière commande a terminé avec succès. Le scénario client prouve une
connexion intégrée réelle ; il ne constitue pas une validation
réseau à plusieurs clients sur un serveur distant.
Les essais ne constituent pas un profil de performance dun grand serveur.
Les mondes, captures, diagnostics et journaux de test restent dans les dossiers
de développement ignorés. Le scénario réseau vérifie que sa nouvelle sauvegarde
est créée sous `mods/sanctuary/build/run/clientGameTest/saves/`. Aucune instance
Prism ni sauvegarde personnelle na été mise à jour. Aucune publication ni
modification du canal packwiz na été effectuée pour ces essais.
+272
View File
@@ -0,0 +1,272 @@
# Vérifications alpha.24
Branche `codex/aerial-sanctuary-alpha24`, base alpha.23.1 `fbefb2e`.
Minecraft 26.3-pre-2, Fabric Loader 0.19.5, Fabric API 0.160.0+26.3,
Java 25 ; mod et pack `0.1.0-alpha.24`.
**Matrice de génération réussie le 11 septembre 2026.** Les quatorze scénarios
passent : neuf créations, deux réouvertures, deux options OFF et lordre inverse
de chargement. Les tests purs, deux admissions réelles dexpansion et la
cascade native jusqu’à Y8 réussissent également. Les 24 GameTests généraux passent également.
`check build assemblePack` passe en 3 min 56 s (83 tâches), avec les 24
GameTests natifs également réussis dans ce build. JAR, MRpack, index packwiz
et bootstrap officiel sont vérifiés. La release est publiée, le canal packwiz
et la même instance Prism sont synchronisés ; 925 fichiers personnels et
réglages suivis conservent leurs empreintes.
Le [contrat de génération](generation-alpha24.md) décrit le périmètre.
## Environnement et preuves
Les essais utilisent un serveur Fabric GameTest sans interface graphique sur
Mac. Les mondes jetables restent dans `mods/sanctuary/build/run/gameTest/` ;
aucune sauvegarde personnelle nest ouverte ou modifiée. Aucun essai Windows
natif ni contrôle graphique du client avec shaders na été exécuté.
Les résultats retenus figurent dans `build/alpha24-evidence/matrix.json`,
avec les rapports `<phase>-<graine>-<diamètre>.json` et leurs journaux `.log`.
Un résultat nest retenu quaprès sortie réussie du processus et
`passed: true` dans le rapport. Ces fichiers de développement sont ignorés par
Git. Les essais précédant la végétation sont conservés séparément dans
`build/alpha24-evidence-before-vegetation/` et ne valident pas les formes finales.
Ceux précédant la correction du rivage sont dans
`build/alpha24-evidence-before-shore-fix/` ; ils attestent les contrôles déjà
réussis, mais pas les nouveaux emplacements recherchés par le filtre corrigé.
Le journal `build/alpha24-shore-smoke.log` atteste les tests purs finaux.
Les images `balloon.png`, `merchant_*.png` et `mineral_islet_*.png` sont des
projections de contrôle calculées depuis lexport des blocs du renderer :
ce ne sont pas des captures d’écran Minecraft. Le test FULL vérifie séparément
que les matériaux prévus existent dans le monde natif.
## Résultats exécutés
| Essai | Résultat | Preuve |
| --- | --- | --- |
| Placement pur : 3 tailles × graines 0, 42, 2026 ; rotations, emprises, conflits et rivage obstrué | Réussi après correction | `alpha24-shore-smoke.log`, `Aerial24Smoke` |
| Manifeste avant journal, relecture stricte, corruption, absence, conflits, plan vide et historique23 | Réussi | Même journal, `AerialPersistence24Smoke` |
| Neuf créations de la matrice après correction du rivage | 9 sur 9 réussies | Tableau ci-dessous |
| Moyen 724, graine 0, première réouverture | Réussi, 54,85 s | `reload1-0-724.json` |
| Même monde, seconde réouverture | Réussi, 54,50 s | `reload2-0-724.json` |
| Option expérimentale OFF, en serveur natif | Réussi, 52,93 s | `create-0-724-experimental-off.json` |
| Structures globales OFF, en serveur natif | Réussi, 27,19 s | `create-0-724-global-off.json` |
| Génération des chunks dans lordre inverse | Réussi, 81,58 s ; sept champs strictement identiques | `create-0-724-reverse.json`, `reverse-comparison.json` |
| Cascade jusqu’à Y8 et deux admissions réelles entre les emprises | Réussi, 2 min 39 s | `flow-admission-0-724.json`, `alpha24-flow-admission.log` |
| Comparaison du témoin historique23 avec lalpha.23.1 | Réussie sur le code24 avant la passe végétale | `legacy23-comparison.json` |
| GameTests généraux : Blocodex, options, population et hydrologie | 24 sur 24 réussis, JVM 2 min 2 s ; suite 54,47 s | `build/alpha24-general-gametest.log` |
| `check build assemblePack`, contrôle des artefacts | Réussi ; 83 tâches, suite native 24/24 | `build/alpha24-check-build-pack.log`, `build/alpha24-artifacts.json` |
| Graine | Petit 512 | Moyen 724 | Grand 1 024 |
| --- | --- | --- | --- |
| 0 | Réussi, 62,64 s ; 57 chunks FULL | Réussi, 80,96 s ; 61 chunks FULL | Réussi, 128,84 s ; 48 chunks FULL |
| 42 | Réussi, 58,25 s ; 61 chunks FULL | Réussi, 72,91 s ; 57 chunks FULL | Réussi, 92,08 s ; 58 chunks FULL |
| 2026 | Réussi, 59,43 s ; 44 chunks FULL | Réussi, 80,77 s ; 37 chunks FULL | Réussi, 115,93 s ; 48 chunks FULL |
Chaque création active retrouve les cinq sites, leurs cinq coffres, deux
marchands et une source de cascade. Les emprises réservées vont de 31 à 55
chunks selon le monde. Les deux cas OFF retrouvent un plan vide, aucune
réservation aérienne et aucun site aérien annoncé dans le registre.
Lordre inverse, exécuté sur un nouveau Moyen, graine 0, conserve exactement
la géométrie, le codec, les départs NBT, les blocs avant et après usage,
les mutations du test et la végétation. `reverse-comparison.json` consigne
l’égalité stricte de ces sept champs ; les durées et états transitoires de
chargement ne sont pas comparés comme sils devaient être identiques.
Les durées incluent Gradle, le lancement du serveur, la préparation du monde,
les chunks FULL demandés et les assertions. Ce ne sont ni des temps de rendu
client, ni une mesure Windows, ni le délai nécessaire pour générer toute l’île.
### Correction du point de vue de rivage
La première matrice végétalisée avait échoué sur le Petit, graine 42 : le
sanctuaire minéral construisait sa voûte jusqu’à Y187 au-dessus dun point
calculé sur le terrain brut à Y174. Le planner prépare désormais les plans
racine avant le manifeste aérien, puis refuse leurs volumes au niveau du
support, du point de vue et de la ligne de visée. Les assertions FULL restent
strictes ; le cas fautif passe dans la nouvelle série. Deux tests purs
vérifient une obstruction au rivage et au milieu du segment de vue.
Cette correction peut changer le candidat retenu pour un nouveau monde24.
Un plan déjà sauvegardé reste relu tel quel : aucun recalcul implicite et
aucune modification des anciens mondes23.
### Ce que le témoin Moyen vérifie
Les cinq sites réservent **55 chunks**, sans réserver un anneau entier. Le test
charge **61 chunks FULL**, comprenant ces emprises, leurs points de rivage et
le chunk qui reçoit les villageois extraits. Il vérifie notamment :
- Les cinq vrais départs de structures natifs, leurs pièces, leurs coordonnées,
la sérialisation NBT et les états doccupation conservés.
- Cinq coffres réellement placés, leurs tables vanilla et un butin non vide,
puis leur maintien à vide après deux redémarrages.
- Deux villageois présents, un par navire, puis déplacés dans le chunk de test.
Leur position reste conservée et aucun remplaçant napparaît dans les navires.
- Des blocs de minerai et un élément de nacelle retirés restent absents après
les deux réouvertures. Cette manipulation est limitée au monde de développement.
- Des accès et intérieurs avec un sol et deux blocs libres, ainsi que les trois
couches de couverture des coffres enterrés lors de la création.
- Les points de rivage reposent sur le terrain FULL et ne sont pas enfouis.
Les colonnes extérieures échantillonnées restent vides de construction.
- Le registre passe de `PLANNED` à `GENERATED`, les recherches utilisent les
vrais départs et le journal dexpansion reste inchangé pendant ces visites.
La source deau est présente et conservée ; lessai séparé ci-dessous vérifie
sa descente par les ticks de fluide jusqu’à Y8.
La création et les deux réouvertures contrôlent aussi la végétation réelle. Sur l’îlot avec
cascade, **596 des 783 colonnes de surface intactes portent de lherbe**
(76,1 %), avec deux arbres et 27 blocs de hauteur rocheuse. Sur l’îlot sec,
ce sont **1 506 sur 1 788** (84,2 %), quatre arbres et 21 blocs de roche.
Chaque enveloppe ajoute huit blocs pour les couronnes. Les troncs ont un appui
en terre, les couronnes restent dans lemprise et les feuilles utilisent les
propriétés natives de distance, sans persistance forcée.
`persistence-comparison.json` confirme également l’égalité de la géométrie,
du codec, des départs natifs, des blocs après usage, des mutations et des
mesures de végétation dans les deux relectures.
### Admission réelle et écoulement natif
`flow-admission-0-724.json` et `build/alpha24-flow-admission.log` attestent le
test dans un autre monde jetable Moyen, graine 0. Il appelle le véritable
`ExpansionRuntime.Session.create` avant que les satellites soient visités.
| Expansion de test, diamètre 128 | Premier centre envisagé X Z | Centre réellement réservé X Z | Appel de réservation | Résultat |
| --- | --- | --- | ---: | --- |
| `aerial_collision_0_southeast` | `496 336`, chevauche un satellite | `368 496` | 114 ms | `READY`, un départ vanilla valide |
| `aerial_interval_0_northeast` | `400 -432`, passage libre | `400 -432` | 54 ms | `READY`, un départ vanilla valide |
La première demande filtre le candidat en conflit, puis poursuit les
vérifications dadaptation avant sa réservation définitive. La seconde
montre que la bande entre les satellites reste utilisable. Les deux
réservations sont idempotentes, respectent leurs emprises et atteignent
`READY` avec une structure native adaptée. Ces durées dappels ne représentent
pas la préparation complète, comprise dans le test de 2 min 39 s.
Après chargement et activation des ticks des chunks concernés, lunique
source prévue produit de leau sous l’îlot à `399 194 312`, puis à
`399 8 312`, après **1 180 ticks dattente contrôlée**. À Y8, le fluide
est de leau courante, pas une source artificiellement posée. Aucun chemin
vertical de blocs sources nest fabriqué par le renderer. Cet essai valide
ce trajet réel ; il ne garantit pas la forme de l’écoulement après des
constructions ou excavations effectuées par les joueurs.
### Chargement des habitants dans le test Grand
Le premier essai Grand, graine 0, a rencontré un délai de visibilité des
entités : les marchands existaient sur disque mais n’étaient pas encore
exposés au test. Le protocole de lecture charge désormais les navires entiers,
vérifie la disponibilité native des entités et attend au plus 600 ticks.
Lexigence reste de deux marchands distincts, sans création ou remplacement
par le test. Les trois Grands passent ensuite : le témoin graine0 attend
250 ticks et celui de graine2026, 323 ticks, avant de voir les deux habitants.
Cela confirme que lancienne limite de 200 ticks du fixture était trop courte.
Le premier essai interrompu nest pas compté dans les réussites de la matrice.
## Repères de visite — Moyen 724, graine 0
Créer un **nouveau monde Sanctuary de génération24**, taille Moyen, graine `0`,
avec **Générer les structures** et **Structures expérimentales** activés.
Les coordonnées proviennent de `create-0-724.json`, champ `look_views`.
Le nouveau rapport après correction du rivage confirme ces mêmes coordonnées.
Les repères de rivage désignent un point de vue deux blocs au-dessus du témoin
enregistré ; ils ne constituent pas des téléportations de survie exécutées.
| Lieu | Entrée X Y Z | Point de vue au rivage X Y Z | Distance horizontale entréerivage |
| --- | --- | --- | ---: |
| Montgolfière | `-492 288 110` | `-251 219 55` | 247,2 blocs |
| Navire de vivres | `-35 186 -503` | `-19 176 -282` | 221,6 blocs |
| Navire de matériel | `463 186 -204` | `229 159 -102` | 255,3 blocs |
| Îlot avec cascade | `399 237 333` | `195 161 163` | 265,5 blocs |
| Îlot sec | `-277 256 456` | `-125 227 207` | 291,7 blocs |
Pour un repérage de développement, `/locate structure sanctuary:aerial_balloon`,
`sanctuary:aerial_merchant_ship` et `sanctuary:aerial_mineral_islet` utilisent
les types natifs. `/sanctuary census structures aerial` distingue les cinq
sites. Aucun pont ou véhicule nest ajouté pour les atteindre. La limite de
512 blocs depuis un rivage ne force pas les réglages de rendu du client ou
du serveur, ni la visibilité à travers les feuillages.
## Historique23
Le rapport `legacy23-0-724.json`, produit sur un monde de génération23 avec le
code24 avant lajout de végétation aérienne, a été comparé à
`build/alpha231-evidence/final-medium.json`. Tous les champs sont identiques,
à lexception attendue de `process_id` : codec, plans, départs natifs,
géométrie, rails et traversées contrôlés dans **85 chunks FULL**.
`legacy23-comparison.json` consigne cette égalité.
Cette preuve porte sur ce témoin précis, pas sur toutes les graines ni toutes
les dimensions. Les tests de persistance conservent aussi les octets du journal23
et refusent une conversion implicite vers24. Aucun monde existant ne reçoit
automatiquement les satellites24 ; le nouveau manifeste appartient uniquement
aux nouveaux mondes24.
## Reproduction
Depuis la racine du dépôt, exécuter les commandes **séquentiellement**.
`runGameTest` recrée son monde de développement par défaut. Pour les phases
de réouverture, garder le même dossier et ajouter impérativement
`sanctuaryExpansionReload=true`.
```sh
./gradlew :sanctuary:aerial24Smoke :sanctuary:aerialPersistence24Smoke
./gradlew :sanctuary:runGameTest \
-PsanctuaryAerial24Tests=true \
-PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=0 \
-PsanctuaryAerial24Phase=create -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runGameTest \
-PsanctuaryAerial24Tests=true \
-PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=0 \
-PsanctuaryAerial24Phase=reload1 -PsanctuaryExpansionReload=true \
-PsanctuaryDensityMaps=false
./gradlew :sanctuary:runGameTest \
-PsanctuaryAerial24Tests=true \
-PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=0 \
-PsanctuaryAerial24Phase=reload2 -PsanctuaryExpansionReload=true \
-PsanctuaryDensityMaps=false
```
La matrice de créations reprend les diamètres `512`, `724`, `1024` et les
graines `0`, `42`, `2026`. Les variantes du Moyen, graine0, ajoutent séparément
`-PsanctuaryExperimentalStructures=false`,
`-PsanctuaryGenerateStructures=false` ou
`-PsanctuaryAerial24ReverseChunks=true` à la commande de création.
Lessai dadmission et deau utilise un nouveau monde :
```sh
./gradlew :sanctuary:runGameTest \
-PsanctuaryAerial24FlowAdmission=true \
-PsanctuaryTestGeneration=24 -PsanctuaryTestDiameter=724 \
-PsanctuaryTestSeed=0 -PsanctuaryDensityMaps=false
```
Cette dernière commande reproduit lessai dadmission et d’écoulement réussi.
Les assertions sont dans
[Aerial24WorldGameTests](../mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Aerial24WorldGameTests.java)
et [Aerial24FlowAdmissionGameTests](../mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Aerial24FlowAdmissionGameTests.java).
## Limites et distribution
Les essais ne valident pas encore le ressenti en vol, la lisibilité dans le
client, l’équilibrage économique des filons, les performances multijoueurs ou
les incidents Windows rapportés sur cette préversion Minecraft. Le test pur
des neuf plans emploie un terrain synthétique ; seule la matrice FULL valide
leur adaptation au relief réel des graines retenues.
La [release alpha.24](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.24)
est publiée depuis `27d7cba`. Le canal packwiz (`bd9b4c4`) et la même instance
Prism sont synchronisés, avec deux passages isolés puis deux sur linstance.
Le second passage conserve les fichiers gérés identiques. Un seul JAR Sanctuary
alpha.24 est installé ; les 925 fichiers personnels et réglages suivis sont
inchangés. Aucune sauvegarde personnelle na été ouverte ou régénérée.
Voir [la distribution et les empreintes](packwiz.md).
Preuves locales : `build/alpha24-artifacts.json`,
`build/alpha24-isolated-validation.json`, `build/alpha24-prism-validation.json`
et `build/alpha24-distribution-status.json`. Le build final dure 3 min 56 s,
avec 83 tâches et les 24 GameTests natifs réussis en 52,92 s. Ces durées ne
constituent pas une mesure des performances du client.
+465
View File
@@ -0,0 +1,465 @@
# Alpha.25 — état des vérifications
Le chantier `codex/terrain-sanctuary-alpha25` est **construit et vérifié localement**.
Les essais ci-dessous utilisent Mac Apple Silicon, Java 25 et Minecraft
26.3-pre-2. **Les douze scénarios de terrain passent : neuf créations,
un ordre inversé et deux OFF.** `check build` et l'assemblage du pack passent ;
le MRpack et le bootstrap Prism sont vérifiés. La publication et la
synchronisation Prism sont encore en cours.
Lalpha.24 reste le canal stable publié. Aucun essai Windows ni contrôle visuel
complet du client nest revendiqué.
Le joueur souhaite désormais **tester le pack sans attendre les performances**.
Les [benchmarks sont arrêtés volontairement](../build/alpha25-evidence/performance-stopped-user-request.json) ;
les mesures conservées sont non bloquantes. Un benchmark est prévu **après
la livraison**, pour orienter les optimisations de l'alpha.26 ; aucune
optimisation de production supplémentaire n'est ajoutée à l'alpha.25.
La réception demandée concerne désormais les **nouveaux mondes de test**.
La compatibilité des anciennes sauvegardes nest plus un critère de livraison.
Les anciens témoins déjà exécutés restent des preuves distinctes ; aucun monde
personnel nest migré ou régénéré.
## État actuel
| Périmètre | Preuve acquise | Limite de cette preuve |
| --- | --- | --- |
| Marais de surface et souterrains | FULL 512/0 et 512/42, sols, biomes 3D et flore native | Deux graines, pas toute la matrice des tailles ; sources testées distinctes |
| Options des marais | ON, Structures Minecraft OFF et Expérimentales OFF passent pour 512/42 | Contrôle du système de marais, pas de toutes les structures du monde |
| Monuments océaniques | Six créations et six réouvertures, gardiens et immersion | Un seul océan préparé entièrement jusqu’à READY |
| Terrain, satellites et sanctuaire | 12/12 : neuf créations FULL, ordre inversé et deux OFF | L'ordre inversé ne démontre pas une île entière identique |
| Hydrologie | 512/0 final sous 1800 ticks : une chute à Y0, l'autre sur un flanc | Les témoins zéro/une sortie restent antérieurs aux marais finaux |
| Structures conditionnelles | Village 512/18 et mine 724/42 : création et rechargement FULL ; hutte et salle à spawner vérifiées | Cabane du mineur sans témoin natif positif ; guérison complète d'un zombie-villageois non testée |
| Distribution locale | `check build`, assemblage, MRpack et bootstrap vérifiés | Publication et synchronisation Prism encore en cours |
Les reçus locaux sont conservés sous `build/alpha25-evidence/`, dossier ignoré
par Git. Un statut sappuie sur le `run.json`, le rapport de la phase et le log
correspondants : un ancien `failure.json` recopié dans un dossier nannule ni
ne valide une exécution plus récente. Les empreintes de sources des reçus ne
sont pas celles dun JAR livré. La présence dun protocole dans le code ne
constitue jamais une validation de son exécution.
## Terrain : neuf créations FULL réussies
Les **512 / 724 / 1024 × graines 0 / 42 / 2026** passent toutes sur la même
empreinte de sources
`4b0d9eb4456dc26ba75686322040eef02187b253db52277d5999a9b6eca36c8d`,
après les marais de surface et inférieurs. La [synthèse des reçus](../build/alpha25-evidence/matrix-final-summary.json)
contrôle pour chaque cas le rapport, le code de sortie et le log ; elle
référence leurs empreintes. Le manifeste complet passe désormais **12/12** :
**9/9 créations**, plus l'ordre inversé et les deux désactivations de structures.
| Taille / graine | Chunks FULL demandés | Chunks recensés pour le terrain | Mooshrooms observées | Escaliers du sanctuaire vérifiés |
| --- | ---: | ---: | ---: | ---: |
| 512 / 0 | 133 | 77 | 6 | 468 |
| 512 / 42 | 132 | 71 | 3 | 479 |
| 512 / 2026 | 115 | 71 | 6 | 450 |
| 724 / 0 | 138 | 77 | 4 | 432 |
| 724 / 42 | 129 | 72 | 3 | 482 |
| 724 / 2026 | 116 | 79 | 6 | 499 |
| 1024 / 0 | 143 | 92 | 6 | 465 |
| 1024 / 42 | 152 | 94 | 3 | 459 |
| 1024 / 2026 | 141 | 93 | 6 | 471 |
Chaque création observe cinq coffres de satellites, deux marchands et une
source de cascade sur un seul îlot. Les dix-huit îlots ont entre 76,12 % et
86,87 % de colonnes herbeuses, avec deux à quatre arbres chacun. Tous les
sanctuaires minéraux possèdent leur vrai départ et leur pièce sauvegardée :
28 blocs de cadre blackstone, 35 blocs d'ouverture d'air, 175 blocs d'accès
dégagés et aucun bloc de portail Nether. Le comptage des minerais, végétaux
et blocs naturels porte sur les chunks recensés, pas sur toute l'île.
Les échantillons de cavités ne garantissent pas un lac d'eau et un lac de
lave dans chaque monde : les refus naturels demeurent possibles.
Ces créations pillent des coffres et déplacent ou retirent des habitants dans
leurs mondes jetables. Elles ne remplacent pas à elles seules une réouverture
sur cette révision. Les anciens cycles de réouverture sont distingués plus bas.
Des serveurs isolés peuvent avoir partagé la machine avec le dernier Grand 2026,
puis l'ordre inversé et les OFF : voir [le relevé de concurrence](../build/alpha25-evidence/native-concurrency.json).
Cela ne transforme pas leurs chronomètres en comparaison contrôlée ; le
[bilan de performances](performance-alpha25.md) en précise les périmètres.
Le cas **Petit 512 / 0 en ordre inversé** passe aussi sur cette révision.
La [comparaison](../build/alpha25-evidence/reverse-comparison-final.json)
retrouve exactement géométrie des satellites, codec, pièces sauvegardées,
blocs contrôlés avant/après, mutations, végétation des îlots et mooshrooms.
**Elle ne démontre pas une île entière identique** : `terrain_blocks` diffère
pour certaines décorations et roches, par exemple herbe courte 1773/1441,
dripstone 15789/16432 et deepslate 450180/448994. Ces différences restent
publiées dans le rapport ; le scope réussi n'est pas étendu à ces totaux.
Les deux OFF **Moyen 724 / 0** passent dans des JVM distinctes. Avec toutes
les structures désactivées, plans aériens, secrets, rails, traversée et salle
restent absents. Avec seulement les expérimentales désactivées, les plans
optionnels restent absents, mais la traversée conserve ses 23 modules et la
salle son réseau ; la génération vanilla reste autorisée. Chaque scénario
inspecte trois chunks FULL naturels, conservant leurs biomes 25 et des roches
au-dessus de Y250, sans start ni rail dans ces échantillons. L'autorisation
des structures vanilla dans le second cas ne prouve donc pas leur présence.
Preuves : [OFF général](../build/alpha25-evidence/terrain-724-0-global-off-final1/global-off.json)
et [OFF expérimental](../build/alpha25-evidence/terrain-724-0-experimental-off-final1/experimental-off.json).
## Marais 3D : terrain réel et flore sous les voûtes
Les deux témoins passent chacun leurs **18 GameTests**. Les sites inférieurs
référencés depuis plusieurs régions sont dédupliqués avant le comptage.
| Contrôle FULL | Petit 512 / graine 0 | Petit 512 / graine 42 |
| --- | ---: | ---: |
| Strates de surface / souterraines du plan | 0 / 2336 | 1853 / 1237 |
| Niveaux deau des sites inférieurs | Y160 et Y170 | Y189 |
| Chunks explicitement demandés | 8 | 13 |
| Eau / berges de surface inspectées | 0 / 0 | 12 / 12 |
| Eau / berges souterraines inspectées | 12 / 5 | 12 / 2 |
| Paires solsous-sol exactement conformes | 17 | 38 |
| Quarts de biomes de cave préservés hors enveloppes | 689 | 1254 |
| Colonnes inférieures inspectées pour la flore | 579 | 323 |
| Blocs de végétation inférieure, tous sous roche | 38 | 8 |
| Hutte réelle avec sorcière et chat | Aucune admise | Une, en surface |
La graine 0 contient sous roche 12 nénuphars, 12 blocs de liane, 11 herbes courtes,
2 herbes hautes et 1 canne à sucre. La graine 42 contient sous roche 1 buisson à
lucioles, 4 nénuphars et 3 cannes à sucre ; ses 949 colonnes de surface inspectées
contiennent 53 herbes courtes, 6 cannes, 195 blocs de liane, 6 nénuphars et 2 champignons
bruns. Tous les végétaux observés satisfont leur condition native de survie.
Il sagit de blocs, pas de plantes indépendantes pour les végétaux multiblocs,
ni dun inventaire botanique de toute l’île.
Les enveloppes stockent réellement `sanctuary:terrain25_swamp` ; hors de leur
union, les quarts de biome des colonnes témoins restent égaux à la référence.
Les sols suivent leau préexistante, sans nouvelle nappe ou plateforme.
Aucun minerai ne figure dans les 55 paires inspectées : la préservation des
minerais est vérifiée séparément par le test sur états de blocs natifs, sans
revendiquer ici un filon réel. Aucune des deux graines na deux marais au même
X/Z : ce cas est couvert par une fixture pure à deux lacs superposés.
Preuves : [512/0 création](../build/alpha25-evidence/wetlands-lower-512-0-on-final3/create.json),
[son reçu](../build/alpha25-evidence/wetlands-lower-512-0-on-final3/run.json),
[512/42 création](../build/alpha25-evidence/wetlands-lower-512-42-on-final3/create.json)
et [son reçu](../build/alpha25-evidence/wetlands-lower-512-42-on-final3/run.json).
La graine 0 précède les deux gardes de flore ; la graine 42 les intègre
(source `9dec8ea64c6107ed873d86f013da8e01c8315dfcc262e72ed689394c1a1f4aef`).
Ses 77,723 s couvrent toute la commande ; les 10,745 s du rapport mesurent le
scénario après sa préparation, pas la création complète de l’île.
Les régressions corrigées sont le tuff non revêtu, une liane native continuée
après refus de son support et un champignon accepté avant éclairage définitif.
Pour ce dernier, le monde sauvegardé montrait un champignon sur herbe avec
SkyLight 13, trop lumineux selon vanilla, et une source de ciel 15 distante de
deux blocs. La garde conservative conserve les champignons abrités et les
substrats bénéficiant de lexemption vanilla ; les deux champignons du
nouveau témoin 42 restent valides. Voir [le relevé NBT](../build/alpha25-evidence/wetlands42-mushroom-light-audit.json).
Les anciens échecs restent archivés et ne sont pas comptés comme succès.
Avec **Structures Minecraft OFF**, puis **Expérimentales OFF**, le témoin 42
repasse dans deux nouvelles JVM sur les mêmes sources : 3090 strates, les
38 paires de sol, 1254 quarts de cave et 8 blocs de flore inférieure sont
conservés, sans hutte. Les trois cas totalisent **54 GameTests réussis**.
Le SHA-256 du plan naturel vaut dans les trois cas
`34335a9d8349c041201a3b5eef639a6e9be5ca838ebc6478296831a52c9a5ab6`.
Preuves : [rapport OFF](../build/alpha25-evidence/wetlands-lower-512-42-global-off-final1/wetlands25/global-off.json)
et [reçu](../build/alpha25-evidence/wetlands-lower-512-42-global-off-final1/run.json),
[rapport Expérimentales OFF](../build/alpha25-evidence/wetlands-lower-512-42-experimental-off-final1/wetlands25/experimental-off.json)
et [reçu](../build/alpha25-evidence/wetlands-lower-512-42-experimental-off-final1/run.json).
Ces résultats remplacent, pour le système de marais, les premiers témoins
ON/OFF qui ne couvraient encore que la surface.
La [comparaison des trois plans](../build/alpha25-evidence/wetlands-structure-options-comparison.json)
consigne leurs empreintes et leurs nombres de strates.
Un passage global OFF supplémentaire valide aussi la sélection locale de
hauteur des huttes avec les vrais tags natifs chargés : une colonne témoin
renvoie Y190 au-dessus de l'eau, puis Y191 sur une berge de pierre, sans
effacer son plafond supérieur. Les 18 GameTests et le marais réel passent
sur la source `36ebe0db5a294ed64b924d9952c828f76719cf200925849abc72a0ce5448a17f`
([reçu](../build/alpha25-evidence/wetlands-lower-512-42-global-off-heightmap-final2/run.json)).
La colonne de ce contrôle d'altitude est construite par le test : elle ne
prouve pas la présence d'une hutte souterraine naturellement générée.
## Monuments : six climats/tailles, douze phases natives
Les **douze phases** passent dans des JVM distinctes, avec serveur de graine 0,
île initiale 724, structures Minecraft ON et expérimentales OFF. Chaque création
utilise la vraie admission `Session.create` et capture le départ vanilla, ses
pièces et son emprise avant la réservation. Le premier candidat est accepté
pour chacune des six combinaisons.
| Océan | Admission | Périmètre atteint | Scénario création / rechargement |
| --- | ---: | --- | ---: |
| 512 glacial | 466 ms | Réservé, monument FULL | 28,06 / 3,21 s |
| 512 tempéré | 534 ms | **READY réel**, 1326 chunks | 275,87 / 2,92 s |
| 512 chaud | 471 ms | Réservé, monument FULL | 29,04 / 2,99 s |
| 1024 glacial | 563 ms | Réservé, monument FULL | 33,01 / 3,05 s |
| 1024 tempéré | 618 ms | Réservé, monument FULL | 31,16 / 2,97 s |
| 1024 chaud | 599 ms | Réservé, monument FULL | 31,30 / 3,53 s |
Chaque phase demande explicitement 37 chunks couvrant le monument, ses voisins
et le lieu dextraction du gardien. Chaque création observe trois grands
gardiens ; les réouvertures conservent un gardien tué absent, un gardien déplacé
hors du monument et le troisième sur place. Un bloc de prismarine démoli
reste absent, sans duplication ni reconstruction. Les monuments 512 contiennent
initialement 13242 blocs de prismarine et 128 lanternes ; les 1024 en contiennent
12847 et 114. Chacun contient huit blocs dor.
Les 207 positions inspectées au-dessus et autour du monument contiennent de
leau réelle, hors des portes et pièces intérieures. Le test autorise aussi la
glace native, mais ces témoins nen prouvent pas la présence. Chaque phase
obtient entre un et quatre gardiens ordinaires via `NaturalSpawner`, avec eau,
table native, collisions et distance au joueur. Ce contrôle ne mesure ni le
plafond global de mobs ni le débit dune ferme.
Locéan tempéré 512 atteint réellement READY après 1326 chunks préparés ; sa file
est inactive et le journal confirme cet état. Lattente réelle de préparation
vaut 248,21 s après le chargement local du monument. Le tag vanilla des cartes
océaniques trouve le départ enregistré, sans créer d’île ni modifier le
journal. Une seconde recherche exclut ce départ déjà référencé. Le compteur 1
persiste dans une autre JVM : le finder marque désormais le chunk gagnant à
sauvegarder après `addReference`. Ce correctif ne transforme pas la référence
cartographique en visite de joueur. Les cinq autres océans, restés réservés,
sont correctement exclus du locator ; ils ne prouvent ni READY ni localisation
positive.
Preuves : [tempéré 512 création](../build/alpha25-evidence/monument-512-temperate-create-final4/create.json)
et [rechargement](../build/alpha25-evidence/monument-512-temperate-reload-final4/reload.json).
Les cinq autres paires sont sous
`build/alpha25-evidence/monument-<taille>-<climat>-<create|reload>-final1/`
(climats `glacial`, `temperate`, `warm`). Leurs douze `run.json` ont exit 0 et
leurs logs confirment 18 GameTests chacun, sur lempreinte de sources commune
`75b3ed4eab89245acf8f9fc6860d59dfdb14571d14731f2c5d8e95ffd81e5ca7`.
Il sagit dune graine de serveur et dexpansions neuves ; ladmission dans du
vide déjà exploré avec certificat reste un contrôle distinct à recevoir.
Le [scénario océan général OFF](../build/alpha25-evidence/monument-global-off-final1/global-off.json)
passe aussi sur la source de la matrice de terrain. Une vraie réservation
d'océan 512 tempéré est acceptée sans plan de monument ni certificat ;
12 candidats natifs sont contrôlés, deux chunks FULL conservent 23664 blocs
d'eau et aucun monument. Les recherches restent vides, sans changer le
journal. Ce cas s'arrête à l'état réservé, sans prétendre atteindre READY.
## Hydrologie finale : une chute au vide, une sur le flanc
Le [témoin Petit 512 / 0](../build/alpha25-evidence/hydrology-512-0-wetlands-long-final1/hydrology25/512-0/full.json)
passe après les marais finaux, sur la même source `4b0d9eb4…` que les neuf
créations. Après activation des 176 chunks demandés puis **1800 ticks
d'observation native**, les surfaces d'eau **3343 et 1357 blocs** et de lave
**1195 blocs** restent complètes, avec leurs appuis ; les 64/51/64 berges
échantillonnées restent sèches et aucun contact eau/lave final n'est détecté
dans les volumes inspectés.
La lecture finale, sans l'ancien arrêt après 16 blocs de descente, trouve :
| Sortie de rivière | Plus basse eau connectée observée | Résultat |
| --- | --- | --- |
| Lèvre près de -147 243 0 | -157 **0** 5 | L'eau atteint réellement Y0 |
| Lèvre près de 19 243 -79 | 24 **199** -72 | L'eau rejoint un flanc naturel |
Les recherches n'atteignent pas leur borne de 120000 cellules. Elles restent
limitées à l'emprise de chaque sortie +7 blocs, avec un parcours horizontal
et descendant. Le second résultat ne signifie ni arrêt permanent de l'eau,
ni absence d'un autre trajet plus loin. Les 20498 mises à jour de fluides
proviennent du scheduler vanilla, sans préremplissage ni propagation manuelle.
Le contact eau/lave final ne retrace pas les réactions éventuellement déjà
terminées. Ce run ne remplace pas les témoins zéro/une sortie ni un essai visuel.
Voir [le reçu](../build/alpha25-evidence/hydrology-512-0-wetlands-long-final1/run.json)
et le [périmètre des chronomètres](performance-alpha25.md).
## Terrain et hydrologie : preuves antérieures distinctes
La [matrice des neuf plans](../build/alpha25-evidence/diagnostics-final1/plans.json)
512/724/1024 × graines 0/42/2026 passe après correction des membranes, sols DRY
et bornes de sondage : 12 lacs deau, 11 de lave et 7 sorties de rivière admis.
Ces plans ne sont pas des chunks FULL. Les sommets du relevé atteignent Y349 ;
Y358 reste un plafond, pas une hauteur imposée.
Les créations FULL 512/0 et 512/42 passent ; 512/0 passe aussi deux réouvertures.
Le premier témoin observe cinq satellites, deux marchands, six mooshrooms,
champignons géants, minerais profonds et lacs. Coffres pillés, extraction des
habitants et démolitions restent conservés après rechargement. Son sanctuaire
minéral contient 28 blocs de cadre blackstone, 35 blocs douverture dair,
175 blocs daccès libres et 468 escaliers avec orientations/appuis vérifiés,
sans surface de portail Nether. Les 133 appels FULL cumulent 48,02 s ; 64,66 s
incluent la boucle et lattente des entités. Preuves sous
`build/alpha25-evidence/terrain-512-0-<create|reload1|reload2>-final1/`
et `terrain-512-42-create-final1/`.
Les trois témoins hydrologiques passent sous **600 ticks natifs** après activation :
| Taille / graine | Cascades | Chunks FULL demandés | Surfaces eau / lave retenues | Ticks de fluides exécutés |
| --- | ---: | ---: | --- | ---: |
| 512 / 0 | 2 | 176 | 3343+1357 / 1195 | 14582 |
| 512 / 42 | 0 | 99 | 2414+1336 / aucune lave admise | 3213 |
| 1024 / 2026 | 1 | 247 | 1128 / 1018 | 24200 |
Les surfaces prévues restent présentes, leurs deux couches dappui continues,
et les berges échantillonnées sèches. Les écoulements sont connectés et
descendent dau moins 16 blocs ; cela ne prouve pas une chute jusqu’à Y0. Aucun
fluide nest prérempli et aucun tick ajouté hors de lordonnanceur vanilla.
La comparaison eau/lave porte sur l’état final, pas lhistorique de toutes
les réactions. Le premier témoin 512/0 a reçu une preuve complémentaire de
complétude des surfaces, après audit de la capture initialement filtrée :
`hydrology-512-0-attempt1/waterline-completeness.json`. Les deux autres utilisent
directement la capture complète corrigée. Rapports sous
`hydrology-512-0-attempt1/`, `hydrology-512-42-final1/` et
`hydrology-1024-2026-final1/`.
Les durées des appels FULL, de lattente des voisins/entités, de lordonnanceur
de fluides et la mémoire sont séparées dans le [bilan de performances](performance-alpha25.md).
Ni ces durées ni les mesures intermédiaires ne démontrent un gain global de
chargement face à 24 ou le temps de lancement dun client.
## Structures conditionnelles et contrôles restant ouverts
Le donjon vanilla à **-63 204 63** et la hutte à **-44 246 -125** sont vérifiés
en FULL 512/42 puis réouverts dans une autre JVM : un spawner réel, le départ
natif de la hutte et ses indicateurs de placement des habitants persistent.
Ce cycle, antérieur aux marais inférieurs, ne simule pas la mort ou lextraction
de la sorcière et du chat. Preuves sous `underground-512-42-<create|reload>-final1/`.
La nouvelle création des marais 42 confirme à nouveau la sorcière et le chat
vivants, avec les quatre appuis sur leur sol naturel.
Les quatre phases dédiées **village 512/18 et mine 724/42, création puis
réouverture dans des JVM distinctes**, passent : voir la
[synthèse](../build/alpha25-evidence/isolated-underground-required-summary.json).
Elles utilisent une copie isolée, source
`1f46e5cd111a91bc80c451b787aec8d0d1c437f50ad049007e7ab0c624c6c463`,
avec uniquement la normalisation native du comparateur NBT ajustée.
Le village de **Petit 512 / graine 18**, repéré à **-52 148 -5**, est désormais
vérifié sur 121 chunks FULL : 90 pièces Jigsaw, dont 14 maisons, et sept
zombie-villageois vanilla persistants. Les sept UUID sont conservés après
extraction d'un occupant, pillage d'un coffre et démolition d'un bloc de rue ;
le coffre reste vide et le bloc absent. Le premier décodage normalise les
28 emprises Jigsaw natives agrandies vers les dimensions exactes des templates ;
tous les autres champs, puis le second décodage, sont comparés strictement.
Le test vérifie le type natif guérissable, sans effectuer une guérison complète.
Preuves : [création](../build/alpha25-evidence/underground-512-18-village-create-required-nbt2/create.json)
et [rechargement](../build/alpha25-evidence/underground-512-18-village-reload-required-nbt2/reload.json).
La mine de **Moyen 724 / graine 42**, entrée **6 190 6**, possède 83 pièces
natives et est inspectée sur 33 chunks FULL. Les 204 rails, 187 toiles,
632 barrières et 895 planches observés restent identiques au rechargement.
Cinq wagonnets-coffres conservent leurs UUID, tables de butin et graines ;
deux spawners d'araignées venimeuses conservent leur configuration vanilla.
Ce cycle ne simule ni le pillage des wagonnets ni l'activation des spawners.
Preuves : [création](../build/alpha25-evidence/underground-724-42-mine-create-required/create.json)
et [rechargement](../build/alpha25-evidence/underground-724-42-mine-reload-required/reload.json).
La cabane du mineur n'a toujours pas de témoin natif positif : son refus
faute d'accès adéquat n'est pas présenté comme une génération réussie.
`./gradlew check build` passe avec **94 tâches et 24 GameTests obligatoires**
([journal](../build/alpha25-check-build-final.log)). L'assemblage
`./gradlew assemblePack -x :sanctuary:check` passe ensuite en une seconde,
sans répéter les vérifications déjà réussies. Le
[contrôle des artefacts](../build/alpha25-artifacts.json) et la
[réception locale](../build/alpha25-delivery-local.json) vérifient le JAR,
le MRpack **0.1.0-alpha.25** et le bootstrap Prism, conservant Minecraft
**26.3-pre-2**. Le SHA-256 du MRpack est
`7dcd58840714f19e3c0929c8618f7779b583ec1f76a1a8706f1183439d922754`.
Ces reçus locaux ne déclarent ni publication ni modification de l'instance
Prism ; ces deux opérations restent en cours.
Les limites ouvertes restent la cabane du mineur, l'admission dans du vide
déjà exploré avec certificat, le contrôle final du Blocodex et l'essai visuel
client. Les douze scénarios terrain et l'hydrologie réussis gardent leur portée
décrite plus haut. Le benchmark demandé pour l'alpha.26 viendra après la
livraison, sans retarder l'essai de cette version.
Les smokes déjà exécutés couvrent notamment géométrie, sols DRY, revêtement,
appuis de hutte dans les quatre orientations, réservations deau et codec du
monument. Ils restent distincts des preuves de terrain, habitants et fluides.
Les essais historiques 23/création et 24/création-rechargement déjà exécutés
sont archivés sous `historical*` ; ils ne prolongent pas les critères de réception.
## Parcours de visite facultatif
<details>
<summary>Afficher des repères précis pour l'essai visuel — emplacements révélés</summary>
Créer des **nouveaux mondes Sanctuary**, avec le bouton natif **Personnaliser**,
les tailles/graines du tableau et les deux options **Générer les structures**
et **Structures expérimentales** activées. Activer les commandes et passer
d'abord en spectateur :
```mcfunction
/gamemode spectator
```
Les points suivants servent à observer, pas à arriver en survie : ils peuvent
être proches d'un plafond, dans l'eau ou au-dessus du vide. Attendre les chunks
puis se déplacer pour examiner les raccords. Les preuves serveur n'équivalent
pas à des captures visuelles et la distance de rendu conditionne la visibilité.
| Monde | Repère | Commande en spectateur |
| --- | --- | --- |
| Petit 512 / 0 | Berge inférieure vérifiée, eau Y160 | `/tp @s 104.5 162 -7.5` |
| Petit 512 / 42 | Marais de surface, eau Y249 | `/tp @s -59.5 252 104.5` |
| Petit 512 / 42 | Berge du lac inférieur Y189 | `/tp @s -103.5 190 4.5` |
| Petit 512 / 42 | Hutte de surface habitée | `/tp @s -44 246 -125` |
| Petit 512 / 18 | Entrée du village souterrain, FULL et rechargement vérifiés | `/tp @s -52 148 -5` |
| Moyen 724 / 42 | Entrée de la mine native, FULL et rechargement vérifiés | `/tp @s 6 190 6` |
| Moyen 724 / 0 | Montgolfière | `/tp @s -492 288 110` |
| Moyen 724 / 0 | Navire de vivres | `/tp @s -35 186 -503` |
| Moyen 724 / 0 | Navire de matériel | `/tp @s 463 186 -204` |
| Moyen 724 / 0 | Îlot avec cascade | `/tp @s 399 237 333` |
| Moyen 724 / 0 | Îlot sans cascade | `/tp @s -277 288 456` |
| Moyen 724 / 0 | Approche du cadre minéral, air vérifié | `/tp @s 183.5 274 43.5` |
Dans les marais, regarder les berges, les petites plantes sous roche et la
variation du biome F3 en montant ou en sortant de la poche. Pour les satellites,
inspecter silhouettes, suspentes, ponts, filons et végétation. Le cadre minéral
est inactif : aucune dimension ne s'ouvre. Les repères viennent des rapports
de marais 512/0 et 512/42 cités plus haut et de
[Moyen 724/0 en FULL](../build/alpha25-evidence/terrain-724-0-wetlands-final1/create.json).
Les commandes opérateur existantes `/sanctuary structures` et
`/sanctuary census structures aerial` donnent les repères et la couverture du
monde courant. Une structure recensée comme planifiée n'est pas nécessairement
entièrement générée. Les repères du village et de la mine sont désormais
confirmés par les cycles FULL cités plus haut. Le
[guide local détaillé](../build/alpha25-player-witnesses.md), rédigé avant ces
deux cycles, conserve les sources et points de rivage des autres lieux.
</details>
## Reproduire les scénarios existants
Exécuter les commandes **séquentiellement**. Sans `sanctuaryExpansionReload=true`,
`runGameTest` recrée uniquement le monde de développement ignoré : ne jamais y
placer une sauvegarde personnelle. Les mesures utilisent les schedulers natifs,
sans sources reconstruites ni ticks forcés pour faire passer les assertions.
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryDensityMaps=false \
-PsanctuaryWetlands25Tests=true -PsanctuaryWetlands25Required=true \
-PsanctuaryWetlands25UndergroundRequired=true \
-PsanctuaryTestDiameter=512 -PsanctuaryTestSeed=42
# Pour chaque scénario OFF séparé : ajouter soit
# -PsanctuaryGenerateStructures=false, soit -PsanctuaryExperimentalStructures=false.
./gradlew :sanctuary:runGameTest -PsanctuaryDensityMaps=false \
-PsanctuaryTerrain25Tests=true -PsanctuaryTestDiameter=512 \
-PsanctuaryTestSeed=0 -PsanctuaryTerrain25Phase=create
# Réouverture immédiate du même monde : remplacer create par reload1 (puis reload2)
# et ajouter -PsanctuaryExpansionReload=true.
# Un nouvel ordre se teste avec -PsanctuaryTerrain25ReverseChunks=true.
./gradlew :sanctuary:runGameTest -PsanctuaryDensityMaps=false \
-PsanctuaryHydrology25Tests=true -PsanctuaryTestDiameter=512 \
-PsanctuaryTestSeed=0 -PsanctuaryHydrology25ExpectedFalls=2 \
-PsanctuaryHydrology25ObservationTicks=1800
# Témoins zéro/une sortie : 512/42 et 1024/2026 ; défaut 600 ticks, maximum 3600.
./gradlew :sanctuary:runGameTest -PsanctuaryDensityMaps=false \
-PsanctuaryMonument25Tests=true -PsanctuaryTestGeneration=25 \
-PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=0 \
-PsanctuaryExperimentalStructures=false -PsanctuaryMonument25Diameter=512 \
-PsanctuaryMonument25Climate=temperate -PsanctuaryMonument25Phase=create \
-PsanctuaryMonument25AwaitReady=true
# Réouverture immédiate : phase reload et -PsanctuaryExpansionReload=true.
# Pour le scénario océan global OFF : GenerateStructures=false, AwaitReady=false.
```
La préversion séparée `0.1.0-alpha.25-preview.1` (commit `817ab0b`) a passé
`check build assemblePack`,24 GameTests, 95 chunks FULL et 1800 ticks deau.
Son MRpack SHA-256 `a79e16d595906d636e0de16d4f6f1cc76df94145bbd4f5ed6747bc7de0a09429`
est vérifié, sans auto-update ni déplacement du canal stable. Son codec
`island_v25_preview_1` et ces preuves ne constituent pas la réception finale 25.
+70 -5
View File
@@ -22,6 +22,37 @@ L'objectif mythologique est de progresser d'une condition très limitée, proche
- **Humour, surprise et souvenirs.** Les noms d'entités, les événements, les animaux rares et les objets insolites comptent autant que les systèmes économiques.
- **Un développement par tickets.** Livrer une petite mécanique jouable et vérifiable, puis l'ajuster avec des tickets de fonctionnalités et de bugs. Ne pas construire tous les systèmes avant de pouvoir jouer.
## Minecraft comme archéologie dun autre Minecraft
**Canon et ligne de conception retenus.** Steve, Ari, Sunny, Kai, Zuri, Alex,
Mefe, Makena, Noor sont les personnages disparus de la partie. Ils ont découvert
quelque chose de révolutionnaire et ont continué parce que ça marchait. Jusquau
moment où ça na plus marché.
> Ils nont pas ouvert quelque chose quils nauraient pas dû ouvrir. Ils ont
> construit quelque chose quils ne savaient plus refermer.
Chaque ruine doit être une trace dune ancienne solution, pas seulement la trace
dun problème : une infrastructure dont lauteur a progressivement disparu
derrière son fonctionnement. Son ancien usage, ses circulations, ses réparations,
ses extensions et ses chantiers doivent permettre de comprendre ce qui avait
été résolu ici, même quand linstallation est abandonnée.
La nature de la découverte et les rôles individuels restent à définir. Aucune
catastrophe précise nest confirmée par ce principe. **La mise en scène de cette
histoire et ses liens avec la progression restent à implémenter** ; les versions
livrées et leurs tests établissent ce que chaque structure contient effectivement.
Les **Indoors** prolongent cette histoire comme espaces artificiels intentionnels,
référencés et contrôlés. Les **Backrooms** en sont les espaces orphelins : la
machine tente dorganiser de la matière dont elle a perdu le contexte. Le ballast
produit par les échanges, déplacements et créations despaces doit pouvoir y
inscrire le comportement réel des joueurs, jusqu’à rendre lhistoire économique
du serveur lisible dans leur géologie. Le [cahier de cosmologie](cosmologie.md)
conserve le texte de lauteur, ses exemples et les articulations encore à définir.
**Ces espaces, le shop et le ballast ne sont pas implémentés.** Le socle méta
des blocs pourra les alimenter ; il ne constitue pas encore un journal économique.
## Périmètre de départ
Le premier incrément porte sur le socle Fabric et la génération du monde : retrouver le générateur 26.2, produire une île principale dans un vrai vide, assurer une arrivée commune et sûre, puis poser une manière reproductible de générer des continents à une distance, une direction et une taille choisies.
@@ -171,6 +202,19 @@ L'ambition est de reprendre puis d'étendre la centaine de biomes du catalogue T
### Structures et territoires
**Direction en conception après lalpha.22 :** départ dans un endroit naturel,
puis découverte de quelques lieux isolés. La sélection retient lobservatoire,
la salle dexpansion, un atelier caché et la grande traversée, avec lajout à
concevoir dun donjon difficile, un boss et un objet de quête essentiel à la
progression. Les villes, regroupements et autres structures restent en réserve.
Les fonctions sont reproductibles chez les joueurs, y compris linstallation
dexpansion ; quelques exceptions collectives restent à définir. La salle
ancienne peut être rééquipée et servir de modèle. Voir le
[cahier de conception en discussion](structures-conception.md).
Ces décisions préparent la suite ; les paragraphes datés ci-dessous conservent
lhistorique des versions et ne signifient pas que cette nouvelle sélection est
déjà implémentée.
- **Structures vanilla** : admises dans les nouvelles générations lorsque leur
biome, leur emprise et leurs fondations conviennent au terrain flottant.
Lautorisation ne garantit pas leur présence sur chaque île. Le laboratoire
@@ -226,7 +270,7 @@ L'ambition est de reprendre puis d'étendre la centaine de biomes du catalogue T
ces connexions fait partie de lexploration. Privilégier les installations
encore utilisables et les chantiers interrompus pour raconter cette histoire.
Les back rooms de ces donjons sont des lieux physiques dans l’île, distincts
de la dimension Backrooms recevant les objets perdus. Le futur butin combine
des futurs espaces Backrooms recevant les objets perdus. Le futur butin combine
des reliques collectives uniques et des récompenses personnelles ; les
spawners pourront rester exploitables après exploration sans régénérer les
donjons. Les règles précises restent à concevoir. Le réacteur et ces nouveaux
@@ -238,14 +282,20 @@ L'ambition est de reprendre puis d'étendre la centaine de biomes du catalogue T
à construire ; voir [le contrat du prototype](testing-alpha17.md).
- **Secret rooms et challenge rooms** : des accès présents dans le monde mènent à des indoors aménagés, avec des équipements, des épreuves ou des gisements de fer, d'or, d'argent ou de titane.
### Dimensions
### Dimensions et espaces
| Dimension | Intention |
| Monde ou espace | Intention |
| --- | --- |
| **Cavernes** | Monde destiné au minage, avec des biomes souterrains, sans mineshafts ni parkings. Des cités anciennes particulièrement difficiles accueillent plusieurs types de squelettes et un nécromancien illageois qui les invoque. Il détient une boule de cristal. |
| **Alpha** | Monde inspiré de Minecraft Indev et des premières versions, avec les anciennes textures de Golden Days actives uniquement dans cette dimension. Notch y est un boss. |
| **Backrooms** | Monde sombre à plusieurs niveaux, exploré avec une source de lumière. Les joueurs y arrivent dans un lit. Il reçoit dans des coffres les objets perdus dans le vide ou brûlés ; c'est aussi un territoire d'exploration et un lieu de quête. |
| **Indoors** | Petits espaces associés à des objets, déclinés en classes de volume, avec une taille indicative allant de 5 × 5 × 5 à 100 × 100 × 100 blocs. Ils peuvent servir d'intérieurs, de salles secrètes ou de lieux d'événements. |
| **Backrooms** | Espaces artificiels orphelins dans la cosmologie, dont la géologie doit conserver lhistoire économique du serveur. Lintention antérieure dun territoire sombre à plusieurs niveaux, accessible par le lit et recueillant des objets perdus, reste à articuler avec cette direction. |
| **Indoors** | Espaces artificiels intentionnels, référencés et contrôlés : une porte peut mener à un intérieur plus grand que le bâtiment. Lauteur les décrit comme de petites dimensions instanciées. Les classes de volume envisagées vont indicativement de 5 × 5 × 5 à 100 × 100 × 100 blocs. |
Le [cahier de cosmologie](cosmologie.md#deux-devenirs-de-la-même-technologie)
distingue cette représentation narrative de son futur hébergement technique.
Un espace dit « orphelin » devra rester correctement identifié et sauvegardé
par Minecraft ; aucune perte réelle de référence ou corruption de chunks nest
prévue. Backrooms et Indoors restent à implémenter.
La géométrie des indoors reste à décider : certains peuvent être des volumes précisément délimités, d'autres des espaces fermés qui se rebouclent en trois dimensions, de type tore 3D. Leurs objets d'accès sont liés à la mailbox de leur propriétaire et doivent être récupérables s'ils sont perdus. Les règles de propriété, de partage, de transfert et de sortie sûre seront définies avant leur implémentation.
@@ -269,6 +319,20 @@ La progression peut débloquer une table de fabrication dans l'inventaire, des c
Les recettes de Just Enough Items sont révélées par paliers liés aux advancements. Les advancements Minecraft donnent accès aux recettes Minecraft ; ceux de Sanctuary ouvrent les blocs décoratifs, équipements et systèmes correspondants. Il faudra distinguer dans chaque ticket l'affichage des recettes, leur connaissance et l'autorisation réelle de fabriquer.
Le **Blocodex natif** fournit une mémoire personnelle des blocs : inconnus, vus,
minés, déjà possédés, jetés et posés, avec le stock porté actuel séparé de
lhistorique. Cette base appartient à Sanctuary et reste disponible sans
appareil à fabriquer. Les palettes de construction, recherches ou objectifs
pourront consulter ces informations côté serveur ; leurs conditions et
récompenses seront définies par leurs propres tickets. Le socle alpha.23 et
ses limites sont décrits dans [Blocodex](blocodex.md). Ce socle distingue aussi
les blocs du terrain chargé des objets en inventaires et stockages, avec des
relevés de couverture séparés. Un historique global par jour UTC conserve les
actions matérielles observées depuis linstallation. Ces données pourront
servir aux palettes, au shop, aux expansions et à la géologie historique des
Backrooms ; elles ne définissent pas encore les coûts, transactions ou règles
de ballast de ces systèmes.
### Construction et production
- **Mining** améliore la vitesse de minage et ouvre le vein mining : extraction de blocs d'une même veine ou sur un plan défini par l'orientation du joueur.
@@ -316,6 +380,7 @@ Les **conveyor belts**, fabriqués notamment avec du cuir de vache, déplacent l
## Machines, équipements et redstone
- **Ordinateur 8 bits** : bloc programmable avec six ports d'entrée et de sortie, permettant de construire ses propres comportements de redstone. Des évolutions en contrôleur et en ordinateur d'interface donnent accès, dans le monde, aux systèmes d'expansion.
- **Installation dexpansion à définir** : assembleur, bloc contrôleur, bloc terminal, bloc de dépôt et ancre spatiale sont les éléments actuellement envisagés. Leurs rôles et connexions restent à concevoir. Linstallation pourra être reconstruite ailleurs que dans la salle ancienne ; voir le [cahier de conception](structures-conception.md#ordinateurs-et-installation-dexpansion).
- **Terminal et storage en titane** : stockage de fin de progression, avec jusqu'à 128 coffres connectés consultables par un terminal commun, afin de déposer et retrouver ses objets sans tri manuel constant.
- **Chunky et clé de chunk** : bloc fabriquable maintenant un chunk actif. Son activation requiert une clé trouvable dans les donjons et structures, afin que le chargement permanent ait une valeur d'exploration et d'échange. L'orthographe des noms sera fixée à partir de l'historique.
- **Particuleur** : émetteur de particules dont l'effet dépend de l'objet inséré et dont l'intensité dépend du signal de redstone.
+2 -2
View File
@@ -8,6 +8,6 @@ loader_version=0.19.5
loom_version=1.17.20
fabric_api_version=0.160.0+26.3
mod_version=0.1.0-alpha.17
pack_version=0.1.0-alpha.17
mod_version=0.1.0-alpha.25
pack_version=0.1.0-alpha.25
maven_group=fr.koka.sanctuary
+338 -2
View File
@@ -40,13 +40,61 @@ tasks.named('runGameTest') {
systemProperty('sanctuary.test.unified', providers.gradleProperty('sanctuaryUnifiedTests').getOrElse('true'))
systemProperty('sanctuary.test.diameter', providers.gradleProperty('sanctuaryTestDiameter').getOrElse('724'))
systemProperty('sanctuary.test.generation', providers.gradleProperty('sanctuaryTestGeneration').getOrElse(
providers.gradleProperty('sanctuaryLostCityTests').map { it.toBoolean() }.getOrElse(false) ? '14' : providers.gradleProperty('sanctuaryLostCity15Tests').map { it.toBoolean() }.getOrElse(false) ? '15' : providers.gradleProperty('sanctuaryLostCity16Tests').map { it.toBoolean() }.getOrElse(false) ? '16' : '17'))
providers.gradleProperty('sanctuaryLostCityTests').map { it.toBoolean() }.getOrElse(false) ? '14' : providers.gradleProperty('sanctuaryLostCity15Tests').map { it.toBoolean() }.getOrElse(false) ? '15' : providers.gradleProperty('sanctuaryLostCity16Tests').map { it.toBoolean() }.getOrElse(false) ? '16' : providers.gradleProperty('sanctuaryRuins17Tests').map { it.toBoolean() }.getOrElse(false) ? '17' : providers.gradleProperty('sanctuaryHeritage19Tests').map { it.toBoolean() }.getOrElse(false) ? '19' : (providers.gradleProperty('sanctuaryHeritage20Tests').map { it.toBoolean() }.getOrElse(false) || providers.gradleProperty('sanctuaryLostCity20Tests').map { it.toBoolean() }.getOrElse(false)) ? '20' : (providers.gradleProperty('sanctuaryHeritage21Tests').map { it.toBoolean() }.getOrElse(false) || providers.gradleProperty('sanctuaryLostCity21Tests').map { it.toBoolean() }.getOrElse(false) || providers.gradleProperty('sanctuaryTransit21Tests').map { it.toBoolean() }.getOrElse(false)) ? '21' : providers.gradleProperty('sanctuarySelective22Tests').map { it.toBoolean() }.getOrElse(false) ? '22' : ['sanctuarySecrets23Tests', 'sanctuaryNative23Tests', 'sanctuaryOcean23Tests', 'sanctuaryAdmission23Tests'].any { providers.gradleProperty(it).map { v -> v.toBoolean() }.getOrElse(false) } ? '23' : ['sanctuaryAerial24Tests', 'sanctuaryAerial24FlowAdmission'].any { providers.gradleProperty(it).map { v -> v.toBoolean() }.getOrElse(false) } ? '24' : '25'))
systemProperty('sanctuary.test.terrain25', providers.gradleProperty('sanctuaryTerrain25Tests').getOrElse('false'))
systemProperty('sanctuary.test.hydrology25', providers.gradleProperty('sanctuaryHydrology25Tests').getOrElse('false'))
systemProperty('sanctuary.test.hydrology25.expectedFalls', providers.gradleProperty('sanctuaryHydrology25ExpectedFalls').getOrElse('-1'))
systemProperty('sanctuary.test.hydrology25.observationTicks', providers.gradleProperty('sanctuaryHydrology25ObservationTicks').getOrElse('600'))
systemProperty('sanctuary.test.wetlands25.required', providers.gradleProperty('sanctuaryWetlands25Required').getOrElse('false'))
systemProperty('sanctuary.test.wetlands25.undergroundRequired', providers.gradleProperty('sanctuaryWetlands25UndergroundRequired').getOrElse('false'))
systemProperty('sanctuary.test.benchmark.fixedCoordinates', providers.gradleProperty('sanctuaryBenchmarkFixedCoordinates').getOrElse('false'))
systemProperty('sanctuary.test.terrain25.phase', providers.gradleProperty('sanctuaryTerrain25Phase').getOrElse('create'))
systemProperty('sanctuary.test.terrain25.reverseChunks', providers.gradleProperty('sanctuaryTerrain25ReverseChunks').getOrElse('false'))
systemProperty('sanctuary.test.terrain25.requireMooshrooms', providers.gradleProperty('sanctuaryTerrain25RequireMooshrooms').getOrElse('false'))
systemProperty('sanctuary.test.terrain25.requireWaterLake', providers.gradleProperty('sanctuaryTerrain25RequireWaterLake').getOrElse('false'))
systemProperty('sanctuary.test.terrain25.requireLavaLake', providers.gradleProperty('sanctuaryTerrain25RequireLavaLake').getOrElse('false'))
systemProperty('sanctuary.test.underground25', providers.gradleProperty('sanctuaryUnderground25Tests').getOrElse('false'))
systemProperty('sanctuary.test.monument25', providers.gradleProperty('sanctuaryMonument25Tests').getOrElse('false'))
systemProperty('sanctuary.test.monument25.diameter', providers.gradleProperty('sanctuaryMonument25Diameter').getOrElse('512'))
systemProperty('sanctuary.test.monument25.climate', providers.gradleProperty('sanctuaryMonument25Climate').getOrElse('temperate'))
systemProperty('sanctuary.test.monument25.phase', providers.gradleProperty('sanctuaryMonument25Phase').getOrElse('create'))
systemProperty('sanctuary.test.monument25.awaitReady', providers.gradleProperty('sanctuaryMonument25AwaitReady').getOrElse('false'))
systemProperty('sanctuary.test.underground25.planOnly', providers.gradleProperty('sanctuaryUnderground25PlanOnly').getOrElse('false'))
systemProperty('sanctuary.test.underground25.required', providers.gradleProperty('sanctuaryUnderground25Required').getOrElse(''))
systemProperty('sanctuary.test.underground25.phase', providers.gradleProperty('sanctuaryUnderground25Phase').getOrElse('create'))
systemProperty('sanctuary.test.underground25.search.seeds', providers.gradleProperty('sanctuaryUnderground25SearchSeeds').getOrElse('1,2,3,4,5,6'))
systemProperty('sanctuary.test.underground25.search.diameter', providers.gradleProperty('sanctuaryUnderground25SearchDiameter').getOrElse('724'))
systemProperty('sanctuary.test.underground25.search.required', providers.gradleProperty('sanctuaryUnderground25SearchRequired').getOrElse(''))
systemProperty('sanctuary.test.underground25.search.dryOnly', providers.gradleProperty('sanctuaryUnderground25SearchDryOnly').getOrElse('false'))
systemProperty('sanctuary.test.underground25.search.stopAfterFirstDry', providers.gradleProperty('sanctuaryUnderground25SearchStopAfterFirstDry').getOrElse('false'))
systemProperty('sanctuary.test.aerial24.flowAdmission', providers.gradleProperty('sanctuaryAerial24FlowAdmission').getOrElse('false'))
systemProperty('sanctuary.test.aerial24', providers.gradleProperty('sanctuaryAerial24Tests').getOrElse('false'))
systemProperty('sanctuary.test.aerial24.reverseChunks', providers.gradleProperty('sanctuaryAerial24ReverseChunks').getOrElse('false'))
systemProperty('sanctuary.test.aerial24.phase', providers.gradleProperty('sanctuaryAerial24Phase').getOrElse('create'))
systemProperty('sanctuary.test.secrets23', providers.gradleProperty('sanctuarySecrets23Tests').getOrElse('false'))
systemProperty('sanctuary.test.admission23', providers.gradleProperty('sanctuaryAdmission23Tests').getOrElse('false'))
systemProperty('sanctuary.test.admission23.reserveOnly', providers.gradleProperty('sanctuaryAdmission23ReserveOnly').getOrElse('false'))
systemProperty('sanctuary.test.admission23.diameter', providers.gradleProperty('sanctuaryAdmission23Diameter').getOrElse('256'))
systemProperty('sanctuary.test.admission23.climate', providers.gradleProperty('sanctuaryAdmission23Climate').getOrElse('temperate'))
systemProperty('sanctuary.test.admission23.relief', providers.gradleProperty('sanctuaryAdmission23Relief').getOrElse('natural'))
systemProperty('sanctuary.test.ocean23', providers.gradleProperty('sanctuaryOcean23Tests').getOrElse('false'))
systemProperty('sanctuary.test.native23', providers.gradleProperty('sanctuaryNative23Tests').getOrElse('false'))
systemProperty('sanctuary.test.generateStructures', providers.gradleProperty('sanctuaryGenerateStructures').getOrElse('true'))
systemProperty('sanctuary.test.selective22', providers.gradleProperty('sanctuarySelective22Tests').getOrElse('false'))
systemProperty('sanctuary.transit.plannerDiagnostics', providers.gradleProperty('sanctuaryTransitDiagnostics').getOrElse('false'))
systemProperty('sanctuary.expansion.structureDiagnostics', providers.gradleProperty('sanctuaryStructureDiagnostics').getOrElse('false'))
systemProperty('sanctuary.test.heritage21', providers.gradleProperty('sanctuaryHeritage21Tests').getOrElse('false'))
systemProperty('sanctuary.test.lostCity21', providers.gradleProperty('sanctuaryLostCity21Tests').getOrElse('false'))
systemProperty('sanctuary.test.transit21', providers.gradleProperty('sanctuaryTransit21Tests').getOrElse('false'))
systemProperty('sanctuary.test.expansion', providers.gradleProperty('sanctuaryExpansionTests').getOrElse('false'))
systemProperty('sanctuary.test.expansionReload', providers.gradleProperty('sanctuaryExpansionReload').getOrElse('false'))
systemProperty('sanctuary.test.lostCity', providers.gradleProperty('sanctuaryLostCityTests').getOrElse('false'))
systemProperty('sanctuary.test.lostCity15', providers.gradleProperty('sanctuaryLostCity15Tests').getOrElse('false'))
systemProperty('sanctuary.test.lostCity20', providers.gradleProperty('sanctuaryLostCity20Tests').getOrElse('false'))
systemProperty('sanctuary.test.lostCity16', providers.gradleProperty('sanctuaryLostCity16Tests').getOrElse('false'))
systemProperty('sanctuary.test.ruins17', providers.gradleProperty('sanctuaryRuins17Tests').getOrElse('false'))
systemProperty('sanctuary.test.heritage20', providers.gradleProperty('sanctuaryHeritage20Tests').getOrElse('false'))
systemProperty('sanctuary.test.heritage19', providers.gradleProperty('sanctuaryHeritage19Tests').getOrElse('false'))
systemProperty('sanctuary.test.experimentalStructures', providers.gradleProperty('sanctuaryExperimentalStructures').getOrElse('true'))
systemProperty('sanctuary.test.cityMatrix', providers.gradleProperty('sanctuaryCityMatrix').getOrElse('false'))
systemProperty('sanctuary.test.cityMatrixOnly', providers.gradleProperty('sanctuaryCityMatrixOnly').getOrElse('false'))
@@ -69,10 +117,50 @@ tasks.named('processGametestResources') {
inputs.property('lostCity16', lostCity16)
def ruins17 = providers.gradleProperty('sanctuaryRuins17Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('ruins17', ruins17)
def lostCity20 = providers.gradleProperty('sanctuaryLostCity20Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('lostCity20', lostCity20)
def heritage20 = providers.gradleProperty('sanctuaryHeritage20Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('heritage20', heritage20)
def heritage21 = providers.gradleProperty('sanctuaryHeritage21Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('heritage21', heritage21)
def lostCity21 = providers.gradleProperty('sanctuaryLostCity21Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('lostCity21', lostCity21)
def terrain25 = providers.gradleProperty('sanctuaryTerrain25Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('terrain25', terrain25)
def hydrology25 = providers.gradleProperty('sanctuaryHydrology25Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('hydrology25', hydrology25)
def wetlands25 = providers.gradleProperty('sanctuaryWetlands25Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('wetlands25', wetlands25)
def underground25Search = providers.gradleProperty('sanctuaryUnderground25Search').map { it.toBoolean() }.getOrElse(false)
inputs.property('underground25Search', underground25Search)
def terrain25Diagnostics = providers.gradleProperty('sanctuaryTerrain25Diagnostics').map { it.toBoolean() }.getOrElse(false)
inputs.property('terrain25Diagnostics', terrain25Diagnostics)
def monument25 = providers.gradleProperty('sanctuaryMonument25Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('monument25', monument25)
def underground25 = providers.gradleProperty('sanctuaryUnderground25Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('underground25', underground25)
def aerial24Flow = providers.gradleProperty('sanctuaryAerial24FlowAdmission').map { it.toBoolean() }.getOrElse(false)
inputs.property('aerial24Flow', aerial24Flow)
def aerial24 = providers.gradleProperty('sanctuaryAerial24Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('aerial24', aerial24)
def secrets23 = providers.gradleProperty('sanctuarySecrets23Tests').map { it.toBoolean() }.getOrElse(false)
def admission23 = providers.gradleProperty('sanctuaryAdmission23Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('admission23', admission23)
def ocean23 = providers.gradleProperty('sanctuaryOcean23Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('ocean23', ocean23)
def native23 = providers.gradleProperty('sanctuaryNative23Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('secrets23', secrets23)
inputs.property('native23', native23)
def selective22 = providers.gradleProperty('sanctuarySelective22Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('selective22', selective22)
def transit21 = providers.gradleProperty('sanctuaryTransit21Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('transit21', transit21)
def heritage19 = providers.gradleProperty('sanctuaryHeritage19Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('heritage19', heritage19)
def voidReuse = providers.gradleProperty('sanctuaryVoidReuseTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('voidReuse', voidReuse)
filesMatching('fabric.mod.json') {
expand(gametest_entrypoint: ruins17 ? 'fr.koka.sanctuary.gametest.Ruins17WorldGameTests' : voidReuse ? 'fr.koka.sanctuary.gametest.ExpansionVoidReuseGameTests' : lostCity16 ? 'fr.koka.sanctuary.gametest.LostCity16WorldGameTests' : lostCity15 ? 'fr.koka.sanctuary.gametest.LostCity15WorldGameTests' : lostCity ? 'fr.koka.sanctuary.gametest.LostCityWorldGameTests' : expansion ? 'fr.koka.sanctuary.gametest.ExpansionWorldGameTests' : benchmark
expand(gametest_entrypoint: underground25Search ? 'fr.koka.sanctuary.gametest.Underground25SeedSearch' : wetlands25 ? 'fr.koka.sanctuary.gametest.Wetlands25WorldGameTests' : hydrology25 ? 'fr.koka.sanctuary.gametest.Hydrology25WorldGameTests' : terrain25 ? 'fr.koka.sanctuary.gametest.Terrain25WorldGameTests' : terrain25Diagnostics ? 'fr.koka.sanctuary.gametest.Terrain25Diagnostics' : monument25 ? 'fr.koka.sanctuary.gametest.Expansion25MonumentGameTests' : underground25 ? 'fr.koka.sanctuary.gametest.Underground25WorldGameTests' : aerial24Flow ? 'fr.koka.sanctuary.gametest.Aerial24FlowAdmissionGameTests' : aerial24 ? 'fr.koka.sanctuary.gametest.Aerial24WorldGameTests' : admission23 ? 'fr.koka.sanctuary.gametest.Expansion23AdmissionGameTests' : ocean23 ? 'fr.koka.sanctuary.gametest.Ocean23WorldGameTests' : secrets23 ? 'fr.koka.sanctuary.gametest.Secrets23WorldGameTests' : native23 ? 'fr.koka.sanctuary.gametest.Native23WorldGameTests' : selective22 ? 'fr.koka.sanctuary.gametest.Selective22WorldGameTests' : transit21 ? 'fr.koka.sanctuary.gametest.Transit21WorldGameTests' : lostCity21 ? 'fr.koka.sanctuary.gametest.LostCity21WorldGameTests' : heritage21 ? 'fr.koka.sanctuary.gametest.Heritage21WorldGameTests' : lostCity20 ? 'fr.koka.sanctuary.gametest.LostCity20WorldGameTests' : heritage20 ? 'fr.koka.sanctuary.gametest.Heritage20WorldGameTests' : heritage19 ? 'fr.koka.sanctuary.gametest.Heritage19WorldGameTests' : ruins17 ? 'fr.koka.sanctuary.gametest.Ruins17WorldGameTests' : voidReuse ? 'fr.koka.sanctuary.gametest.ExpansionVoidReuseGameTests' : lostCity16 ? 'fr.koka.sanctuary.gametest.LostCity16WorldGameTests' : lostCity15 ? 'fr.koka.sanctuary.gametest.LostCity15WorldGameTests' : lostCity ? 'fr.koka.sanctuary.gametest.LostCityWorldGameTests' : expansion ? 'fr.koka.sanctuary.gametest.ExpansionWorldGameTests' : benchmark
? 'fr.koka.sanctuary.gametest.WorldgenPerformanceGameTests'
: unified ? 'fr.koka.sanctuary.gametest.UnifiedWorldGameTests'
: 'fr.koka.sanctuary.gametest.SanctuaryWorldGameTests')
@@ -132,6 +220,24 @@ tasks.named('check') { dependsOn('hydrologySmoke') }
// src/test contains a JavaExec assertion harness; engine tests use Fabric's gametest source set.
tasks.named('test') { failOnNoDiscoveredTests = false }
tasks.register('blockKnowledgeSmoke', JavaExec) {
group = 'verification'
description = 'Check personal block history, atomic persistence and corrupt-file refusal.'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.knowledge.BlockKnowledgeSmoke'
}
tasks.named('check') { dependsOn('blockKnowledgeSmoke') }
tasks.register('materialActivitySmoke', JavaExec) {
group = 'verification'
description = 'Check dated material observations, daily isolation and atomic persistence.'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.knowledge.history.MaterialActivitySmoke'
}
tasks.named('check') { dependsOn('materialActivitySmoke') }
tasks.register('naturalHydrologySmoke', JavaExec) {
group = 'verification'
description = 'Check broad shores, supported sediments and natural rock springs.'
@@ -415,3 +521,233 @@ tasks.named('check') { dependsOn('lostCityFoundation16Smoke') }
}
tasks.named('check') { dependsOn(smoke) }
}
// Local native starts keep the long heritage routes bounded by Minecraft's reference range.
['Planner', 'Renderer', 'Piece'].each { component ->
def smoke = tasks.register("heritage19${component}Smoke", JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka.sanctuary.worldgen.heritage.Heritage${component}19Smoke"
}
tasks.named('check') { dependsOn(smoke) }
}
// Generation20 architecture retains the previous renderers and verifies connected block states.
[
connectedBlocks20Smoke: 'detail.ConnectedBlocks20Smoke',
heritage20RendererSmoke: 'heritage.HeritageRenderer20Smoke',
heritage20PieceSmoke: 'heritage.HeritagePiece20Smoke',
ruins20RendererSmoke: 'ruins.RuinsRenderer20Smoke',
lostCityUnderground20Smoke: 'city.LostCityUnderground20Smoke',
lostCitySurface20Smoke: 'city.LostCitySurface20Smoke',
lostCityInteriors20Smoke: 'city.LostCityInteriors20Smoke'
].each { name, harness ->
def smoke = tasks.register(name, JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka.sanctuary.worldgen.${harness}"
}
tasks.named('check') { dependsOn(smoke) }
}
// Optional actual block-state exports for architectural review; never enabled in ordinary checks.
tasks.named('heritage20RendererSmoke') {
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
systemProperty('sanctuary.test.heritage20.export', rootProject.layout.buildDirectory.dir('alpha20-preview/heritage').get().asFile.absolutePath)
}
tasks.named('lostCityInteriors20Smoke') {
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
systemProperty('sanctuary.city20.preview', rootProject.layout.buildDirectory.file('alpha20-preview/city.json').get().asFile.absolutePath)
}
tasks.named('lostCitySurface20Smoke') {
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
systemProperty('sanctuary.city20.exteriorPreview', rootProject.layout.buildDirectory.file('alpha20-preview/exterior.json').get().asFile.absolutePath)
}
// Generation21 keeps published20 renderers immutable.
[
heritage21RendererSmoke: 'heritage.HeritageRenderer21Smoke',
heritage21PieceSmoke: 'heritage.HeritagePiece21Smoke',
heritage21PlannerSmoke: 'heritage.HeritagePlanner21Smoke',
lostCity21PlannerSmoke: 'city.LostCityPlanner21Smoke',
lostCitySurface21Smoke: 'city.LostCitySurface21Smoke',
lostCityInteriors21Smoke: 'city.LostCityInteriors21Smoke',
transit21Smoke: 'transit.Transit21Smoke'
].each { name, harness ->
def smoke = tasks.register(name, JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka.sanctuary.worldgen.${harness}"
}
tasks.named('check') { dependsOn(smoke) }
}
tasks.named('heritage21RendererSmoke') {
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
systemProperty('sanctuary.test.heritage21.export', rootProject.layout.buildDirectory.dir('alpha21-preview/heritage').get().asFile.absolutePath)
}
tasks.named('lostCityInteriors21Smoke') {
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
systemProperty('sanctuary.city21.preview', rootProject.layout.buildDirectory.file('alpha21-preview/city.json').get().asFile.absolutePath)
}
tasks.named('lostCitySurface21Smoke') {
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
systemProperty('sanctuary.city21.exteriorPreview', rootProject.layout.buildDirectory.file('alpha21-preview/exterior.json').get().asFile.absolutePath)
}
// Generation22 filters surface placement and adds an independent core hall; historical tests still run.
[
lostCity22PlannerSmoke: 'city.LostCityPlanner22Smoke',
underground22Smoke: 'city.LostCityUnderground22Smoke',
heritage22PlannerSmoke: 'heritage.HeritagePlanner22Smoke',
transit22PlannerSmoke: 'transit.TransitPlanner22Smoke',
transit22RouteSmoke: 'transit.TransitRoute22Smoke',
transit22RendererSmoke: 'transit.TransitRenderer22Smoke'
].each { name, harness ->
def smoke = tasks.register(name, JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka.sanctuary.worldgen.${harness}"
}
tasks.named('check') { dependsOn(smoke) }
}
// New geography has a pure harness in addition to native world witnesses.
tasks.register('expansionOcean23Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.expansion.ExpansionOcean23Smoke'
}
tasks.named('check') { dependsOn('expansionOcean23Smoke') }
[
secrets23Smoke: 'fr.koka.sanctuary.worldgen.mineral.Secrets23Smoke',
structureCensusSmoke: 'fr.koka.sanctuary.knowledge.structures.StructureCensusSmoke',
expansionPriorities23Smoke: 'fr.koka.sanctuary.expansion.ExpansionPriorities23Smoke'
].each { taskName, harness ->
tasks.register(taskName, JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = harness
}
tasks.named('check') { dependsOn(taskName) }
}
// Versioned aerial planner: bounded search, native pieces and local claims.
tasks.register('aerial24Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.aerial.Aerial24Smoke'
}
tasks.named('check') { dependsOn('aerial24Smoke') }
tasks.register('aerialPersistence24Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.aerial.AerialPersistence24Smoke'
}
tasks.named('check') { dependsOn('aerialPersistence24Smoke') }
// Generation25 keeps its geometry tests separate from the historical witnesses.
tasks.register('mineral25Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.mineral.Mineral25Smoke'
}
tasks.named('check') { dependsOn('mineral25Smoke') }
tasks.register('terrain25ShapeSmoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.Terrain25ShapeSmoke'
}
tasks.named('check') { dependsOn('terrain25ShapeSmoke') }
tasks.register('expansionMonument25Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.expansion.ExpansionMonument25Smoke'
}
tasks.named('check') { dependsOn('expansionMonument25Smoke') }
tasks.register('initialFlows25Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.expansion.ExpansionInitialFlows25Smoke'
}
tasks.named('check') { dependsOn('initialFlows25Smoke') }
tasks.register('terrain25CoatingSmoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.Terrain25CoatingSmoke'
}
tasks.named('check') { dependsOn('terrain25CoatingSmoke') }
tasks.register('underground25Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.underground25.Underground25Smoke'
}
tasks.named('check') { dependsOn('underground25Smoke') }
tasks.register('terrain25ProtectionSmoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.Terrain25ProtectionSmoke'
}
tasks.named('check') { dependsOn('terrain25ProtectionSmoke') }
tasks.register('terrain25ColumnBoundsSmoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.Terrain25ColumnBoundsSmoke'
}
tasks.named('check') { dependsOn('terrain25ColumnBoundsSmoke') }
tasks.register('terrain25DryFloorSmoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.Terrain25DryFloorSmoke'
}
tasks.named('check') { dependsOn('terrain25DryFloorSmoke') }
tasks.register('terrain25WetlandsSmoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.Terrain25WetlandsSmoke'
}
tasks.named('check') { dependsOn('terrain25WetlandsSmoke') }
tasks.register('terrain25WetlandSoilSmoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.Terrain25WetlandSoilSmoke'
}
tasks.named('check') { dependsOn('terrain25WetlandSoilSmoke') }
tasks.register('terrain25WetlandPlantLightSmoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.Terrain25WetlandPlantLightSmoke'
}
tasks.named('check') { dependsOn('terrain25WetlandPlantLightSmoke') }
@@ -0,0 +1,295 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.client.SanctuaryBlocodexScreen;
import fr.koka.sanctuary.knowledge.BlockKnowledge;
import fr.koka.sanctuary.knowledge.BlockKnowledgePayloads;
import io.netty.buffer.Unpooled;
import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.AbstractWidget;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.CycleButton;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.ObjectSelectionList;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.world.item.Items;
/** Native rendering with an explicit test fixture; no world or server state is fabricated. */
final class BlocodexClientChecks {
private static final KeyEvent PRESS = new KeyEvent(InputConstants.KEY_RETURN, 0, 0);
private static final String LARGE_COUNTER_ID = "minecraft:ancient_debris";
private static final String REMOVED_MOD_ID = "fixture_missing_mod:retained_block";
static void run(ClientGameTestContext context) {
Screen previous = context.computeOnClient(client -> client.gui.screen());
int previousScale = context.computeOnClient(client -> client.options.guiScale().get());
List<BlockKnowledge> fixture = context.computeOnClient(client -> fixture());
List<Map<String, Object>> screenshots = new ArrayList<>();
try {
context.runOnClient(client -> {
client.options.guiScale().set(1);
client.resizeGui();
client.gui.setScreen(new SanctuaryBlocodexScreen());
});
SanctuaryBlocodexScreen screen = context.computeOnClient(client -> (SanctuaryBlocodexScreen) client.gui.screen());
context.runOnClient(client -> {
require(list(screen).children().isEmpty(), "No connection must not invent unknown block rows");
fixtureRequest(screen, 401);
screen.accept(wireRoundTrip(part(fixture, 999, 0)));
require(list(screen).children().isEmpty(), "A stale request must not populate the screen");
screen.accept(wireRoundTrip(part(fixture, 401, 0)));
require(list(screen).children().isEmpty(), "An incomplete snapshot must not reveal partial knowledge");
for (int index = 1; index < partCount(fixture); index++) {
screen.accept(wireRoundTrip(part(fixture, 401, index)));
}
require(filteredCount(screen) == fixture.size(), "The full snapshot must become visible together");
verifyControls(screen, fixture);
assertWidgetBounds(screen);
});
context.waitTick();
Path large = context.takeScreenshot("sanctuary-blocodex-fixture-large");
screenshots.add(context.computeOnClient(client -> Map.of("file", large.toString(),
"gui_width", screen.width, "gui_height", screen.height, "gui_scale", client.getWindow().getGuiScale())));
context.runOnClient(client -> {
// Vanilla auto scale chooses its largest permitted scale and therefore its smallest GUI.
client.options.guiScale().set(0);
client.resizeGui();
require(client.gui.screen() == screen, "Changing GUI scale must retain the Blocodex");
require(filteredCount(screen) == fixture.size(), "Resizing must retain the loaded catalogue");
assertWidgetBounds(screen);
verifyControls(screen, fixture);
});
context.waitTick();
Path small = context.takeScreenshot("sanctuary-blocodex-fixture-minimum-gui");
screenshots.add(context.computeOnClient(client -> Map.of("file", small.toString(),
"gui_width", screen.width, "gui_height", screen.height, "gui_scale", client.getWindow().getGuiScale())));
context.runOnClient(client -> {
search(screen).setValue(LARGE_COUNTER_ID);
require(list(screen).children().size() == 1, "Counter fixture must be reachable by identifier");
String narration = list(screen).children().getFirst().getNarration().getString();
require(narration.contains(Long.toString(Long.MAX_VALUE))
&& narration.contains(Long.toString(Long.MAX_VALUE - 1))
&& narration.contains(Long.toString(Long.MAX_VALUE - 2))
&& narration.contains(Integer.toString(Integer.MAX_VALUE)),
"Exact counters must remain available in narration without overflow or truncation");
});
context.waitTick();
context.takeScreenshot("sanctuary-blocodex-fixture-large-counters");
context.clickScreenButton("sanctuary.blocodex.help");
context.runOnClient(client -> {
require(client.gui.screen() != screen, "Help must open an actual readable screen");
assertWidgetBounds(client.gui.screen());
});
context.waitTick();
context.takeScreenshot("sanctuary-blocodex-help-minimum-gui");
context.clickScreenButton("gui.back");
context.runOnClient(client -> {
require(client.gui.screen() == screen, "Closing help must return to the same Blocodex");
require(search(screen).getValue().equals(LARGE_COUNTER_ID), "Help must retain the search");
verifyFragmentFailures(screen, fixture);
});
Path diagnostics = Path.of("diagnostics").toAbsolutePath();
Files.createDirectories(diagnostics);
Files.writeString(diagnostics.resolve("sanctuary-blocodex-client.json"),
new GsonBuilder().setPrettyPrinting().create().toJson(Map.of(
"fixture", "Explicit client test snapshot; no server or saved world is used",
"entries", fixture.size(), "fragments", partCount(fixture),
"search_filters_pagination", true, "exact_counter_narration", true,
"fragment_atomicity_and_stale_requests", true, "invalid_snapshots_clear_state", true,
"screenshots", screenshots, "passed", true)) + "\n");
SanctuaryMod.LOGGER.info("Blocodex native client checks passed: explicit fixture, real widgets, two GUI scales, exact counters and fragment atomicity.");
} catch (IOException failure) {
throw new AssertionError("Unable to write Blocodex client diagnostics", failure);
} finally {
context.runOnClient(client -> {
client.gui.setScreen(previous);
client.options.guiScale().set(previousScale);
client.resizeGui();
});
}
}
private static List<BlockKnowledge> fixture() {
List<BlockKnowledge> entries = new ArrayList<>();
entries.add(new BlockKnowledge(LARGE_COUNTER_ID, true, true,
Long.MAX_VALUE, Long.MAX_VALUE - 1, Long.MAX_VALUE - 2, Integer.MAX_VALUE));
List<String> ids = BuiltInRegistries.BLOCK.stream()
.filter(block -> block.asItem() != Items.AIR)
.map(block -> BuiltInRegistries.BLOCK.getKey(block).toString())
.filter(id -> !id.equals(LARGE_COUNTER_ID)).sorted().limit(258).toList();
require(ids.size() == 258, "The native registry must contain enough blocks to exercise three fragments");
for (int index = 0; index < ids.size(); index++) {
int evidence = index % 7;
entries.add(new BlockKnowledge(ids.get(index), evidence == 0, evidence == 1,
evidence == 2 ? 7 : 0, evidence == 3 ? 11 : 0,
evidence == 4 ? 13 : 0, evidence == 5 ? 17 : 0));
}
entries.add(new BlockKnowledge(REMOVED_MOD_ID, false, true, 0, 0, 0, 0));
return List.copyOf(entries);
}
private static void verifyControls(SanctuaryBlocodexScreen screen, List<BlockKnowledge> fixture) {
search(screen).setValue(REMOVED_MOD_ID.toUpperCase(java.util.Locale.ROOT));
require(filteredCount(screen) == 1 && list(screen).children().size() == 1,
"Identifier search must ignore case and preserve absent mod identifiers");
search(screen).setValue("fixture_no_matching_block");
require(filteredCount(screen) == 0 && list(screen).children().isEmpty()
&& !button(screen, "<").active && !button(screen, ">").active,
"Empty search results must not retain a previous page or active pagination");
search(screen).setValue("");
for (var filter : Map.<String, Predicate<BlockKnowledge>>of(
"UNKNOWN", entry -> !entry.known(), "KNOWN", BlockKnowledge::known,
"SEEN", BlockKnowledge::seen, "POSSESSED", BlockKnowledge::possessed,
"MINED", entry -> entry.mined() > 0, "DROPPED", entry -> entry.dropped() > 0,
"PLACED", entry -> entry.placed() > 0, "CARRIED", entry -> entry.carried() > 0).entrySet()) {
chooseFilter(screen, filter.getKey());
require(filteredCount(screen) == fixture.stream().filter(filter.getValue()).count(),
"Facet " + filter.getKey() + " must use only its own evidence");
}
chooseFilter(screen, "ALL");
require(filteredCount(screen) == fixture.size(), "All filter must restore the whole catalogue");
List<String> first = narrations(screen);
require(!first.isEmpty() && button(screen, ">").active && !button(screen, "<").active,
"A full catalogue must expose another page while starting on page one");
button(screen, ">").onPress(PRESS);
require(!narrations(screen).equals(first) && button(screen, "<").active,
"Next page must replace the visible rows and enable return");
button(screen, "<").onPress(PRESS);
require(narrations(screen).equals(first), "Returning to the first page must restore its exact rows");
}
private static void verifyFragmentFailures(SanctuaryBlocodexScreen screen, List<BlockKnowledge> fixture) {
search(screen).setValue("");
List<String> before = narrations(screen);
fixtureRequest(screen, 402);
screen.accept(part(fixture, 402, 0));
screen.accept(part(fixture, 401, 1));
require(narrations(screen).equals(before) && filteredCount(screen) == fixture.size(),
"An incomplete refresh and stale fragment must retain only the prior complete snapshot");
screen.accept(part(fixture, 402, 2));
require(list(screen).children().isEmpty() && filteredCount(screen) == 0,
"Out-of-order fragments must invalidate the view without inventing zero counters");
fixtureRequest(screen, 403);
List<BlockKnowledge> duplicate = new ArrayList<>(fixture);
duplicate.set(128, duplicate.getFirst());
screen.accept(part(duplicate, 403, 0));
screen.accept(part(duplicate, 403, 1));
require(list(screen).children().isEmpty(), "Duplicate block identifiers across fragments must never publish");
fixtureRequest(screen, 404);
screen.accept(new BlockKnowledgePayloads.SnapshotPart(404, 0, 1, 0, false, List.of()));
require(list(screen).children().isEmpty() && !(boolean) read(screen, "available"),
"An unhealthy server snapshot must remain unavailable rather than become an unknown catalogue");
fixtureRequest(screen, 405);
for (int index = 0; index < partCount(fixture); index++) screen.accept(part(fixture, 405, index));
require(filteredCount(screen) == fixture.size(), "A complete fresh request must recover after a failed snapshot");
}
private static BlockKnowledgePayloads.SnapshotPart part(List<BlockKnowledge> entries, int request, int index) {
int start = index * BlockKnowledgePayloads.FRAGMENT_SIZE;
return new BlockKnowledgePayloads.SnapshotPart(request, index, partCount(entries), entries.size(), true,
entries.subList(start, Math.min(entries.size(), start + BlockKnowledgePayloads.FRAGMENT_SIZE)));
}
private static int partCount(List<BlockKnowledge> entries) {
return Math.max(1, (entries.size() + BlockKnowledgePayloads.FRAGMENT_SIZE - 1) / BlockKnowledgePayloads.FRAGMENT_SIZE);
}
private static BlockKnowledgePayloads.SnapshotPart wireRoundTrip(BlockKnowledgePayloads.SnapshotPart part) {
RegistryFriendlyByteBuf buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), RegistryAccess.EMPTY);
try {
BlockKnowledgePayloads.SnapshotPart.CODEC.encode(buffer, part);
var decoded = BlockKnowledgePayloads.SnapshotPart.CODEC.decode(buffer);
require(decoded.equals(part) && buffer.readableBytes() == 0,
"The real payload codec must retain exact counters, flags and fragment boundaries");
return decoded;
} finally {
buffer.release();
}
}
private static void fixtureRequest(SanctuaryBlocodexScreen screen, int request) {
// Only tests bypass the live connection prerequisite. Production has no fixture entry point.
write(screen, "activeRequest", request);
write(screen, "nextPart", 0);
write(screen, "expectedParts", -1);
write(screen, "expectedTotal", -1);
write(screen, "pending", true);
write(screen, "lastRequestTick", 1_000_000); // Keep screenshots independent of offline auto-refresh.
((List<?>) read(screen, "incoming")).clear();
((java.util.Set<?>) read(screen, "incomingIds")).clear();
}
private static int filteredCount(SanctuaryBlocodexScreen screen) {
return ((List<?>) read(screen, "filtered")).size();
}
private static List<String> narrations(SanctuaryBlocodexScreen screen) {
return list(screen).children().stream().map(entry -> entry.getNarration().getString()).toList();
}
private static ObjectSelectionList<?> list(Screen screen) {
return screen.children().stream().filter(ObjectSelectionList.class::isInstance)
.map(child -> (ObjectSelectionList<?>) child).findFirst().orElseThrow();
}
private static EditBox search(Screen screen) {
return screen.children().stream().filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow();
}
private static Button button(Screen screen, String text) {
return screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(button -> button.getMessage().getString().equals(text)).findFirst().orElseThrow();
}
private static void chooseFilter(Screen screen, String target) {
CycleButton<?> button = screen.children().stream().filter(CycleButton.class::isInstance)
.map(CycleButton.class::cast).findFirst().orElseThrow();
int attempts = 9;
while (!button.getValue().toString().equals(target) && attempts-- > 0) button.onPress(PRESS);
require(button.getValue().toString().equals(target), "Filter cycle must expose " + target);
}
private static void assertWidgetBounds(Screen screen) {
for (var child : screen.children()) {
if (child instanceof AbstractWidget widget) {
require(widget.getX() >= 0 && widget.getY() >= 0
&& widget.getX() + widget.getWidth() <= screen.width
&& widget.getY() + widget.getHeight() <= screen.height,
"Widget must remain inside the native GUI: " + widget.getMessage().getString());
}
}
}
private static Object read(Object target, String name) {
try { Field field = target.getClass().getDeclaredField(name); field.setAccessible(true); return field.get(target); }
catch (ReflectiveOperationException failure) { throw new AssertionError("Missing client fixture field " + name, failure); }
}
private static void write(Object target, String name, Object value) {
try { Field field = target.getClass().getDeclaredField(name); field.setAccessible(true); field.set(target, value); }
catch (ReflectiveOperationException failure) { throw new AssertionError("Missing client fixture field " + name, failure); }
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -0,0 +1,230 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.client.SanctuaryBlocodexScreen;
import fr.koka.sanctuary.knowledge.BlockKnowledge;
import fr.koka.sanctuary.knowledge.BlockKnowledgePayloads;
import fr.koka.sanctuary.knowledge.BlockKnowledgeService;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.ObjectSelectionList;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
/** Real key mapping, connection and server observations in a newly created development world. */
final class BlocodexNetworkClientChecks {
private static final String SEEN_ID = "minecraft:gold_block";
private static final String POSSESSED_ID = "minecraft:diamond_block";
private static final String UNKNOWN_ID = "minecraft:emerald_block";
static void run(ClientGameTestContext context) {
Path runDirectory = Path.of("").toAbsolutePath().normalize();
Path saves = context.computeOnClient(client -> client.getLevelSource().getBaseDir().toAbsolutePath().normalize());
require(runDirectory.endsWith(Path.of("build", "run", "clientGameTest"))
&& saves.startsWith(runDirectory),
"The network test must create worlds only inside the ignored development client run directory");
context.runOnClient(client -> {
var matches = Arrays.stream(client.options.keyMappings)
.filter(key -> key.getName().equals("sanctuary.blocodex.key")).toList();
require(matches.size() == 1, "Exactly one innate Blocodex key mapping must be registered");
var key = matches.getFirst();
require(key.getCategory().id().equals(SanctuaryMod.id("sanctuary"))
&& key.matches(new KeyEvent(InputConstants.KEY_B, 0, 0)),
"The Sanctuary category must expose the real B binding in the development client");
});
String worldName = "Sanctuary Blocodex network " + UUID.randomUUID();
Path saveDirectory;
UUID playerId;
Map<String, BlockKnowledge> firstServerSnapshot;
Map<String, BlockKnowledge> secondServerSnapshot;
// Fabric's consistent defaults are a fresh flat world, seed 1, no structures or mobs.
try (var world = context.worldBuilder().adjustSettings(settings -> {
settings.setName(worldName);
settings.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
settings.setAllowCommands(false);
}).create()) {
saveDirectory = world.getWorldSave().getSaveDirectory().toAbsolutePath().normalize();
require(saveDirectory.startsWith(saves) && !saveDirectory.equals(saves),
"The new test save must remain inside the development saves directory");
var connection = world.getConnection();
var serverContext = world.getServer();
connection.waitForChunksDownload(1200);
context.waitFor(client -> client.player != null && client.gui.screen() == null
&& ClientPlayNetworking.canSend(BlockKnowledgePayloads.Request.TYPE), 600);
playerId = serverContext.computeOnServer(server -> player(server).getUUID());
serverContext.runOnServer(server -> {
require(!BlockKnowledgeService.knowledge(player(server), Blocks.GOLD_BLOCK).known(),
"A fresh flat world must not already know the observation target");
require(!BlockKnowledgeService.knowledge(player(server), Blocks.DIAMOND_BLOCK).known(),
"A fresh player must not already possess the inventory target");
});
BlockPos target = serverContext.computeOnServer(server -> {
ServerPlayer player = player(server);
BlockPos pos = player.blockPosition().offset(0, 1, 3);
// Explicit test setup; normal tick observation must discover both facts itself.
player.level().setBlockAndUpdate(pos, Blocks.GOLD_BLOCK.defaultBlockState());
player.getInventory().setItem(0, new ItemStack(Items.DIAMOND_BLOCK, 3));
player.inventoryMenu.broadcastChanges();
return pos;
});
connection.waitForClientboundPackets();
context.waitFor(client -> client.level.getBlockState(target).is(Blocks.GOLD_BLOCK)
&& client.player.getInventory().getItem(0).is(Items.DIAMOND_BLOCK), 600);
context.getInput().lookAt(target);
connection.waitForServerboundPackets();
context.waitTicks(BlockKnowledgeService.OBSERVATION_INTERVAL + 1);
serverContext.waitFor(server -> {
ServerPlayer player = player(server);
var observed = BlockKnowledgeService.knowledge(player, Blocks.GOLD_BLOCK);
var carried = BlockKnowledgeService.knowledge(player, Blocks.DIAMOND_BLOCK);
return observed.seen() && carried.possessed() && carried.carried() == 3;
}, 600);
firstServerSnapshot = expected(serverContext);
require(firstServerSnapshot.get(SEEN_ID).seen()
&& !firstServerSnapshot.get(SEEN_ID).possessed()
&& !firstServerSnapshot.get(POSSESSED_ID).seen()
&& firstServerSnapshot.get(POSSESSED_ID).carried() == 3
&& !firstServerSnapshot.get(UNKNOWN_ID).known(),
"The live server must retain independent seen, possessed and unknown states");
SanctuaryBlocodexScreen first = openWithB(context);
context.runOnClient(client -> assertSnapshot(first, firstServerSnapshot));
context.runOnClient(client -> search(first).setValue(POSSESSED_ID));
context.waitTick();
context.takeScreenshot("sanctuary-blocodex-network-carried-three");
context.getInput().pressKey(InputConstants.KEY_ESCAPE);
context.waitFor(client -> client.gui.screen() == null, 120);
serverContext.runOnServer(server -> {
ServerPlayer player = player(server);
player.getInventory().setItem(0, ItemStack.EMPTY);
player.inventoryMenu.broadcastChanges();
});
context.waitTicks(2);
serverContext.waitFor(server -> {
var knowledge = BlockKnowledgeService.knowledge(player(server), Blocks.DIAMOND_BLOCK);
return knowledge.possessed() && knowledge.carried() == 0;
}, 600);
connection.waitForClientboundPackets();
secondServerSnapshot = expected(serverContext);
// The production request limiter uses wall time, while game-test ticks can run faster.
long reopenAt = System.nanoTime() + 1_100_000_000L;
context.waitFor(client -> System.nanoTime() >= reopenAt, 2400);
SanctuaryBlocodexScreen second = openWithB(context);
require(second != first, "Closing and reopening with B must issue a fresh screen request");
context.runOnClient(client -> {
assertSnapshot(second, secondServerSnapshot);
search(second).setValue(POSSESSED_ID);
require(list(second).children().size() == 1,
"The received block must remain searchable after its current inventory becomes empty");
});
context.waitTick();
context.takeScreenshot("sanctuary-blocodex-network-sticky-possession");
context.getInput().pressKey(InputConstants.KEY_ESCAPE);
context.waitFor(client -> client.gui.screen() == null, 120);
}
try {
Path sidecar = saveDirectory.resolve("data/sanctuary-blocodex").resolve(playerId + ".json");
require(Files.isRegularFile(sidecar), "Closing the real server must persist its observed player history");
Path diagnostics = runDirectory.resolve("diagnostics");
Files.createDirectories(diagnostics);
Files.writeString(diagnostics.resolve("sanctuary-blocodex-network-client.json"),
new GsonBuilder().setPrettyPrinting().create().toJson(Map.of(
"fixture", "New flat development world; gold block and three diamond blocks prepared on the server",
"save_directory", saveDirectory.toString(), "player_uuid", playerId.toString(),
"real_b_key_and_payloads", true, "server_gaze_and_inventory", true,
"before_removal", firstServerSnapshot, "after_removal", secondServerSnapshot,
"sidecar_saved_on_close", true, "passed", true)) + "\n");
SanctuaryMod.LOGGER.info("Blocodex network client checks passed: real B key, native server snapshots, observed gaze, sticky possession and empty carried stock in {}", saveDirectory);
} catch (IOException failure) {
throw new AssertionError("Unable to verify native Blocodex network diagnostics", failure);
}
}
private static SanctuaryBlocodexScreen openWithB(ClientGameTestContext context) {
context.getInput().pressKey(InputConstants.KEY_B);
context.waitForScreen(SanctuaryBlocodexScreen.class);
context.waitFor(client -> client.gui.screen() instanceof SanctuaryBlocodexScreen screen
&& (boolean) read(screen, "loaded"), 1200);
return context.computeOnClient(client -> (SanctuaryBlocodexScreen) client.gui.screen());
}
private static Map<String, BlockKnowledge> expected(TestServerContext context) {
return context.computeOnServer(server -> {
ServerPlayer player = player(server);
return Map.of(SEEN_ID, BlockKnowledgeService.knowledge(player, Blocks.GOLD_BLOCK),
POSSESSED_ID, BlockKnowledgeService.knowledge(player, Blocks.DIAMOND_BLOCK),
UNKNOWN_ID, BlockKnowledgeService.knowledge(player, Blocks.EMERALD_BLOCK));
});
}
private static void assertSnapshot(SanctuaryBlocodexScreen screen, Map<String, BlockKnowledge> expected) {
require((boolean) read(screen, "available"), "The real server snapshot must be available");
List<?> rows = (List<?>) read(screen, "catalogue");
Map<String, BlockKnowledge> received = rows.stream().map(BlocodexNetworkClientChecks::knowledge)
.collect(Collectors.toUnmodifiableMap(BlockKnowledge::blockId, value -> value));
for (var entry : expected.entrySet()) {
require(entry.getValue().equals(received.get(entry.getKey())),
"The live network snapshot must retain the server's exact evidence and counters for " + entry.getKey());
}
}
private static BlockKnowledge knowledge(Object row) {
try {
Method method = row.getClass().getDeclaredMethod("knowledge");
method.setAccessible(true);
return (BlockKnowledge) method.invoke(row);
} catch (ReflectiveOperationException failure) {
throw new AssertionError("Unable to read received Blocodex row", failure);
}
}
private static ServerPlayer player(MinecraftServer server) {
List<ServerPlayer> players = server.getPlayerList().getPlayers();
require(players.size() == 1, "The development integrated server must have one actual connected player");
return players.getFirst();
}
private static EditBox search(SanctuaryBlocodexScreen screen) {
return screen.children().stream().filter(EditBox.class::isInstance)
.map(EditBox.class::cast).findFirst().orElseThrow();
}
private static ObjectSelectionList<?> list(SanctuaryBlocodexScreen screen) {
return screen.children().stream().filter(ObjectSelectionList.class::isInstance)
.map(child -> (ObjectSelectionList<?>) child).findFirst().orElseThrow();
}
private static Object read(Object target, String name) {
try { Field field = target.getClass().getDeclaredField(name); field.setAccessible(true); return field.get(target); }
catch (ReflectiveOperationException failure) { throw new AssertionError("Unable to read received Blocodex state " + name, failure); }
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -17,6 +17,8 @@ public final class SanctuaryClientRenderTests implements FabricClientGameTest {
SanctuaryMod.LOGGER.info("Client render tests passed: Sanctuary sky and fog mixins applied.");
});
SanctuaryWorldOptionsClientChecks.run(context);
BlocodexClientChecks.run(context);
BlocodexNetworkClientChecks.run(context);
}
private static void assertApplied(ClassLoader loader, String target, String hook) {
@@ -0,0 +1,179 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.expansion.*;
import fr.koka.sanctuary.knowledge.structures.StructureCensusMemory;
import fr.koka.sanctuary.knowledge.structures.StructureCensusService;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.aerial.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.TicketType;
import net.minecraft.tags.FluidTags;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.StructureStart;
/** Separate disposable world: real admission before visiting satellites, then an actual ticking waterfall. */
public final class Aerial24FlowAdmissionGameTests {
private record Request(String id, String direction, int diameter, ExpansionIsland raw, ExpansionIsland filtered) {}
@GameTest(maxTicks = 24000)
public void reservedSatellitesAndNativeWaterfall(GameTestHelper helper) throws Exception {
var report = new LinkedHashMap<String, Object>();
var level = helper.getLevel();
var generator = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 24 && generator.experimentalStructures()
&& level.structureManager().shouldGenerateStructures(), "Fresh generation24 with aerial sites and native structures enabled");
var plan = generator.aerialPlan24();
var session = Objects.requireNonNull(ExpansionRuntime.session(level));
helper.assertTrue(session.islands().size() == 1 && plan.sites().size() == 5, "Use an untouched disposable world for admission witnesses");
var evidence = StructureCensusService.snapshot(level.getServer()).evidence();
String dimension = level.dimension().identifier().toString();
for (var site : plan.sites()) for (var chunk : site.claimedChunks())
helper.assertTrue((evidence.chunks().getOrDefault(new StructureCensusMemory.ChunkKey(dimension, chunk.x(), chunk.z()), 0) & StructureCensusMemory.FULL) == 0,
"Satellite claims are protected before any of their chunks reach FULL");
report.put("seed", level.getSeed()); report.put("diameter", generator.initialDiameter()); report.put("process_id", ProcessHandle.current().pid());
report.put("unvisited_claims_verified", true); report.put("sites", plan.sites());
var admitted = new ArrayList<ExpansionIsland>();
try {
report.put("stage", "real admission toward an unseen satellite");
admitted.add(admit(helper, level, generator, session, plan, true, report));
report.put("stage", "real admission through an unclaimed interval");
admitted.add(admit(helper, level, generator, session, plan, false, report));
} catch (Exception error) { failure(report, error); throw error; }
var waterfall = plan.sites().stream().filter(AerialPlan24.Site::waterfall).findFirst().orElseThrow();
var requested = waterfall.claimedChunks(); var iterator = requested.iterator();
boolean[] tickets = {false}; int[] flowTicks = {0};
Runnable release = () -> {
if (tickets[0]) requested.forEach(chunk -> level.getChunkSource().removeTicketWithRadius(TicketType.DRAGON, chunk, 2));
tickets[0] = false;
};
Runnable[] step = new Runnable[1];
step[0] = () -> {
try {
if (admitted.stream().anyMatch(island -> !session.island(island.id()).status().equals("ready"))) {
report.put("stage", "prepare admitted expansions"); report.put("progress", session.progress());
helper.assertTrue(!session.progress().contains("failed") && !session.progress().contains("reload_required")
&& !session.progress().contains("unavailable"), "Real admission preparation progresses without reload or failure");
helper.runAtTickTime(helper.getTick() + 5, () -> step[0].run()); return;
}
if (!report.containsKey("native_ready_starts")) {
var nativeStarts = new TreeMap<String, Integer>();
for (var reserved : admitted) {
var ready = session.island(reserved.id());
helper.assertTrue(ready.equals(reserved.ready()) && !plan.conflicts(ready), "READY retains its immutable safe placement");
int count = 0;
for (var candidate : ExpansionStructures23.candidates(ready)) {
var chunk = level.getChunk(candidate.chunk().x(), candidate.chunk().z(), ChunkStatus.STRUCTURE_STARTS, true);
count += (int) chunk.getAllStarts().values().stream().filter(StructureStart::isValid)
.filter(start -> ((Object) start) instanceof NativeStructureMetadata23 metadata && metadata.sanctuary$native23() != null).count();
}
helper.assertTrue(count > 0, "Admitted generation24 expansion produces at least one real native start");
nativeStarts.put(ready.id(), count);
}
report.put("native_ready_starts", nativeStarts);
}
if (iterator.hasNext()) {
var chunk = iterator.next(); report.put("stage", "FULL waterfall " + chunk);
level.getChunk(chunk.x(), chunk.z(), ChunkStatus.FULL, true);
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run()); return;
}
if (!tickets[0]) {
requested.forEach(chunk -> level.getChunkSource().addTicketWithRadius(TicketType.DRAGON, chunk, 2)); tickets[0] = true;
}
flowTicks[0] += 20; report.put("flow_wait_ticks", flowTicks[0]); report.put("stage", "native fluid ticks toward the void");
var source = AerialGeometry24.waterfallSource(waterfall);
helper.assertTrue(level.getFluidState(source).is(FluidTags.WATER) && level.getFluidState(source).isSource(), "The authored source remains a single native water source");
var below = waterAt(level, waterfall, waterfall.minY() - 16);
var low = waterAt(level, waterfall, 8);
report.put("water_below_islet", below == null ? List.of() : coords(below));
report.put("water_near_y0", low == null ? List.of() : coords(low));
if (below == null || low == null) {
helper.assertTrue(flowTicks[0] <= 4000, "Scheduled vanilla fluid ticks carry the waterfall below the island and down to y=8");
helper.runAtTickTime(helper.getTick() + 20, () -> step[0].run()); return;
}
helper.assertTrue(!level.getFluidState(low).isSource(), "Low waterfall water is flowing, not a fabricated source column");
report.put("expansions", admitted.stream().map(island -> session.island(island.id())).toList());
report.put("passed", true);
StructureCensusService.flush(level.getServer());
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Persist the disposable flow/admission witness");
write(report, "full.json"); release.run(); helper.succeed();
} catch (Exception error) { release.run(); failure(report, error); throw new IllegalStateException("Aerial24 flow/admission failed: " + error.getMessage(), error); }
};
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run());
}
private static ExpansionIsland admit(GameTestHelper helper, ServerLevel level, SanctuaryChunkGenerator generator,
ExpansionRuntime.Session session, AerialPlan24 plan, boolean collision, Map<String, Object> report) throws Exception {
var requests = requests(level.getSeed(), session.islands(), plan, collision);
helper.assertTrue(!requests.isEmpty(), "The finite candidate search contains a " + (collision ? "satellite overlap" : "free interval") + " witness");
var attempts = new ArrayList<Map<String, Object>>(); report.put(collision ? "collision_attempts" : "interval_attempts", attempts);
for (var request : requests.stream().limit(8).toList()) {
var before = List.copyOf(session.islands());
var filtered = session.candidate(request.id(), "sanctuary", request.direction(), "temperate", request.diameter(), "natural");
helper.assertTrue(filtered.equals(request.filtered()), "Real runtime candidate uses the same immutable aerial claims before any terrain inspection");
if (collision) helper.assertTrue(plan.conflicts(request.raw()) && !plan.conflicts(filtered) && !filtered.equals(request.raw()),
"The first overlapping candidate is rejected before the satellite is visited");
long start = System.nanoTime();
try {
var reserved = session.create(request.id(), "sanctuary", request.direction(), "temperate", request.diameter(), "natural");
long elapsed = (System.nanoTime() - start) / 1_000_000;
helper.assertTrue(elapsed <= 31_000, "Actual Session.create keeps its production thirty-second deadline");
helper.assertTrue(!plan.conflicts(reserved), "The admitted expansion cannot claim any satellite chunk");
helper.assertTrue(!ExpansionVoidReuse.requiresReload(level, reserved.id()), "Untouched candidate prepares in the same development session");
if (!collision) helper.assertTrue(inAerialBand(reserved, plan), "The admitted free interval lies among the nearby satellites, not beyond a blanket exclusion ring");
helper.assertTrue(session.create(request.id(), "sanctuary", request.direction(), "temperate", request.diameter(), "natural").equals(reserved),
"Reservation retry is idempotent");
attempts.add(Map.of("request", request, "reserved", reserved, "elapsed_ms", elapsed));
return reserved;
} catch (IllegalArgumentException refused) {
helper.assertTrue(before.equals(session.islands()), "Refused native admission writes no reservation");
attempts.add(Map.of("request", request, "refused", String.valueOf(refused.getMessage()), "elapsed_ms", (System.nanoTime() - start) / 1_000_000));
if (!"native_structure_unavailable".equals(refused.getMessage())) throw refused;
}
}
throw new IllegalStateException("No native expansion fits the bounded aerial admission witnesses");
}
private static List<Request> requests(long seed, List<ExpansionIsland> islands, AerialPlan24 plan, boolean collision) {
var result = new ArrayList<Request>();
for (int index = 0; index < 32 && result.size() < 16; index++) for (var direction : ExpansionIsland.DIRECTIONS) {
String id = (collision ? "aerial_collision_" : "aerial_interval_") + index + "_" + direction.replace("_", "");
var raw = ExpansionPlacement.candidates(seed, islands, id, "sanctuary", direction, "temperate", 128, "natural", 0);
if (raw.isEmpty() || plan.conflicts(raw.getFirst()) != collision) continue;
var filtered = raw.stream().filter(candidate -> !plan.conflicts(candidate)).findFirst().orElse(null);
if (filtered == null || !collision && !inAerialBand(filtered, plan)) continue;
result.add(new Request(id, direction, 128, raw.getFirst(), filtered));
}
return result;
}
private static boolean inAerialBand(ExpansionIsland island, AerialPlan24 plan) {
double outside = plan.claimsChunks().stream().map(ChunkPos::unpack).mapToDouble(chunk -> Math.hypot(chunk.getMiddleBlockX(), chunk.getMiddleBlockZ()) + 12).max().orElseThrow();
return Math.hypot(island.centerX(), island.centerZ()) - island.reservedRadius() < outside;
}
private static BlockPos waterAt(ServerLevel level, AerialPlan24.Site site, int y) {
for (int x = site.minX() - 3; x <= site.maxX() + 3; x++) for (int z = site.minZ() - 3; z <= site.maxZ() + 3; z++) {
var pos = new BlockPos(x, y, z);
if (level.getFluidState(pos).is(FluidTags.WATER)) return pos;
}
return null;
}
private static List<Integer> coords(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
private static void write(Map<String, Object> report, String name) throws java.io.IOException {
var directory = Path.of("diagnostics/aerial24-flow-admission"); Files.createDirectories(directory);
Files.writeString(directory.resolve(name), new GsonBuilder().setPrettyPrinting().create().toJson(report));
}
private static void failure(Map<String, Object> report, Exception error) {
report.put("passed", false); report.put("failure", error.toString());
var stack = new java.io.StringWriter(); error.printStackTrace(new java.io.PrintWriter(stack)); report.put("stacktrace", stack.toString());
SanctuaryMod.LOGGER.error("Aerial24 flow/admission failed at {}", report.get("stage"), error);
try { write(report, "failure.json"); } catch (java.io.IOException failed) { error.addSuppressed(failed); }
}
}
@@ -0,0 +1,399 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import fr.koka.sanctuary.expansion.ExpansionStructures23;
import fr.koka.sanctuary.knowledge.structures.StructureCensusService;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.aerial.*;
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryOps;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.TicketType;
import net.minecraft.world.entity.npc.villager.Villager;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.LevelResource;
import net.minecraft.world.level.storage.loot.LootTable;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
/** One real world per size/seed. Separate JVM phases preserve looting, mining and extracted occupants. */
public final class Aerial24WorldGameTests {
private static final com.google.gson.Gson JSON = new GsonBuilder().setPrettyPrinting().create();
@GameTest(maxTicks = 24000)
public void fiveSitesPersistAfterUse(GameTestHelper helper) throws Exception {
var report = new LinkedHashMap<String, Object>();
try { begin(helper, report); }
catch (Exception failure) { failure(report, failure); throw failure; }
}
private static void begin(GameTestHelper helper, Map<String, Object> report) throws Exception {
var level = helper.getLevel();
var generator = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 24, "Aerial24 requires a disposable generation24 world");
String phase = System.getProperty("sanctuary.test.aerial24.phase", "create");
helper.assertTrue(List.of("create", "reload1", "reload2").contains(phase), "Known aerial lifecycle phase");
boolean create = phase.equals("create");
var plan = generator.aerialPlan24();
report.put("phase", phase); report.put("seed", level.getSeed()); report.put("diameter", generator.initialDiameter());
report.put("process_id", ProcessHandle.current().pid()); report.put("experimental", generator.experimentalStructures());
report.put("generate_structures", level.structureManager().shouldGenerateStructures());
if (!level.structureManager().shouldGenerateStructures() || !generator.experimentalStructures()) {
helper.assertTrue(plan.isEmpty() && plan.claimsChunks().isEmpty(), "Disabled aerial generation reserves no space");
helper.assertTrue(StructureCensusService.aerialSites(level).isEmpty(), "Disabled aerial sites are not presented as planned");
report.put("passed", true); write(Path.of("diagnostics/aerial24/off.json"), report); helper.succeed(); return;
}
helper.assertTrue(plan.sites().size() == 5, "All five initial aerial sites have a finite saved plan");
helper.assertTrue(plan.sites().stream().filter(AerialPlan24.Site::merchant).count() == 2, "Two merchant ships");
helper.assertTrue(plan.sites().stream().filter(AerialPlan24.Site::waterfall).count() == 1, "One waterfall site");
if (generator.initialDiameter() == 724 && level.getSeed() == 0)
AerialRenderer24.writeDiagnostic(Path.of("diagnostics/aerial24/blocks.json"), plan.sites());
report.put("geometry", JSON.toJsonTree(plan.sites())); report.put("claim_count", plan.claimsChunks().size());
report.put("census_before", StructureCensusService.aerialSites(level));
report.put("generator_codec", ChunkGenerator.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow());
var journalBefore = files(level.getServer().getWorldPath(LevelResource.ROOT).resolve("data").resolve(generator.journalDirectory()));
verifyClaims(helper, generator, plan, report);
Path world = level.getServer().getWorldPath(LevelResource.ROOT);
JsonObject previous = create ? null : JsonParser.parseString(Files.readString(world.resolve("aerial24-" + (phase.equals("reload1") ? "create" : "reload1") + ".json"))).getAsJsonObject();
if (previous != null) {
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload occurs in a separate JVM after a successful phase");
helper.assertTrue(previous.get("geometry").equals(JSON.toJsonTree(plan.sites())), "Site geometry and palette seeds survive reload");
helper.assertTrue(previous.get("generator_codec").equals(report.get("generator_codec")), "Generator options survive reload");
}
var chunks = new TreeSet<ChunkPos>(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z));
plan.sites().forEach(site -> chunks.addAll(site.claimedChunks()));
plan.sites().forEach(site -> chunks.add(ChunkPos.containing(new BlockPos(site.shoreX(), site.shoreY(), site.shoreZ()))));
// The two extracted merchants are retained in a separate, existing root chunk.
chunks.add(new ChunkPos(0, 0));
var loadOrder = new ArrayList<>(chunks);
boolean reverseChunks = Boolean.getBoolean("sanctuary.test.aerial24.reverseChunks");
if (reverseChunks) Collections.reverse(loadOrder);
report.put("reverse_chunks", reverseChunks);
var iterator = loadOrder.iterator();
var entityChunks = new HashSet<ChunkPos>();
plan.sites().stream().filter(AerialPlan24.Site::merchant).forEach(site -> entityChunks.addAll(site.claimedChunks()));
entityChunks.add(new ChunkPos(0, 0));
boolean[] tickets = {false}; int[] wait = {0};
Runnable release = () -> {
if (tickets[0]) entityChunks.forEach(pos -> level.getChunkSource().removeTicketWithRadius(TicketType.DRAGON, pos, 2));
tickets[0] = false;
};
Runnable[] step = new Runnable[1];
step[0] = () -> {
try {
if (iterator.hasNext()) {
var chunk = iterator.next(); report.put("stage", "FULL " + chunk);
level.getChunk(chunk.x(), chunk.z(), ChunkStatus.FULL, true);
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run()); return;
}
if (!tickets[0]) {
entityChunks.forEach(pos -> level.getChunkSource().addTicketWithRadius(TicketType.DRAGON, pos, 2)); tickets[0] = true;
}
var occupants = occupants(level, plan);
var readiness = new TreeMap<String, Object>(); boolean entitiesReady = true;
for (var pos : entityChunks) {
boolean loaded = level.areEntitiesLoaded(pos.pack()), ticking = level.areEntitiesActuallyLoadedAndTicking(pos);
readiness.put(pos.toString(), Map.of("loaded", loaded, "ticking", ticking));
entitiesReady &= loaded && ticking;
}
report.put("entity_chunks", readiness);
report.put("occupants_observed", occupants.stream().map(villager -> Map.of("uuid", villager.getUUID().toString(),
"position", List.of(villager.getX(), villager.getY(), villager.getZ()))).toList());
report.put("visible_occupants", occupants.size()); report.put("entity_wait_ticks", wait[0]);
if ((!entitiesReady || occupants.size() < 2) && wait[0]++ < 600) {
report.put("stage", "wait for persistent merchants");
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run()); return;
}
helper.assertTrue(entitiesReady, "All ship chunks finish native entity loading and become ticking before occupant inspection");
helper.assertTrue(occupants.size() == 2, "Both one-time merchants are present after native entity loading");
report.put("stage", "inspect native structures");
var starts = starts(helper, level, plan);
var before = inspect(helper, level, plan, create, report);
if (create) {
verifyLocator(helper, level, plan, starts);
report.put("stage", "loot, extract and dismantle disposable witnesses");
report.put("mutations", mutate(helper, level, plan, occupants, before));
} else {
report.put("mutations", previous.get("mutations"));
verifyMutations(helper, level, plan, previous.getAsJsonObject("mutations"), occupants);
}
var tags = tags(level, starts);
if (!create) helper.assertTrue(previous.get("native_tags").equals(JSON.toJsonTree(tags)), "Native starts, references and occupant flags survive both reloads");
report.put("native_tags", tags); report.put("full_chunks", chunks.size());
report.put("blocks_after", countSites(level, plan));
var census = StructureCensusService.aerialSites(level);
helper.assertTrue(census.size() == 5 && census.stream().allMatch(site -> site.status() == StructureCensusService.AerialStatus.GENERATED),
"Five historical generated sites remain indexed after loot, mining and demolition");
report.put("census_after", census);
helper.assertTrue(equalFiles(journalBefore, files(world.resolve("data").resolve(generator.journalDirectory()))), "Aerial tests never reserve or activate an expansion");
report.put("expansion_journal_unchanged", true);
StructureCensusService.flush(level.getServer());
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save actual blocks, loot state, starts and occupants");
report.put("fluid_scheduler", FluidTiming25.snapshot());
report.put("passed", true);
write(world.resolve("aerial24-" + phase + ".json"), report);
write(Path.of("diagnostics/aerial24/" + phase + ".json"), report);
release.run(); helper.succeed();
} catch (Exception error) {
release.run(); failure(report, error); throw new IllegalStateException("Aerial24 failed: " + error.getMessage(), error);
}
};
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run());
}
private static void verifyClaims(GameTestHelper helper, SanctuaryChunkGenerator generator, AerialPlan24 plan, Map<String, Object> report) {
var root = generator.source().islands().stream().filter(ExpansionIsland::origin).findFirst().orElseThrow();
var views = new LinkedHashMap<String, Object>();
for (var site : plan.sites()) {
helper.assertTrue(site.claimedChunks().stream().noneMatch(chunk -> root.ownsChunk(chunk.x(), chunk.z())), "No aerial claim replaces natural starter chunks");
var overlap = new ExpansionIsland("aerial_overlap", root.id(), "east", "temperate", 64,
site.startChunkX() * 16, site.startChunkZ() * 16, 42, "natural", "reserved", 24);
helper.assertTrue(plan.conflicts(overlap), "An unseen site already prevents overlapping expansion ownership");
double distance = Math.hypot(site.locator().getX() - site.shoreX(), site.locator().getZ() - site.shoreZ());
helper.assertTrue(distance <= 512, "Aerial site remains within 512 blocks of a real shore witness");
views.put(site.id(), Map.of("shore", List.of(site.shoreX(), site.shoreY() + 2, site.shoreZ()),
"entry", coordinates(AerialGeometry24.entry(site)), "centre", coordinates(site.locator()), "shore_distance", distance,
"exterior_view", List.of(site.minX() - 24, site.minY() + site.height() / 2, site.minZ() - 24)));
}
helper.assertTrue(!plan.conflicts(new ExpansionIsland("aerial_far", root.id(), "east", "temperate", 64, 4096, 4096, 42, "natural", "reserved", 24)),
"Unclaimed space remains available; there is no blanket exclusion ring");
report.put("look_views", views);
}
private static List<Villager> occupants(ServerLevel level, AerialPlan24 plan) {
var expected = plan.sites().stream().filter(AerialPlan24.Site::merchant).map(AerialGeometry24::occupantUuid).collect(java.util.stream.Collectors.toSet());
return level.getEntitiesOfClass(Villager.class, new AABB(-2048, 0, -2048, 2048, 384, 2048))
.stream().filter(villager -> expected.contains(villager.getUUID())).toList();
}
private static Map<String, StructureStart> starts(GameTestHelper helper, ServerLevel level, AerialPlan24 plan) {
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
var result = new TreeMap<String, StructureStart>();
for (var site : plan.sites()) {
var type = registry.get(Identifier.parse(AerialStructures24.structureId(site))).orElseThrow().value();
var start = level.getChunk(site.startChunkX(), site.startChunkZ(), ChunkStatus.FULL, true).getStartForStructure(type);
helper.assertTrue(start != null && start.isValid(), "Planned aerial site has a real persisted native start: " + site.id());
helper.assertTrue(start.getPieces().stream().filter(piece -> piece instanceof AerialPiece24 aerial && aerial.site().equals(site)).count() == 1,
"Exactly one piece retains the site's immutable descriptor");
var context = StructurePieceSerializationContext.fromLevel(level);
var tag = start.createTag(context, start.getChunkPos());
var restored = StructureStart.loadStaticStart(context, tag, level.getSeed());
helper.assertTrue(restored != null && tag.equals(restored.createTag(context, start.getChunkPos())), "Aerial start round-trips through native serialization");
if (site.merchant()) helper.assertTrue(start.getPieces().stream().filter(AerialPiece24.class::isInstance).map(AerialPiece24.class::cast)
.allMatch(AerialPiece24::occupantPlaced), "Merchant placement flag is stored after its first creation");
result.put(site.id(), start);
}
return result;
}
private static Map<String, Map<BlockPos, BlockState>> inspect(GameTestHelper helper, ServerLevel level, AerialPlan24 plan,
boolean fresh, Map<String, Object> report) {
var expected = new LinkedHashMap<String, Map<BlockPos, BlockState>>(); int chestCount = 0, waterSources = 0;
var vegetation = new TreeMap<String, Object>();
var shores = new TreeMap<String, Object>(); report.put("shore_witnesses", shores);
for (var site : plan.sites()) {
var model = render(site); expected.put(site.id(), model);
var shore = new BlockPos(site.shoreX(), site.shoreY(), site.shoreZ());
var column = new TreeMap<Integer, String>();
for (int dy = -2; dy <= 20; dy++) column.put(shore.getY() + dy, level.getBlockState(shore.above(dy)).toString());
shores.put(site.id(), Map.of("position", coordinates(shore), "column", column));
helper.assertTrue(level.getBlockState(shore.below()).isSolid(), "Saved shore witness has a real FULL terrain support: " + site.id() + " " + shore);
helper.assertTrue(level.getBlockState(shore.above()).getCollisionShape(level, shore.above()).isEmpty(),
"Shore viewpoint is not buried inside the island: " + site.id() + " " + shore.above() + " " + level.getBlockState(shore.above()));
var chestAt = AerialGeometry24.chest(site);
helper.assertTrue(level.getBlockEntity(chestAt) instanceof RandomizableContainerBlockEntity, "Real chest at " + site.id()); chestCount++;
var chest = (RandomizableContainerBlockEntity) level.getBlockEntity(chestAt);
if (fresh) {
helper.assertTrue(AerialRenderer24.loot(site).equals(chest.getLootTable()), "Aerial chest retains its unopened site-specific loot table");
for (var entry : model.entrySet()) if (!entry.getValue().isAir() && !entry.getValue().is(Blocks.WATER))
helper.assertTrue(level.getBlockState(entry.getKey()).is(entry.getValue().getBlock()), "Native FULL keeps authored material at " + site.id() + " " + entry.getKey());
for (var pos : AerialGeometry24.interiorChecks(site)) {
helper.assertTrue(level.getBlockState(pos).getCollisionShape(level, pos).isEmpty()
&& level.getBlockState(pos.above()).getCollisionShape(level, pos.above()).isEmpty(), "Usable two-block headroom at " + site.id());
helper.assertTrue(!level.getBlockState(pos.below()).getCollisionShape(level, pos.below()).isEmpty(), "Interior stands on a real floor");
}
if (site.kind() == AerialPlan24.Kind.MINERAL_ISLET) for (int dy = 1; dy <= 3; dy++)
helper.assertTrue(level.getBlockState(chestAt.above(dy)).isSolid(), "Mineral chest needs excavation through three layers");
} else helper.assertTrue(chest.getLootTable() == null && chest.isEmpty(), "Looted chest never refills on reload");
if (site.kind() == AerialPlan24.Kind.MINERAL_ISLET)
vegetation.put(site.id(), vegetation(helper, level, site));
if (site.waterfall()) {
var source = AerialGeometry24.waterfallSource(site);
helper.assertTrue(level.getFluidState(source).isSource(), "The one real source drives the native waterfall"); waterSources++;
}
// These columns are beyond the authored box and well outside initial terrain ownership.
for (var pos : List.of(new BlockPos(site.minX() - 2, site.minY(), site.minZ() - 2), new BlockPos(site.maxX() + 2, site.minY(), site.maxZ() + 2)))
for (int y = site.minY(); y <= site.maxY() + 2; y++) {
var state = level.getBlockState(pos.atY(y));
helper.assertTrue(state.isAir() || site.waterfall() && !state.getFluidState().isEmpty(), "No manufactured structure leaks outside its authored footprint");
}
}
helper.assertTrue(chestCount == 5 && waterSources == 1, "Five physical chests and exactly one planned source");
report.put("chests", chestCount); report.put("waterfall_sources", waterSources); report.put("blocks_before", countSites(level, plan));
report.put("vegetation", vegetation);
return expected;
}
private static Map<String, Object> vegetation(GameTestHelper helper, ServerLevel level, AerialPlan24.Site site) {
helper.assertTrue(site.rockHeight() >= 20 && site.rockHeight() <= 35
&& site.height() == site.rockHeight() + AerialPlan24.VEGETATION_HEADROOM,
"Tree headroom preserves the original stone height");
int surface = 0, grass = 0;
for (int x = 0; x < site.width(); x++) for (int z = 0; z < site.depth(); z++) {
int top = AerialGeometry24.terrainTop(site, x, z);
if (!AerialGeometry24.mineralBody(site, x, top, z)) continue;
surface++;
if (level.getBlockState(AerialGeometry24.world(site, x, top, z)).is(Blocks.GRASS_BLOCK)) grass++;
}
helper.assertTrue(grass * 2 > surface, "Most real intact surface columns carry grass: " + site.id());
var trees = AerialGeometry24.trees(site);
helper.assertTrue(!trees.isEmpty(), "At least one real tree on each mineral islet");
for (var tree : trees) {
var ground = AerialGeometry24.world(site, tree.x(), tree.groundY(), tree.z());
var floor = level.getBlockState(ground);
helper.assertTrue(floor.is(Blocks.GRASS_BLOCK) || floor.is(Blocks.DIRT), "Tree stands on soil, never in the air");
for (int y = 1; y <= tree.trunkHeight(); y++)
helper.assertTrue(level.getBlockState(ground.above(y)).is(tree.birch() ? Blocks.BIRCH_LOG : Blocks.OAK_LOG), "Native tree trunk stays continuous");
var crown = ground.above(tree.trunkHeight() + 1);
var leaves = level.getBlockState(crown);
helper.assertTrue(site.bounds().isInside(crown) && leaves.is(tree.birch() ? Blocks.BIRCH_LEAVES : Blocks.OAK_LEAVES)
&& !leaves.getValue(BlockStateProperties.PERSISTENT) && leaves.getValue(BlockStateProperties.DISTANCE) < 7,
"Complete natural crown survives native chunk decoration and reload");
}
return Map.of("rock_height", site.rockHeight(), "headroom", AerialPlan24.VEGETATION_HEADROOM,
"surface_columns", surface, "grass_columns", grass, "grass_percent", 100.0 * grass / surface, "trees", trees);
}
private static Map<String, Object> mutate(GameTestHelper helper, ServerLevel level, AerialPlan24 plan, List<Villager> occupants,
Map<String, Map<BlockPos, BlockState>> models) {
var emptied = new ArrayList<List<Integer>>(); var removed = new ArrayList<List<Integer>>(); var loot = new TreeMap<String, Integer>();
for (var site : plan.sites()) {
var at = AerialGeometry24.chest(site); var chest = (RandomizableContainerBlockEntity) level.getBlockEntity(at);
chest.unpackLootTable(null); int quantity = 0;
for (int slot = 0; slot < chest.getContainerSize(); slot++) quantity += chest.getItem(slot).getCount();
helper.assertTrue(quantity > 0, "Site-specific chest produces usable vanilla loot"); loot.put(site.id(), quantity);
chest.clearContent(); chest.setChanged(); emptied.add(coordinates(at));
if (site.kind() == AerialPlan24.Kind.MINERAL_ISLET) {
var ores = models.get(site.id()).entrySet().stream().filter(entry -> ore(entry.getValue())).sorted(Map.Entry.comparingByKey(
Comparator.<BlockPos>comparingInt(BlockPos::getX).thenComparingInt(BlockPos::getY).thenComparingInt(BlockPos::getZ))).limit(16).toList();
helper.assertTrue(!ores.isEmpty(), "Each mineral island contains actual finite veins");
for (var entry : ores) { level.setBlock(entry.getKey(), Blocks.AIR.defaultBlockState(), 2); removed.add(coordinates(entry.getKey())); }
}
}
var balloon = plan.sites().stream().filter(site -> site.kind() == AerialPlan24.Kind.BALLOON).findFirst().orElseThrow();
var demolition = AerialGeometry24.entry(balloon).below();
helper.assertTrue(level.getBlockState(demolition).isSolid(), "The demolition witness removes an existing floor");
level.setBlock(demolition, Blocks.AIR.defaultBlockState(), 2); removed.add(coordinates(demolition));
var extracted = new TreeMap<String, List<Double>>(); int i = 0;
for (var villager : occupants.stream().sorted(Comparator.comparing(v -> v.getUUID().toString())).toList()) {
// This fixture parks extracted merchants in an existing root chunk, outside both ships.
villager.setNoAi(true); villager.setNoGravity(true); villager.setDeltaMovement(Vec3.ZERO);
villager.snapTo(4.5 + i++ * 4, 330, 4.5); villager.setPersistenceRequired();
extracted.put(villager.getUUID().toString(), List.of(villager.getX(), villager.getY(), villager.getZ()));
}
return Map.of("emptied_chests", emptied, "removed_blocks", removed, "extracted_merchants", extracted, "loot_items_obtained", loot);
}
private static void verifyMutations(GameTestHelper helper, ServerLevel level, AerialPlan24 plan, JsonObject mutations, List<Villager> occupants) {
for (var value : mutations.getAsJsonArray("removed_blocks")) {
var pos = position(value.getAsJsonArray()); helper.assertTrue(level.getBlockState(pos).isAir(), "Mined ore and dismantled floor remain absent after reload");
}
var extracted = mutations.getAsJsonObject("extracted_merchants");
for (var villager : occupants) {
var at = extracted.getAsJsonArray(villager.getUUID().toString());
helper.assertTrue(at != null && Math.abs(villager.getX() - at.get(0).getAsDouble()) < .01
&& Math.abs(villager.getY() - at.get(1).getAsDouble()) < .01 && Math.abs(villager.getZ() - at.get(2).getAsDouble()) < .01,
"Extracted merchant stays outside its original ship without respawning");
}
for (var site : plan.sites()) if (site.merchant()) helper.assertTrue(level.getEntitiesOfClass(Villager.class, AABB.of(site.bounds())).isEmpty(),
"The visited ship does not replenish an extracted merchant");
}
private static void verifyLocator(GameTestHelper helper, ServerLevel level, AerialPlan24 plan, Map<String, StructureStart> starts) {
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
var holders = new ArrayList<Holder<Structure>>();
plan.sites().stream().map(AerialStructures24::structureId).distinct().forEach(id -> holders.add(registry.get(Identifier.parse(id)).orElseThrow()));
holders.add(registry.get(Identifier.withDefaultNamespace("stronghold")).orElseThrow());
var wanted = HolderSet.direct(holders); var origin = plan.sites().getFirst().locator();
var activeBefore = ((SanctuaryChunkGenerator) level.getChunkSource().getGenerator()).source().islands();
int before = starts.values().stream().mapToInt(StructureStart::getReferences).sum();
var found = ExpansionStructures23.findNearest(level, wanted, origin, 100, false);
helper.assertTrue(found != null, "Mixed vanilla/aerial locator finds a real accepted start");
helper.assertTrue(starts.values().stream().mapToInt(StructureStart::getReferences).sum() == before, "Ordinary locate never marks a site referenced");
var referenced = ExpansionStructures23.findNearest(level, wanted, origin, 100, true);
helper.assertTrue(referenced != null && starts.values().stream().mapToInt(StructureStart::getReferences).sum() == before + 1,
"Mixed map lookup references exactly its one winning aerial start");
helper.assertTrue(activeBefore.equals(((SanctuaryChunkGenerator) level.getChunkSource().getGenerator()).source().islands()), "Locator never activates an expansion");
}
private static Map<BlockPos, BlockState> render(AerialPlan24.Site site) {
var result = new HashMap<BlockPos, BlockState>();
AerialRenderer24.render(site, new LostCityRenderer.BlockWriter() {
public void set(int x, int y, int z, BlockState state) { result.put(new BlockPos(site.minX() + x, site.minY() + y, site.minZ() + z), state); }
public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) { set(x, y, z, Blocks.CHEST.defaultBlockState()); }
});
return result;
}
private static Map<String, Map<String, Integer>> countSites(ServerLevel level, AerialPlan24 plan) {
var all = new TreeMap<String, Map<String, Integer>>();
for (var site : plan.sites()) {
var counts = new TreeMap<String, Integer>();
for (int x = site.minX(); x <= site.maxX(); x++) for (int z = site.minZ(); z <= site.maxZ(); z++) for (int y = site.minY(); y <= site.maxY(); y++) {
var state = level.getBlockState(new BlockPos(x, y, z));
if (!state.isAir()) counts.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), 1, Integer::sum);
}
all.put(site.id(), counts);
}
return all;
}
private static boolean ore(BlockState state) { return BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath().endsWith("_ore"); }
private static Map<String, String> tags(ServerLevel level, Map<String, StructureStart> starts) {
var result = new TreeMap<String, String>(); var context = StructurePieceSerializationContext.fromLevel(level);
starts.forEach((id, start) -> result.put(id, start.createTag(context, start.getChunkPos()).toString())); return result;
}
private static List<Integer> coordinates(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
private static BlockPos position(com.google.gson.JsonArray value) { return new BlockPos(value.get(0).getAsInt(), value.get(1).getAsInt(), value.get(2).getAsInt()); }
private static Map<String, byte[]> files(Path directory) throws java.io.IOException {
var found = new TreeMap<String, byte[]>();
if (Files.exists(directory)) try (var paths = Files.walk(directory)) {
for (var path : paths.filter(Files::isRegularFile).toList()) found.put(directory.relativize(path).toString(), Files.readAllBytes(path));
}
return found;
}
private static boolean equalFiles(Map<String, byte[]> a, Map<String, byte[]> b) {
return a.keySet().equals(b.keySet()) && a.entrySet().stream().allMatch(entry -> Arrays.equals(entry.getValue(), b.get(entry.getKey())));
}
private static void write(Path path, Map<String, Object> report) throws java.io.IOException { Files.createDirectories(path.toAbsolutePath().getParent()); Files.writeString(path, JSON.toJson(report)); }
private static void failure(Map<String, Object> report, Exception error) {
report.put("passed", false); report.put("failure", error.toString());
var stack = new java.io.StringWriter(); error.printStackTrace(new java.io.PrintWriter(stack)); report.put("stacktrace", stack.toString());
SanctuaryMod.LOGGER.error("Aerial24 fixture failed at {}", report.get("stage"), error);
try { write(Path.of("diagnostics/aerial24/failure.json"), report); } catch (java.io.IOException failed) { error.addSuppressed(failed); }
}
}
@@ -0,0 +1,384 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.knowledge.BlockKnowledge;
import fr.koka.sanctuary.knowledge.BlockKnowledgeEvents;
import fr.koka.sanctuary.knowledge.BlockKnowledgeFacet;
import fr.koka.sanctuary.knowledge.BlockKnowledgeService;
import fr.koka.sanctuary.knowledge.BlockKnowledgeStore;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.network.Connection;
import net.minecraft.network.protocol.PacketFlow;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.network.CommonListenerCookie;
import net.minecraft.server.network.ServerGamePacketListenerImpl;
import net.minecraft.stats.Stats;
import net.minecraft.util.Prediction;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.inventory.ChestMenu;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.item.context.UseOnContext;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.LevelResource;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.Vec3;
/** Exercise native actions so invalid placements and death drops cannot unlock false history. */
public final class BlockKnowledgeGameTests {
@GameTest(maxTicks = 100)
public void placementCountsConfirmedStatesAndRejectsFailure(GameTestHelper helper) {
ServerPlayer player = player(helper);
BlockPos support = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlockAndUpdate(support, Blocks.STONE.defaultBlockState());
helper.getLevel().setBlockAndUpdate(support.above(), Blocks.AIR.defaultBlockState());
helper.getLevel().setBlockAndUpdate(support.east(), Blocks.AIR.defaultBlockState());
helper.assertTrue(place(player, Items.COBBLESTONE, support, Direction.UP),
"A valid block placement must succeed");
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.COBBLESTONE).placed() == 1,
"A successful placement must be recorded exactly once");
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.COBBLESTONE).possessed(),
"The final held block must be observed before placement consumes it");
helper.assertTrue(place(player, Items.TORCH, support, Direction.EAST),
"A torch must attach to the side of a solid support");
helper.assertTrue(helper.getLevel().getBlockState(support.east()).is(Blocks.WALL_TORCH),
"The placement fixture must produce a wall torch");
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.WALL_TORCH).placed() == 1,
"Placement history must use the actual wall block");
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.TORCH).placed() == 0,
"The item form must not receive a second placement");
BlockPos invalidSupport = helper.absolutePos(new BlockPos(3, 1, 1));
helper.getLevel().setBlockAndUpdate(invalidSupport, Blocks.STONE.defaultBlockState());
helper.getLevel().setBlockAndUpdate(invalidSupport.above(), Blocks.AIR.defaultBlockState());
helper.assertTrue(!place(player, Items.OAK_SAPLING, invalidSupport, Direction.UP),
"A sapling cannot be placed on stone");
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.OAK_SAPLING).placed() == 0,
"A failed item use must not count as a placement");
helper.succeed();
}
@GameTest(maxTicks = 100)
public void vanillaMiningAndVoluntaryDropsRemainAuthoritative(GameTestHelper helper) {
ServerPlayer player = player(helper);
BlockPos mined = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlockAndUpdate(mined, Blocks.STONE.defaultBlockState());
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.DIAMOND_PICKAXE));
helper.assertTrue(player.gameMode.destroyBlock(mined), "The test player must mine stone");
helper.assertTrue(player.getStats().getValue(Stats.BLOCK_MINED.get(Blocks.STONE)) == 1,
"The successful harvest must increment Minecraft's mined counter");
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.STONE).mined() == 1,
"Sanctuary must expose Minecraft's counter without double counting");
helper.getLevel().setBlockAndUpdate(mined, Blocks.STONE.defaultBlockState());
player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY);
player.gameMode.destroyBlock(mined);
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.STONE).mined() == 1,
"Breaking stone with the wrong tool must preserve vanilla mining semantics");
player.drop(new ItemStack(Items.DIRT, 3), true, Prediction.PREDICTED);
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.DIRT).dropped() == 3,
"Voluntary drops must count individual items exactly once");
player.getInventory().setItem(0, new ItemStack(Items.DIRT, 5));
player.getInventory().dropAll();
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.DIRT).dropped() == 3,
"The inventory's death-drop path must not count as voluntary drops");
helper.succeed();
}
@GameTest(maxTicks = 100)
public void cursorPossessionSurvivesImmediateReturnToChest(GameTestHelper helper) {
ServerPlayer player = player(helper);
SimpleContainer chest = new SimpleContainer(27);
chest.setItem(0, new ItemStack(Items.EMERALD_BLOCK, 5));
ChestMenu menu = ChestMenu.threeRows(1, player.getInventory(), chest);
player.containerMenu = menu;
helper.assertTrue(!BlockKnowledgeService.knowledge(player, Blocks.EMERALD_BLOCK).possessed(),
"An unopened chest stack must not count as player possession");
// No service query or tick between taking and returning the stack.
menu.clicked(0, 0, ContainerInput.PICKUP, player);
menu.clicked(0, 0, ContainerInput.PICKUP, player);
var knowledge = BlockKnowledgeService.knowledge(player, Blocks.EMERALD_BLOCK);
helper.assertTrue(knowledge.possessed(),
"A stack briefly held on the cursor must remain in possession history");
helper.assertTrue(knowledge.carried() == 0,
"Returning the stack must remove current stock without erasing its history");
helper.succeed();
}
@GameTest(maxTicks = 100)
public void gazeStopsAtFirstBlockAndSpectatorsRevealNothing(GameTestHelper helper) {
ServerPlayer player = player(helper);
Vec3 feet = helper.absoluteVec(new Vec3(1.5, 2, 1.5));
player.setPos(feet);
player.setYRot(0);
player.setXRot(0);
BlockPos eye = BlockPos.containing(player.getEyePosition());
// The observer deliberately never requests missing chunks. Load its short ray here,
// in the isolated test world, before asking it to observe anything.
int minX = (int) Math.floor(player.getX() - 1) >> 4;
int maxX = (int) Math.floor(player.getX() + 1) >> 4;
int minZ = (int) Math.floor(player.getZ() - 1) >> 4;
int maxZ = (int) Math.floor(player.getZ() + BlockKnowledgeService.OBSERVATION_DISTANCE + 1) >> 4;
for (int x = minX; x <= maxX; x++) for (int z = minZ; z <= maxZ; z++) {
helper.getLevel().getChunk(x, z);
}
for (int z = 0; z <= 4; z++) {
helper.getLevel().setBlockAndUpdate(eye.offset(0, 0, z), Blocks.AIR.defaultBlockState());
}
BlockPos visible = eye.offset(0, 0, 2);
BlockPos hidden = eye.offset(0, 0, 3);
helper.getLevel().setBlockAndUpdate(visible, Blocks.STONE.defaultBlockState());
helper.getLevel().setBlockAndUpdate(hidden, Blocks.DIAMOND_ORE.defaultBlockState());
BlockKnowledgeService.observeLook(player);
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.STONE).seen(),
"The first block on the player's gaze ray must become seen");
helper.assertTrue(!BlockKnowledgeService.knowledge(player, Blocks.DIAMOND_ORE).known(),
"An ore behind the first block must remain unknown");
ServerPlayer spectator = (ServerPlayer) helper.makeMockServerPlayer(GameType.SPECTATOR);
spectator.setPos(feet);
spectator.setYRot(0);
spectator.setXRot(0);
helper.assertTrue(spectator.isSpectator(), "The observer fixture must be a spectator");
BlockKnowledgeService.observeLook(spectator);
helper.assertTrue(!BlockKnowledgeService.knowledge(spectator, Blocks.STONE).known()
&& !BlockKnowledgeService.knowledge(spectator, Blocks.DIAMOND_ORE).known(),
"Spectator gaze must not discover either visible or occluded blocks");
helper.succeed();
}
@GameTest(maxTicks = 100)
public void existingStatsRemainPersonalWithoutInventingSightOrPlacements(GameTestHelper helper) {
ServerPlayer first = player(helper);
ServerPlayer second = player(helper);
// Restore vanilla values directly, without going through an action hook.
first.getStats().setValue(first, Stats.BLOCK_MINED.get(Blocks.DIAMOND_ORE), 7);
first.getStats().setValue(first, Stats.ITEM_DROPPED.get(Items.EMERALD_BLOCK), 4);
first.getStats().setValue(first, Stats.ITEM_PICKED_UP.get(Items.REDSTONE_BLOCK), 9);
var mined = BlockKnowledgeService.knowledge(first, Blocks.DIAMOND_ORE);
var dropped = BlockKnowledgeService.knowledge(first, Blocks.EMERALD_BLOCK);
var pickedUp = BlockKnowledgeService.knowledge(first, Blocks.REDSTONE_BLOCK);
helper.assertTrue(mined.mined() == 7 && !mined.possessed() && !mined.seen() && mined.placed() == 0,
"A historical mining counter must not invent possession, sight or placement evidence");
helper.assertTrue(dropped.dropped() == 4 && dropped.possessed() && !dropped.seen() && dropped.placed() == 0,
"Historical drops imply possession but cannot reconstruct sight or placements");
helper.assertTrue(pickedUp.possessed() && pickedUp.dropped() == 0 && !pickedUp.seen() && pickedUp.placed() == 0,
"Historical pickups imply possession without inventing other actions");
helper.assertTrue(mined.carried() == 0 && dropped.carried() == 0 && pickedUp.carried() == 0,
"Historical counters must not create current inventory stock");
BlockKnowledgeService.recordSeen(first, Blocks.GOLD_BLOCK);
helper.assertTrue(!BlockKnowledgeService.knowledge(second, Blocks.DIAMOND_ORE).known()
&& !BlockKnowledgeService.knowledge(second, Blocks.EMERALD_BLOCK).known()
&& !BlockKnowledgeService.knowledge(second, Blocks.REDSTONE_BLOCK).known()
&& !BlockKnowledgeService.knowledge(second, Blocks.GOLD_BLOCK).known(),
"Both vanilla evidence and Sanctuary history must stay isolated by player UUID");
helper.succeed();
}
@GameTest(maxTicks = 100)
public void currentStockSumsPersonalSlotsAndLeavesPossessionHistory(GameTestHelper helper) {
ServerPlayer player = player(helper);
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.LAPIS_BLOCK, 4));
player.setItemInHand(InteractionHand.OFF_HAND, new ItemStack(Items.LAPIS_BLOCK, 2));
player.containerMenu.setCarried(new ItemStack(Items.LAPIS_BLOCK, 3));
var held = BlockKnowledgeService.knowledge(player, Blocks.LAPIS_BLOCK);
helper.assertTrue(held.carried() == 9 && held.possessed(),
"Inventory, offhand and cursor must be counted once each");
player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY);
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
player.containerMenu.setCarried(ItemStack.EMPTY);
var emptied = BlockKnowledgeService.knowledge(player, Blocks.LAPIS_BLOCK);
helper.assertTrue(emptied.carried() == 0 && emptied.possessed(),
"Empty current stock must retain historical possession");
helper.assertTrue(!emptied.seen() && emptied.mined() == 0 && emptied.placed() == 0 && emptied.dropped() == 0,
"Inventory evidence must not manufacture other kinds of progression");
helper.succeed();
}
@GameTest(maxTicks = 100)
public void listenersReceiveUpdatedProgressionAndStockChanges(GameTestHelper helper) {
ServerPlayer player = player(helper);
var uuid = player.getUUID();
List<Notice> notices = new ArrayList<>();
BlockKnowledgeEvents.CHANGED.register((source, knowledge, activity) -> {
if (source.getUUID().equals(uuid) && knowledge.blockId().equals("minecraft:gold_block")) {
notices.add(new Notice(activity, knowledge));
}
});
// Vanilla pickup inserts the stack before awarding its statistic. Inventory observation
// and the statistic hook must publish one discovery between them, then remain sticky.
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.GOLD_BLOCK));
player.awardStat(Stats.ITEM_PICKED_UP.get(Items.GOLD_BLOCK), 1);
helper.assertTrue(notices.stream().filter(n -> n.activity() == BlockKnowledgeEvents.Activity.POSSESSED).count() == 1,
"One pickup must not duplicate first-possession notifications through its inventory and stat paths");
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.GOLD_BLOCK, 2));
player.awardStat(Stats.ITEM_PICKED_UP.get(Items.GOLD_BLOCK), 1);
helper.assertTrue(notices.stream().filter(n -> n.activity() == BlockKnowledgeEvents.Activity.POSSESSED).count() == 1,
"A later pickup must change stock without publishing another first-possession notification");
BlockKnowledgeService.recordSeen(player, Blocks.GOLD_BLOCK);
BlockKnowledgeService.recordSeen(player, Blocks.GOLD_BLOCK);
player.awardStat(Stats.BLOCK_MINED.get(Blocks.GOLD_BLOCK), 2);
BlockPos support = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlockAndUpdate(support, Blocks.STONE.defaultBlockState());
helper.getLevel().setBlockAndUpdate(support.above(), Blocks.AIR.defaultBlockState());
helper.assertTrue(place(player, Items.GOLD_BLOCK, support, Direction.UP),
"The event fixture must place a gold block");
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.GOLD_BLOCK, 5));
BlockKnowledgeService.observeInventory(player);
player.getInventory().removeItem(player.getInventory().getSelectedSlot(), 1);
player.drop(new ItemStack(Items.GOLD_BLOCK), true, Prediction.PREDICTED);
helper.assertTrue(notices.stream().filter(n -> n.activity() == BlockKnowledgeEvents.Activity.SEEN).count() == 1,
"Repeated sight of the same block must not emit duplicate discoveries");
helper.assertTrue(notices.stream().anyMatch(n -> n.activity() == BlockKnowledgeEvents.Activity.MINED && n.knowledge().mined() == 2),
"Mining listeners must receive the already updated vanilla counter");
helper.assertTrue(notices.stream().anyMatch(n -> n.activity() == BlockKnowledgeEvents.Activity.PLACED && n.knowledge().placed() == 1),
"Placement listeners must receive the confirmed placement count");
helper.assertTrue(notices.stream().anyMatch(n -> n.activity() == BlockKnowledgeEvents.Activity.POSSESSED && n.knowledge().possessed()),
"First possession must be available to future progression listeners");
helper.assertTrue(notices.stream().anyMatch(n -> n.activity() == BlockKnowledgeEvents.Activity.DROPPED && n.knowledge().dropped() == 1),
"Drop listeners must receive the already updated vanilla counter");
helper.assertTrue(notices.stream().anyMatch(n -> n.activity() == BlockKnowledgeEvents.Activity.INVENTORY && n.knowledge().carried() == 4),
"Dropping an item must also notify consumers of the changed current stock");
helper.succeed();
}
@GameTest(maxTicks = 100)
public void disconnectFromNetworkThreadSavesAndReloadsOnServer(GameTestHelper helper) throws InterruptedException {
ServerPlayer player = player(helper);
var server = helper.getLevel().getServer();
// A real packet listener, but no player-list registration or live connection. The only
// DISCONNECT subscriber in this test runtime is Sanctuary's history lifecycle handler.
var listener = new ServerGamePacketListenerImpl(server, new Connection(PacketFlow.SERVERBOUND),
player, CommonListenerCookie.createInitial(player.getGameProfile(), false));
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.PRISMARINE, 2));
BlockKnowledgeService.recordSeen(player, Blocks.PRISMARINE);
BlockKnowledgeService.recordPlaced(player, Blocks.PRISMARINE);
var directory = server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-blocodex");
var file = directory.resolve(player.getUUID() + ".json");
helper.assertTrue(!Files.exists(file), "Fresh history must still be in memory before its first save");
AtomicReference<Throwable> workerFailure = new AtomicReference<>();
Thread worker = new Thread(() -> {
try {
if (server.isSameThread()) throw new AssertionError("The disconnect fixture must run outside the server thread");
ServerPlayConnectionEvents.DISCONNECT.invoker().onPlayDisconnect(listener, server);
} catch (Throwable failure) {
workerFailure.set(failure);
}
}, "sanctuary-blocodex-disconnect-test");
worker.setDaemon(true);
worker.start();
worker.join(5000);
helper.assertTrue(!worker.isAlive(), "The network callback must return without waiting for the server tick");
helper.assertTrue(workerFailure.get() == null, "The network callback must safely schedule its work: " + workerFailure.get());
helper.assertTrue(!Files.exists(file),
"A network-thread disconnect must defer history writes until the server can execute them");
player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY);
helper.runAfterDelay(2, () -> {
try {
helper.assertTrue(Files.isRegularFile(file), "The queued disconnect must save history on the server thread");
var disk = new BlockKnowledgeStore(directory, player.getUUID()).load().entry("minecraft:prismarine");
helper.assertTrue(disk.seen() && disk.possessed() && disk.placed() == 1,
"The saved file must retain all Sanctuary evidence from before disconnect");
var reloaded = BlockKnowledgeService.knowledge(player, Blocks.PRISMARINE);
helper.assertTrue(reloaded.seen() && reloaded.possessed() && reloaded.placed() == 1 && reloaded.carried() == 0,
"A fresh service session must reload history while rebuilding empty current stock");
BlockKnowledgeService.recordPlaced(player, Blocks.PRISMARINE);
BlockKnowledgeService.flush(server);
var updated = new BlockKnowledgeStore(directory, player.getUUID()).load().entry("minecraft:prismarine");
helper.assertTrue(updated.placed() == 2,
"A reloaded service session must be able to extend and flush its verified history");
helper.succeed();
} catch (IOException failure) {
helper.fail("Cannot read native block knowledge history: " + failure.getMessage());
} finally {
player.getTextFilter().leave();
}
});
}
@GameTest(maxTicks = 100)
public void structurePalettesCombinePersonalEvidenceWithAllowedMaterials(GameTestHelper helper) {
ServerPlayer player = player(helper);
var natural = BlockKnowledgeService.NATURAL_STRUCTURE_MATERIALS;
helper.assertTrue(Blocks.STONE.defaultBlockState().is(natural)
&& Blocks.OAK_LOG.defaultBlockState().is(natural)
&& !Blocks.CONCRETE.pick(net.minecraft.world.item.DyeColor.WHITE).defaultBlockState().is(natural),
"The structure palette tag must include stone and logs while excluding concrete");
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.STONE, 3));
BlockKnowledgeService.observeInventory(player);
player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY);
BlockKnowledgeService.recordSeen(player, Blocks.OAK_LOG);
player.setItemInHand(InteractionHand.OFF_HAND, new ItemStack(Items.CONCRETE.pick(net.minecraft.world.item.DyeColor.WHITE), 5));
var possessed = BlockKnowledgeService.palette(player, BlockKnowledgeFacet.POSSESSED, natural);
helper.assertTrue(possessed.stream().map(BlockKnowledge::blockId).toList().equals(List.of("minecraft:stone")),
"A possessed-material palette must retain historical stone, exclude seen-only oak and reject concrete");
helper.assertTrue(possessed.getFirst().carried() == 0,
"An unlocked material must not invent remaining stock after its items are removed");
helper.assertTrue(BlockKnowledgeService.palette(player, BlockKnowledgeFacet.CARRIED, natural).isEmpty(),
"Current natural stock must be empty even while the player carries disallowed concrete");
var discovered = BlockKnowledgeService.palette(player,
BlockKnowledgeFacet.SEEN.or(BlockKnowledgeFacet.POSSESSED), natural);
helper.assertTrue(discovered.stream().map(BlockKnowledge::blockId).toList()
.equals(List.of("minecraft:oak_log", "minecraft:stone")),
"Composed evidence must produce a stable sorted palette without admitting unknown materials");
helper.assertTrue(discovered.getFirst().seen() && !discovered.getFirst().possessed()
&& discovered.getFirst().carried() == 0,
"Seeing a log must not manufacture possession or a usable quantity");
boolean immutable = false;
try { discovered.clear(); }
catch (UnsupportedOperationException expected) { immutable = true; }
helper.assertTrue(immutable, "Consumers must receive an immutable material palette");
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.STONE, 2));
var available = BlockKnowledgeService.palette(player,
BlockKnowledgeFacet.POSSESSED.and(BlockKnowledgeFacet.CARRIED), natural);
helper.assertTrue(available.size() == 1 && available.getFirst().blockId().equals("minecraft:stone")
&& available.getFirst().carried() == 2,
"An available-material palette must report only the exact current allowed quantity");
helper.succeed();
}
private record Notice(BlockKnowledgeEvents.Activity activity, BlockKnowledge knowledge) { }
private static ServerPlayer player(GameTestHelper helper) {
ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL);
player.setPos(helper.absoluteVec(new Vec3(5.5, 3, 5.5)));
return player;
}
private static boolean place(ServerPlayer player, Item item, BlockPos support, Direction face) {
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(item));
var hit = new BlockHitResult(Vec3.atCenterOf(support), face, support, false);
var context = new BlockPlaceContext(new UseOnContext(player, InteractionHand.MAIN_HAND, hit));
return ((BlockItem) item).place(context).consumesAction();
}
}
@@ -0,0 +1,105 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.knowledge.census.LoadedChunks;
import fr.koka.sanctuary.knowledge.census.TerrainCensus;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.item.DyeColor;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
/** Native material changes and the non-loading contract of the live terrain census. */
public final class CensusGameTestsTerrain {
@GameTest(maxTicks = 3000)
public void chunkCensusRecountsPlacementReplacementAndRemoval(GameTestHelper helper) {
var level = helper.getLevel();
BlockPos pos = helper.absolutePos(new BlockPos(2, 3, 2));
ChunkPos chunkPos = new ChunkPos(pos.getX() >> 4, pos.getZ() >> 4);
var original = level.getBlockState(pos);
long[] baseline = new long[2];
TerrainCensus.ChunkSnapshot[] immutableBefore = new TerrainCensus.ChunkSnapshot[1];
String pink = "minecraft:pink_glazed_terracotta";
String orange = "minecraft:orange_glazed_terracotta";
helper.startSequence()
.thenWaitUntil(() -> helper.assertTrue(current(helper, chunkPos).complete(),
"The fixture chunk must finish its bounded initial material census"))
.thenExecute(() -> {
var snapshot = current(helper, chunkPos);
baseline[0] = snapshot.blockCounts().getOrDefault(pink, 0L);
baseline[1] = snapshot.blockCounts().getOrDefault(orange, 0L);
immutableBefore[0] = snapshot;
level.setBlockAndUpdate(pos, Blocks.GLAZED_TERRACOTTA.pick(DyeColor.PINK).defaultBlockState());
})
.thenWaitUntil(() -> {
var placed = current(helper, chunkPos);
helper.assertTrue(placed.complete() && placed.blockCounts().getOrDefault(pink, 0L) == baseline[0] + 1,
"A native placement must add exactly one material to its loaded chunk");
helper.assertTrue(immutableBefore[0].blockCounts().getOrDefault(pink, 0L) == baseline[0],
"A previously returned census must not mutate when terrain changes");
})
.thenExecute(() -> {
level.setBlockAndUpdate(pos, Blocks.GLAZED_TERRACOTTA.pick(DyeColor.ORANGE).defaultBlockState());
var pending = current(helper, chunkPos);
helper.assertTrue(pending.blockCounts().getOrDefault(pink, 0L) <= baseline[0],
"A dirty section must stop contributing its obsolete material count immediately");
helper.assertTrue(pending.countedSections() <= pending.loadedSections()
&& pending.coverage() >= 0 && pending.coverage() <= 1,
"Coverage must honestly describe the sections contributing to this snapshot");
})
.thenWaitUntil(() -> {
var replaced = current(helper, chunkPos);
helper.assertTrue(replaced.complete() && replaced.blockCounts().getOrDefault(pink, 0L) == baseline[0]
&& replaced.blockCounts().getOrDefault(orange, 0L) == baseline[1] + 1,
"Recounting must remove the replaced material and count its replacement once");
})
.thenExecute(() -> level.setBlockAndUpdate(pos, original))
.thenWaitUntil(() -> {
var removed = current(helper, chunkPos);
helper.assertTrue(removed.complete() && removed.blockCounts().getOrDefault(pink, 0L) == baseline[0]
&& removed.blockCounts().getOrDefault(orange, 0L) == baseline[1],
"Removing the placed material must restore its original material totals");
helper.assertTrue(removed.dimensionId().equals(level.dimension().identifier().toString())
&& removed.chunkX() == chunkPos.x() && removed.chunkZ() == chunkPos.z(),
"Spatial snapshots must retain their stable dimension and chunk coordinates");
}).thenSucceed();
}
@GameTest(maxTicks = 100)
public void missingChunksStayUnloadedAndRepeatedReadsShareBudget(GameTestHelper helper) {
var level = helper.getLevel();
var server = level.getServer();
ChunkPos absent = new ChunkPos(1_000_000, 1_000_000);
helper.assertTrue(level.getChunkSource().getChunkNow(absent.x(), absent.z()) == null,
"The distant fixture must begin unloaded");
int holdersBefore = level.getChunkSource().getLoadedChunksCount();
var first = TerrainCensus.snapshot(server);
int countedBefore = first.dimensions().stream().mapToInt(TerrainCensus.DimensionSnapshot::countedSections).sum();
for (int read = 0; read < 32; read++) {
helper.assertTrue(TerrainCensus.chunkSnapshot(level, absent).isEmpty(),
"Requesting spatial metadata for a missing chunk must return absence");
}
var repeated = TerrainCensus.snapshot(server);
helper.assertTrue(repeated.dimensions().stream().mapToInt(TerrainCensus.DimensionSnapshot::countedSections).sum() == countedBefore,
"Repeated reads in the same tick must not spend another palette scan budget");
helper.assertTrue(level.getChunkSource().getLoadedChunksCount() == holdersBefore
&& level.getChunkSource().getChunkNow(absent.x(), absent.z()) == null,
"Census reads must neither create holders nor load or generate distant chunks");
var dimension = repeated.dimensions().stream()
.filter(value -> value.dimensionId().equals(level.dimension().identifier().toString())).findFirst().orElseThrow();
helper.assertTrue(dimension.loadedChunks() == LoadedChunks.full(level).size(),
"Coverage must count only currently available FULL chunks, excluding unloaded terrain");
helper.assertTrue(!dimension.blockCounts().containsKey("minecraft:air")
&& !dimension.blockCounts().containsKey("minecraft:cave_air")
&& !dimension.blockCounts().containsKey("minecraft:void_air"),
"Terrain materials must not count empty space as available matter");
helper.succeed();
}
private static TerrainCensus.ChunkSnapshot current(GameTestHelper helper, ChunkPos pos) {
var result = TerrainCensus.chunkSnapshot(helper.getLevel(), pos);
helper.assertTrue(result.isPresent(), "The active GameTest fixture chunk must already be loaded FULL");
return result.orElseThrow();
}
}
@@ -0,0 +1,131 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.knowledge.census.StockCensus;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.CompoundContainer;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.entity.vehicle.minecart.MinecartChest;
import net.minecraft.world.inventory.ChestMenu;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.ChestBlock;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import net.minecraft.world.level.block.entity.HopperBlockEntity;
import net.minecraft.world.level.block.state.properties.ChestType;
import net.minecraft.world.level.storage.loot.BuiltInLootTables;
import net.minecraft.world.phys.Vec3;
/** Native fixtures: a census must not move items, invent stock or open unopened loot. */
public final class CensusStockGameTests {
@GameTest(maxTicks = 100)
public void categoriesRemainSeparateAndOpenDoubleChestIsCountedOnce(GameTestHelper helper) {
var level = helper.getLevel();
var server = level.getServer();
var before = StockCensus.snapshot(server);
BlockPos leftPos = helper.absolutePos(new BlockPos(1, 1, 1));
BlockPos rightPos = leftPos.east();
BlockPos hopperPos = helper.absolutePos(new BlockPos(4, 1, 1));
var state = Blocks.CHEST.defaultBlockState().setValue(ChestBlock.FACING, Direction.NORTH);
level.setBlockAndUpdate(leftPos, state.setValue(ChestBlock.TYPE, ChestType.LEFT));
level.setBlockAndUpdate(rightPos, state.setValue(ChestBlock.TYPE, ChestType.RIGHT));
level.setBlockAndUpdate(hopperPos, Blocks.HOPPER.defaultBlockState());
var left = (ChestBlockEntity) level.getBlockEntity(leftPos);
var right = (ChestBlockEntity) level.getBlockEntity(rightPos);
var hopper = (HopperBlockEntity) level.getBlockEntity(hopperPos);
left.setItem(0, new ItemStack(Items.GOLD_INGOT, 3));
right.setItem(0, new ItemStack(Items.GOLD_INGOT, 5));
hopper.setItem(0, new ItemStack(Items.GOLD_INGOT, 7));
ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL);
player.getInventory().setItem(0, new ItemStack(Items.GOLD_INGOT, 6));
player.setItemSlot(EquipmentSlot.OFFHAND, new ItemStack(Items.GOLD_INGOT, 2));
player.setItemSlot(EquipmentSlot.HEAD, new ItemStack(Items.CARVED_PUMPKIN));
player.containerMenu = ChestMenu.sixRows(1, player.getInventory(), new CompoundContainer(left, right));
player.containerMenu.setCarried(new ItemStack(Items.GOLD_INGOT, 3));
helper.assertTrue(!server.getPlayerList().getPlayers().contains(player), "Mock player must start outside the online list");
helper.assertTrue(StockCensus.snapshot(server).playerItems().equals(before.playerItems()),
"An offline player's inventory must not enter loaded online stock");
Vec3 position = helper.absoluteVec(new Vec3(3.5, 2, 3.5));
var cart = new MinecartChest(EntityTypes.CHEST_MINECART, level);
cart.setPos(position);
cart.setItem(0, new ItemStack(Items.GOLD_INGOT, 11));
level.addFreshEntity(cart);
var item = new ItemEntity(level, position.x, position.y, position.z, new ItemStack(Items.GOLD_INGOT, 13));
level.addFreshEntity(item);
server.getPlayerList().getPlayers().add(player);
try {
var census = StockCensus.snapshot(server);
String gold = "minecraft:gold_ingot";
helper.assertTrue(census.playerItems().getOrDefault(gold, 0L) - before.playerItems().getOrDefault(gold, 0L) == 11,
"Personal stock must include main inventory, equipment and cursor without the open chest");
helper.assertTrue(census.blockContainerItems().getOrDefault(gold, 0L) - before.blockContainerItems().getOrDefault(gold, 0L) == 15,
"Two chest halves and a hopper must each be counted once");
helper.assertTrue(census.entityContainerItems().getOrDefault(gold, 0L) - before.entityContainerItems().getOrDefault(gold, 0L) == 11,
"Chest minecart stock belongs to its own category");
helper.assertTrue(census.groundItems().getOrDefault(gold, 0L) - before.groundItems().getOrDefault(gold, 0L) == 13,
"Dropped stack units belong only to ground stock");
helper.assertTrue(census.total(gold) - before.total(gold) == 50, "All four categories must total 50 real items");
helper.assertTrue(census.playerItems().getOrDefault("minecraft:carved_pumpkin", 0L)
- before.playerItems().getOrDefault("minecraft:carved_pumpkin", 0L) == 1,
"Head equipment must be included exactly once");
helper.assertTrue(left.getItem(0).getCount() == 3 && right.getItem(0).getCount() == 5 && hopper.getItem(0).getCount() == 7,
"A stock census must preserve all container contents");
helper.assertTrue(census.coverage().partial() && !census.coverage().offlineIncluded()
&& !census.coverage().nestedContentsIncluded() && !census.coverage().mobInventoriesIncluded(),
"Coverage must explicitly distinguish this loaded census from total world wealth");
boolean immutable = false;
try { census.playerItems().clear(); } catch (UnsupportedOperationException expected) { immutable = true; }
helper.assertTrue(immutable, "Consumers must receive immutable stock maps");
} finally {
server.getPlayerList().getPlayers().remove(player);
cart.discard();
item.discard();
}
helper.succeed();
}
@GameTest(maxTicks = 100)
public void unopenedBlockAndVehicleLootRemainUnresolved(GameTestHelper helper) {
var level = helper.getLevel();
var server = level.getServer();
var before = StockCensus.snapshot(server);
BlockPos chestPos = helper.absolutePos(new BlockPos(1, 1, 1));
level.setBlockAndUpdate(chestPos, Blocks.CHEST.defaultBlockState());
var chest = (ChestBlockEntity) level.getBlockEntity(chestPos);
chest.setLootTable(BuiltInLootTables.SIMPLE_DUNGEON, 41L);
var cart = new MinecartChest(EntityTypes.CHEST_MINECART, level);
cart.setPos(helper.absoluteVec(new Vec3(3.5, 2, 3.5)));
cart.setContainerLootTable(BuiltInLootTables.ABANDONED_MINESHAFT);
cart.setContainerLootTableSeed(73L);
level.addFreshEntity(cart);
try {
var census = StockCensus.snapshot(server);
helper.assertTrue(chest.getLootTable().equals(BuiltInLootTables.SIMPLE_DUNGEON) && chest.getLootTableSeed() == 41L,
"Reading stock must preserve the chest's unopened loot table and seed");
helper.assertTrue(cart.getContainerLootTable().equals(BuiltInLootTables.ABANDONED_MINESHAFT)
&& cart.getContainerLootTableSeed() == 73L,
"Reading stock must also preserve a minecart's unopened loot table and seed");
helper.assertTrue(census.coverage().pendingLootContainers() - before.coverage().pendingLootContainers() == 2,
"Both unopened containers must be explicitly reported as omitted loot");
helper.assertTrue(census.blockContainerItems().equals(before.blockContainerItems())
&& census.entityContainerItems().equals(before.entityContainerItems()),
"An unresolved loot table must not manufacture counted item stacks");
} finally {
// Prevent cleanup from generating loot after the assertions.
cart.setContainerLootTable(null);
cart.discard();
chest.setLootTable(null);
}
helper.succeed();
}
}
@@ -0,0 +1,18 @@
package fr.koka.sanctuary.gametest;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.TicketType;
import net.minecraft.world.level.ChunkPos;
/** Native loading and simulation tickets, including dimension activity, without persisted force-load state. */
final class ChunkTickets25 {
private ChunkTickets25() {}
static void add(ServerLevel level, ChunkPos pos) {
level.getChunkSource().addTicketWithRadius(TicketType.PLAYER_LOADING, pos, 2);
level.getChunkSource().addTicketWithRadius(TicketType.PLAYER_SIMULATION, pos, 2);
}
static void remove(ServerLevel level, ChunkPos pos) {
level.getChunkSource().removeTicketWithRadius(TicketType.PLAYER_LOADING, pos, 2);
level.getChunkSource().removeTicketWithRadius(TicketType.PLAYER_SIMULATION, pos, 2);
}
}
@@ -0,0 +1,278 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.expansion.ExpansionAdmission;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import fr.koka.sanctuary.expansion.ExpansionJournal;
import fr.koka.sanctuary.expansion.ExpansionRuntime;
import fr.koka.sanctuary.expansion.ExpansionStructures23;
import fr.koka.sanctuary.expansion.ExpansionVoidCertificate;
import fr.koka.sanctuary.expansion.ExpansionVoidReuse;
import fr.koka.sanctuary.expansion.NativeStructureMetadata23;
import fr.koka.sanctuary.knowledge.structures.StructureCensusMemory;
import fr.koka.sanctuary.knowledge.structures.StructureCensusService;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.RegistryOps;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.LevelResource;
/** The real public admission transaction and preparation queue, with the production thirty-second deadline. */
public final class Expansion23AdmissionGameTests {
private static final int DISTANCE = 2048;
@GameTest(maxTicks = 24000)
public void createPreparePersistAndReload(GameTestHelper helper) throws Exception {
helper.assertTrue(Boolean.getBoolean("sanctuary.test.admission23"), "Admission fixture must be explicitly enabled");
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 23 && helper.getLevel().structureManager().shouldGenerateStructures(),
"Admission fixture requires generation23 with native structures enabled");
var scenario = new Scenario(helper, generator);
if (scenario.reload) scenario.verifyReload(); else scenario.create();
}
private static final class Scenario {
final GameTestHelper helper;
final ServerLevel level;
final SanctuaryChunkGenerator generator;
final ExpansionRuntime.Session session;
final boolean reload = Boolean.getBoolean("sanctuary.test.expansionReload");
final boolean reserveOnly = Boolean.getBoolean("sanctuary.test.admission23.reserveOnly");
final int diameter = Integer.getInteger("sanctuary.test.admission23.diameter", 256);
final String climate = System.getProperty("sanctuary.test.admission23.climate", "temperate");
final String relief = System.getProperty("sanctuary.test.admission23.relief", "natural");
final Path world, directory, journal, proof;
final Map<String, Object> report = new LinkedHashMap<>();
final List<Map<String, Object>> attempts = new ArrayList<>();
ExpansionIsland reservation;
List<String> captured;
String certificate = "";
long preparationStarted;
boolean drained;
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator) {
this.helper = helper; this.level = helper.getLevel(); this.generator = generator;
helper.assertTrue(!reload || !reserveOnly, "Reserve-only costs require a fresh JVM and disposable world, not reload mode");
session = ExpansionRuntime.session(level);
world = level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
directory = world.resolve("data").resolve(generator.journalDirectory());
journal = directory.resolve(ExpansionJournal.FILE_NAME); proof = world.resolve("admission23-create.json");
report.put("process_id", ProcessHandle.current().pid()); report.put("world_directory", world.toString());
report.put("world_seed", level.getSeed()); report.put("generation", generator.rootGeneration());
report.put("deadline_seconds", 30); report.put("attempts", attempts);
report.put("reserve_only", reserveOnly); report.put("requested_diameter", diameter);
report.put("requested_climate", climate); report.put("requested_relief", relief);
report.put("no_expansion_preview_before_create", true);
}
void create() throws Exception {
helper.assertTrue(!Files.exists(proof) && session.islands().size() == 1, "Use a new disposable world for the first admission pass");
long remaining = ExpansionAdmission.searchDeadline() - System.nanoTime();
helper.assertTrue(remaining > TimeUnit.SECONDS.toNanos(29) && remaining <= TimeUnit.SECONDS.toNanos(30),
"Fixture uses the unchanged production30s deadline");
byte[] untouchedJournal = Files.readAllBytes(journal);
var initialIslands = session.islands();
try {
for (int draw = 0; draw < 3 && reservation == null; draw++) {
String id = "admission_23_" + draw;
captured = StructureCensusService.priorities(level);
long started = System.nanoTime();
try {
reservation = session.create(id, "sanctuary", "east", climate, diameter, relief, DISTANCE);
long elapsed = elapsedMillis(started);
attempts.add(Map.of("id", id, "result", "reserved", "milliseconds", elapsed));
helper.assertTrue(elapsed <= 31_000, "Real admission completes within its30s budget plus bounded clock/IO overhead");
} catch (IllegalArgumentException | IOException refused) {
attempts.add(Map.of("id", id, "result", String.valueOf(refused.getMessage()), "milliseconds", elapsedMillis(started)));
helper.assertTrue(session.islands().equals(initialIslands) && Arrays.equals(untouchedJournal, Files.readAllBytes(journal)),
"A refused request leaves no reservation or journal mutation");
// A timeout, IO failure or other implementation error is not a new random draw.
if (!"native_structure_unavailable".equals(refused.getMessage())) throw refused;
}
}
helper.assertTrue(reservation != null, "A real expansion is admitted within three bounded feature refusals: " + diameter + " " + climate + " " + relief);
helper.assertTrue(reservation.status().equals("reserved") && session.islands().size() == 2,
"The transaction durably reserves exactly one child before preparation");
helper.assertTrue(reservation.structurePriorities().equals(captured), "The stored profile equals the snapshot taken before create");
helper.assertTrue(!ExpansionVoidReuse.requiresReload(level, reservation.id()), "Fresh distant terrain prepares without a void-reuse reload");
certificate = certificate(reservation);
verifyJournal(reservation);
var snapshot = Files.readAllBytes(journal);
var repeated = session.create(reservation.id(), "sanctuary", "east", climate, diameter, relief, DISTANCE);
helper.assertTrue(repeated.equals(reservation) && Arrays.equals(snapshot, Files.readAllBytes(journal)) && session.islands().size() == 2,
"Repeating create on the reserved ID does not reserve again or rewrite the profile");
if (reserveOnly) {
report.put("island", encode(reservation)); report.put("captured_priorities", captured);
report.put("journal_sha256", ExpansionVoidCertificate.digest(Files.readString(journal)));
report.put("certificate", certificate); report.put("certificate_required", !reservation.reuseToken().isEmpty());
report.put("generator_codec", generatorCodec()); report.put("idempotent", true);
report.put("native_guarantee_checked_by_session_create", true);
report.put("full_generation_checked", false); report.put("passed", true);
write("reserve-" + diameter + "-" + climate + "-" + relief);
helper.succeed(); return;
}
preparationStarted = System.nanoTime();
helper.runAfterDelay(5, this::awaitReady);
} catch (Exception failure) { fail(failure); }
}
void awaitReady() {
try {
var current = session.island(reservation.id());
if (!current.status().equals("ready")) {
helper.assertTrue(session.progress().matches(java.util.regex.Pattern.quote(reservation.id()) + ": \\d+/\\d+"),
"Real preparation must keep progressing, not stop silently: " + session.progress());
helper.runAfterDelay(5, this::awaitReady); return;
}
if (!drained) { drained = true; helper.runAfterDelay(5, this::awaitReady); return; }
helper.assertTrue(current.equals(reservation.ready()), "Preparation changes only reserved→ready, preserving identity and priorities");
verifyJournal(current);
helper.assertTrue(certificate.equals(certificate(current)), "Preparation cannot rewrite the certified terrain identity");
var starts = nativeStarts(current);
helper.assertTrue(!starts.isEmpty(), "An admitted expansion really creates at least one registered native start");
verifyCensus(current, starts);
report.put("priorities_after_generation", StructureCensusService.priorities(level));
byte[] readyJournal = Files.readAllBytes(journal);
helper.assertTrue(session.create(current.id(), "sanctuary", "east", current.climate(), current.diameter(), current.relief(), DISTANCE).equals(current),
"A ready ID stays idempotent even after census evidence has changed");
helper.assertTrue(Arrays.equals(readyJournal, Files.readAllBytes(journal)), "Idempotence preserves exact journal bytes");
StructureCensusService.flush(level.getServer());
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Persist actual FULL preparation and census before shutdown");
report.put("preparation_milliseconds", elapsedMillis(preparationStarted));
report.put("island", encode(current)); report.put("captured_priorities", captured);
report.put("journal_sha256", ExpansionVoidCertificate.digest(Files.readString(journal)));
report.put("certificate", certificate); report.put("certificate_required", !current.reuseToken().isEmpty());
report.put("generator_codec", generatorCodec()); report.put("native_starts", tags(starts));
report.put("native_count", starts.size()); report.put("census_verified", true); report.put("idempotent", true);
report.put("passed", true); write("create");
Files.writeString(proof, json()); helper.succeed();
} catch (Exception failure) { fail(failure); }
}
void verifyReload() throws Exception {
try {
var previous = JsonParser.parseString(Files.readString(proof)).getAsJsonObject();
helper.assertTrue(previous.get("passed").getAsBoolean(), "Reload requires a successful first admission pass");
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must use another JVM");
helper.assertTrue(previous.get("world_directory").getAsString().equals(world.toString())
&& previous.get("world_seed").getAsLong() == level.getSeed(), "Reload preserves the same development world and seed");
var saved = ExpansionIsland.CODEC.parse(JsonOps.INSTANCE, previous.get("island")).getOrThrow();
var current = session.island(saved.id());
helper.assertTrue(current.equals(saved) && current.status().equals("ready") && session.islands().size() == 2,
"Startup restores the ready child directly, without another create call");
helper.assertTrue(generator.source().islands().equals(session.islands()), "Loaded world generation uses the journal snapshot");
helper.assertTrue(ExpansionVoidCertificate.digest(Files.readString(journal)).equals(previous.get("journal_sha256").getAsString()),
"Opening a ready journal preserves its exact bytes");
verifyJournal(current);
helper.assertTrue(previous.get("generator_codec").equals(generatorCodec()), "The generator codec survives a full process restart");
helper.assertTrue(previous.get("certificate").getAsString().equals(certificate(current)), "The same certificate decision survives startup");
var starts = nativeStarts(current);
helper.assertTrue(previous.get("native_starts").equals(tags(starts)), "Native start identity, contents and reference counters survive reload");
verifyCensus(current, starts);
helper.assertTrue(session.progress().equals("idle"), "Ready regions do not restart their preparation job");
report.put("island", encode(current)); report.put("native_count", starts.size());
report.put("previous_process_id", previous.get("process_id").getAsLong());
report.put("create_called", false); report.put("census_verified", true); report.put("passed", true);
write("reload"); helper.succeed();
} catch (Exception failure) { fail(failure); }
}
private List<StructureStart> nativeStarts(ExpansionIsland island) {
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
var starts = new ArrayList<StructureStart>();
for (var candidate : ExpansionStructures23.candidates(island)) {
var chunk = level.getChunk(candidate.chunk().x(), candidate.chunk().z(), ChunkStatus.STRUCTURE_STARTS, true);
for (var start : chunk.getAllStarts().values()) {
if (!start.isValid()) continue;
var id = registry.getKey(start.getStructure());
if (id != null && id.getNamespace().equals("minecraft")
&& ((NativeStructureMetadata23) (Object) start).sanctuary$native23() != null) starts.add(start);
}
}
starts.sort(Comparator.comparingInt((StructureStart s) -> s.getChunkPos().x()).thenComparingInt(s -> s.getChunkPos().z())
.thenComparing(s -> registry.getKey(s.getStructure()).toString()));
return List.copyOf(starts);
}
private void verifyCensus(ExpansionIsland island, List<StructureStart> starts) {
var view = StructureCensusService.snapshot(level.getServer());
helper.assertTrue(view.available(), "Passive structure census stays available through actual generation and reload");
var region = view.regions().stream().filter(r -> r.island().equals(island.id())).findFirst().orElseThrow();
helper.assertTrue(region.coverage().startsComplete() && region.coverage().fullChunks() == region.coverage().expectedChunks(),
"The preparation queue certifies finite native-start and FULL coverage of the ready island");
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
for (var start : starts) {
String id = registry.getKey(start.getStructure()).toString();
var key = new StructureCensusMemory.SiteKey(level.dimension().identifier().toString(), id,
start.getChunkPos().x(), 0, start.getChunkPos().z(), false);
var indexed = view.evidence().sites().get(key);
helper.assertTrue(indexed != null && view.evidence().generated(indexed), "The census records the real generated occurrence: " + id);
var family = region.families().get(StructureCensusService.family(id));
helper.assertTrue(family != null && family.presence() == StructureCensusService.Presence.PRESENT && family.generated() > 0,
"A generated native family is present, never absent or unknown");
}
}
private void verifyJournal(ExpansionIsland island) throws IOException {
helper.assertTrue(ExpansionIsland.CODEC.parse(JsonOps.INSTANCE, encode(island)).getOrThrow().equals(island), "Every descriptor field survives CODEC roundtrip");
var reopened = new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter(), generator.rootGeneration());
helper.assertTrue(reopened.islands().equals(session.islands()) && reopened.islands().contains(island),
"A separate read of the durable journal agrees with the live session");
}
private String certificate(ExpansionIsland island) throws IOException {
if (island.reuseToken().isEmpty()) return ""; // Fully absent terrain needs no void-reuse certificate.
String text = Files.readString(directory.resolve("void-reuse").resolve(island.reuseToken() + ".json"));
helper.assertTrue(ExpansionVoidCertificate.digest(text).equals(island.reuseToken()), "Certificate content matches its immutable token");
var body = JsonParser.parseString(text).getAsJsonObject();
helper.assertTrue(ExpansionIsland.CODEC.parse(JsonOps.INSTANCE, body.get("island")).getOrThrow().equals(island.withReuseToken("").ready()),
"Certificate binds the same immutable descriptor and priority profile");
return text;
}
private JsonArray tags(List<StructureStart> starts) {
var result = new JsonArray(); var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
var context = StructurePieceSerializationContext.fromLevel(level);
for (var start : starts) {
var saved = new JsonObject(); saved.addProperty("id", registry.getKey(start.getStructure()).toString());
saved.addProperty("x", start.getChunkPos().x()); saved.addProperty("z", start.getChunkPos().z());
saved.addProperty("nbt", start.createTag(context, start.getChunkPos()).toString()); result.add(saved);
}
return result;
}
private com.google.gson.JsonElement encode(ExpansionIsland island) { return ExpansionIsland.CODEC.encodeStart(JsonOps.INSTANCE, island).getOrThrow(); }
private com.google.gson.JsonElement generatorCodec() {
return ChunkGenerator.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow();
}
private String json() { return new GsonBuilder().setPrettyPrinting().create().toJson(report); }
private void write(String name) throws IOException {
var output = Path.of("diagnostics", "admission23"); Files.createDirectories(output); Files.writeString(output.resolve(name + ".json"), json());
}
private void fail(Exception failure) {
report.put("passed", false); report.put("failure", failure.toString()); report.put("progress", session.progress());
try { write("failure"); } catch (IOException ignored) {}
throw new IllegalStateException("Production admission23 failed", failure);
}
}
private static long elapsedMillis(long started) { return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); }
}
@@ -0,0 +1,464 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.expansion.*;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.TicketType;
import net.minecraft.tags.FluidTags;
import net.minecraft.tags.StructureTags;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.entity.MobCategory;
import net.minecraft.world.entity.SpawnPlacements;
import net.minecraft.world.entity.monster.Guardian;
import net.minecraft.world.level.NaturalSpawner;
import net.minecraft.world.entity.monster.ElderGuardian;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.LevelResource;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
/** Real admission plus a bounded FULL monument footprint; the whole-island preparation queue is tested separately. */
public final class Expansion25MonumentGameTests {
@GameTest(maxTicks = 60000)
public void reservedMonumentAndEldersSurviveReload(GameTestHelper helper) throws Exception {
new Scenario(helper).start();
}
private static final class Scenario {
final GameTestHelper helper;
final net.minecraft.server.level.ServerLevel level;
final SanctuaryChunkGenerator generator;
final ExpansionRuntime.Session session;
final boolean reload = !System.getProperty("sanctuary.test.monument25.phase", "create").equals("create");
final int diameter = Integer.getInteger("sanctuary.test.monument25.diameter", 512);
final String climate = System.getProperty("sanctuary.test.monument25.climate", "temperate");
final boolean awaitReady = Boolean.getBoolean("sanctuary.test.monument25.awaitReady");
final Path world, directory, journal, proof;
final Map<String, Object> report = new LinkedHashMap<>();
final List<ChunkPos> chunks = new ArrayList<>();
ExpansionIsland island;
ExpansionStructurePlan25 plan;
JsonObject previous;
int index, wait;
long entityWaitStartedNanos, entityReadyNanos;
final long scenarioStartedNanos = System.nanoTime();
long readyWaitStartedNanos, readyLastAdvanceNanos, readyLastReportNanos;
int readyLastIndex = -1, readyTotalChunks = -1;
boolean tickets;
Scenario(GameTestHelper helper) {
this.helper = helper; level = helper.getLevel(); generator = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
session = ExpansionRuntime.session(level);
world = level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
directory = world.resolve("data").resolve(generator.journalDirectory()); journal = directory.resolve(ExpansionJournal.FILE_NAME);
proof = world.resolve("monument25-created.json");
report.put("process_id", ProcessHandle.current().pid()); report.put("diameter", diameter); report.put("climate", climate);
report.put("phase", reload ? "reload" : "create"); report.put("world_seed", level.getSeed());
report.put("await_ready", awaitReady);
}
void start() throws Exception {
helper.assertTrue(Boolean.getBoolean("sanctuary.test.monument25") && generator.rootGeneration() == 25,
"Dedicated disposable generation25 world required");
try {
if (!level.structureManager().shouldGenerateStructures()) { verifyStructuresOff(); return; }
if (reload) {
previous = JsonParser.parseString(Files.readString(proof)).getAsJsonObject();
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload uses a fresh JVM");
var saved = ExpansionIsland.CODEC.parse(JsonOps.INSTANCE, previous.get("island")).getOrThrow();
helper.assertTrue(saved.diameter() == diameter && saved.climate().equals(climate), "Reload arguments match the saved monument scenario");
island = session.island(saved.id());
helper.assertTrue(island.ready().equals(saved.ready()), "Startup restores the exact admitted geometry and plan");
report.put("create_called", false);
} else {
helper.assertTrue(session.islands().size() == 1 && !Files.exists(proof), "Fresh world, no preview before admission");
byte[] pristine = Files.readAllBytes(journal);
var attempts = new ArrayList<Map<String, Object>>(); report.put("attempts", attempts);
for (int draw = 0; draw < 3 && island == null; draw++) {
long start = System.nanoTime();
try {
island = session.create("monument25_" + draw, "sanctuary", "east", climate, diameter, "ocean", 4096);
long elapsed = (System.nanoTime() - start) / 1_000_000;
attempts.add(Map.of("draw", draw, "milliseconds", elapsed, "accepted", true));
helper.assertTrue(elapsed < 31_000, "Real admission uses its thirty-second deadline");
} catch (IllegalArgumentException refused) {
helper.assertTrue(Arrays.equals(pristine, Files.readAllBytes(journal)) && session.islands().size() == 1,
"A refused ocean leaves no reservation");
attempts.add(Map.of("draw", draw, "milliseconds", (System.nanoTime() - start) / 1_000_000, "reason", String.valueOf(refused.getMessage())));
if (!"native_structure_unavailable".equals(refused.getMessage())) throw refused;
}
}
helper.assertTrue(island != null, "A suitable monument must be found within three bounded requests");
var bytes = Files.readAllBytes(journal);
helper.assertTrue(session.create(island.id(), "sanctuary", "east", climate, diameter, "ocean", 4096).equals(island)
&& Arrays.equals(bytes, Files.readAllBytes(journal)), "Repeated admission cannot redraw the selected monument");
}
plan = island.structurePlan25().orElseThrow();
helper.assertTrue(new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter(), 25).islands().contains(island), "Durable journal contains the exact plan");
helper.assertTrue(plan.structure().equals("minecraft:monument"), "Ocean guarantee cannot be satisfied by a ruin or shipwreck");
if (!island.reuseToken().isEmpty()) {
String certificate = Files.readString(directory.resolve("void-reuse").resolve(island.reuseToken() + ".json"));
helper.assertTrue(ExpansionVoidCertificate.digest(certificate).equals(island.reuseToken()), "Certificate digest");
helper.assertTrue(ExpansionIsland.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseString(certificate).getAsJsonObject().get("island")).getOrThrow()
.equals(island.withReuseToken("").ready()), "Certificate includes native pieces and algorithm");
}
chunks.addAll(plan.protection().intersectingChunks().toList()); chunks.add(new ChunkPos(0, 0));
helper.runAfterDelay(1, this::step);
} catch (Exception error) { fail(error); }
}
/** Real admission with the global Minecraft switch OFF; no native preflight, fake plan or fabricated READY. */
void verifyStructuresOff() throws Exception {
helper.assertTrue(!reload && !awaitReady && session.islands().size() == 1,
"Structures-OFF admission uses a fresh bounded scenario without a whole-island READY claim");
long admissionStarted = System.nanoTime();
island = session.create("monument25_off", "sanctuary", "east", climate, diameter, "ocean", 4096);
report.put("admission_milliseconds", (System.nanoTime() - admissionStarted) / 1_000_000);
helper.assertTrue(island.structurePlan25().isEmpty(), "Real structures-OFF admission captures no monument");
var encoded = ExpansionIsland.CODEC.encodeStart(JsonOps.INSTANCE, island).getOrThrow();
helper.assertTrue(!encoded.getAsJsonObject().has("structure_plan25"), "The codec invents no monument payload with structures OFF");
var savedJournal = new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter(), 25);
helper.assertTrue(savedJournal.islands().contains(island) && savedJournal.islands().stream().allMatch(i -> i.structurePlan25().isEmpty()),
"The durable OFF journal has no captured monument plan");
helper.assertTrue(!Files.readString(journal).contains("structure_plan25"), "The manual journal writer omits absent structure plans");
if (!island.reuseToken().isEmpty()) {
String certificate = Files.readString(directory.resolve("void-reuse").resolve(island.reuseToken() + ".json"));
helper.assertTrue(ExpansionVoidCertificate.digest(certificate).equals(island.reuseToken()), "OFF certificate digest");
var certified = JsonParser.parseString(certificate).getAsJsonObject().getAsJsonObject("island");
helper.assertTrue(!certified.has("structure_plan25") && ExpansionIsland.CODEC.parse(JsonOps.INSTANCE, certified).getOrThrow()
.equals(island.withReuseToken("").ready()), "Any OFF void certificate preserves the exact geometry without native pieces");
}
report.put("certificate_present", !island.reuseToken().isEmpty());
var starts = ExpansionStructures23.candidates(island);
helper.assertTrue(!starts.isEmpty() && starts.size() <= 128, "Finite native start candidate set for this OFF ocean");
for (var candidate : starts) {
var at = candidate.chunk();
var chunk = level.getChunk(at.x(), at.z(), ChunkStatus.STRUCTURE_STARTS, true);
helper.assertTrue(chunk.getAllStarts().values().stream().noneMatch(StructureStart::isValid),
"GlobalOFF emits no native start at a candidate chunk: " + at);
}
var full = new java.util.LinkedHashSet<ChunkPos>();
full.add(new ChunkPos(island.centerX() >> 4, island.centerZ() >> 4));
full.add(starts.getFirst().chunk()); full.add(starts.get(starts.size() / 2).chunk());
long actualWater = 0;
for (var at : full) {
var chunk = level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true);
helper.assertTrue(chunk.getAllStarts().values().stream().noneMatch(StructureStart::isValid), "GlobalOFF keeps FULL chunks structure-free");
for (var pos : BlockPos.betweenClosed(at.getMinBlockX(), 0, at.getMinBlockZ(), at.getMaxBlockX(), 383, at.getMaxBlockZ())) {
var state = chunk.getBlockState(pos);
if (state.is(Blocks.WATER)) actualWater++;
helper.assertTrue(!state.is(Blocks.PRISMARINE) && !state.is(Blocks.PRISMARINE_BRICKS)
&& !state.is(Blocks.DARK_PRISMARINE) && !state.is(Blocks.SEA_LANTERN), "GlobalOFF does not place monument blocks");
}
}
helper.assertTrue(actualWater > 0, "GlobalOFF retains a real floating ocean");
byte[] before = Files.readAllBytes(journal);
var beforeSession = List.copyOf(session.islands()); var beforePublished = List.copyOf(generator.source().islands());
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
var wanted = registry.getOrThrow(StructureTags.ON_OCEAN_MONUMENT_MAPS);
var origin = new BlockPos(island.centerX(), ExpansionOcean23.seaLevel(island), island.centerZ());
helper.assertTrue(generator.findNearestMapStructure(level, wanted, origin, 8, false) == null
&& generator.findNearestMapStructure(level, wanted, origin, 8, true) == null, "Both normal and explorer-map tag searches are empty with structures OFF");
helper.assertTrue(session.create(island.id(), "sanctuary", "east", climate, diameter, "ocean", 4096).equals(session.island(island.id())),
"Repeated OFF admission reuses its saved reservation");
helper.assertTrue(Arrays.equals(before, Files.readAllBytes(journal)) && beforeSession.equals(session.islands())
&& beforePublished.equals(generator.source().islands()), "OFF searches and duplicate request never activate or redraw an expansion");
report.put("generate_structures", false); report.put("native_candidates_checked", starts.size()); report.put("full_chunks_checked", full.size());
report.put("actual_water_blocks", actualWater); report.put("monument_plan_captured", false);
report.put("locator_empty", true); report.put("search_journal_unchanged", true); report.put("whole_island_ready_checked", false);
report.put("island", ExpansionIsland.CODEC.encodeStart(JsonOps.INSTANCE, session.island(island.id())).getOrThrow());
report.put("progress", session.progress()); report.put("scenario_milliseconds", (System.nanoTime() - scenarioStartedNanos) / 1_000_000);
report.put("passed", true); write("global-off", new GsonBuilder().setPrettyPrinting().create().toJson(report)); helper.succeed();
}
void step() {
try {
if (index < chunks.size()) {
var pos = chunks.get(index++); report.put("stage", "FULL " + pos);
level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true); helper.runAfterDelay(1, this::step); return;
}
if (!tickets) {
chunks.forEach(pos -> ChunkTickets25.add(level, pos));
tickets = true; entityWaitStartedNanos = System.nanoTime();
}
boolean ready = recordReadiness();
var elders = level.getEntitiesOfClass(ElderGuardian.class, AABB.of(plan.protection()).inflate(12));
int expected = reload ? 1 : 3;
report.put("elders_observed", elders.stream().map(e -> Map.of("uuid", e.getUUID().toString(), "position", List.of(e.getX(), e.getY(), e.getZ()))).toList());
report.put("entity_wait_ticks", wait);
if ((!ready || elders.size() != expected) && wait++ < 6000) { report.put("stage", "entity readiness"); helper.runAfterDelay(1, this::step); return; }
helper.assertTrue(ready, "All monument entity chunks and the extraction chunk are loaded and ticking: " + report.get("pending_entity_chunks"));
helper.assertTrue(elders.size() == expected, "Three initial elders; no replacements after one death and one extraction");
if (entityReadyNanos == 0) entityReadyNanos = System.nanoTime();
if (awaitReady && !recordWholeIslandReadiness()) {
helper.runAfterDelay(5, this::step); return;
}
inspect(elders);
fr.koka.sanctuary.knowledge.structures.StructureCensusService.flush(level.getServer());
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Persist native starts, mutations and entities");
report.put("island", ExpansionIsland.CODEC.encodeStart(JsonOps.INSTANCE, session.island(island.id())).getOrThrow());
report.put("full_monument_chunks", chunks.size()); report.put("whole_island_ready_checked", awaitReady);
report.put("scenario_milliseconds", (System.nanoTime() - scenarioStartedNanos) / 1_000_000); report.put("passed", true);
String json = new GsonBuilder().setPrettyPrinting().create().toJson(report);
write(reload ? "reload" : "create", json);
if (!reload) Files.writeString(proof, json);
release(); helper.succeed();
} catch (Exception error) { fail(error); }
}
/** Observe the production queue; do not publish an artificial READY snapshot or load its remainder in this fixture. */
boolean recordWholeIslandReadiness() throws java.io.IOException {
long now = System.nanoTime();
if (readyWaitStartedNanos == 0) { readyWaitStartedNanos = now; readyLastAdvanceNanos = now; }
var current = session.island(island.id());
report.put("preparation_progress", session.progress());
report.put("ready_wait_milliseconds", (now - readyWaitStartedNanos) / 1_000_000);
report.put("ready_wait_budget_milliseconds", 900_000);
report.put("ready_stall_budget_milliseconds", 300_000);
if (current.status().equals("ready")) {
helper.assertTrue(current.equals(island.ready()), "Production READY preserves the admitted monument, priorities and complete island descriptor");
helper.assertTrue(new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter(), 25).islands().contains(current),
"The production queue commits READY durably before the locator can advertise it");
helper.assertTrue(generator.source().islands().contains(current), "The generator publishes the same durably READY island");
if (readyTotalChunks >= 0) report.put("ready_full_chunks", readyTotalChunks);
report.put("already_ready_at_first_observation", readyTotalChunks < 0);
report.put("ready_observed", true);
return true;
}
report.put("stage", "wait for real whole-island READY");
var progress = java.util.regex.Pattern.compile(java.util.regex.Pattern.quote(island.id()) + ": (\\d+)/(\\d+)").matcher(session.progress());
helper.assertTrue(progress.matches(), "Whole-island preparation must continue: " + session.progress());
int completed = Integer.parseInt(progress.group(1)), total = Integer.parseInt(progress.group(2));
helper.assertTrue(completed >= readyLastIndex && completed < total && total > 0
&& (readyTotalChunks < 0 || readyTotalChunks == total), "Production preparation is monotonic over a stable finite chunk list");
if (completed > readyLastIndex) { readyLastAdvanceNanos = now; readyLastIndex = completed; }
readyTotalChunks = total;
report.put("ready_completed_chunks", completed); report.put("ready_total_chunks", total);
report.put("ready_stalled_milliseconds", (now - readyLastAdvanceNanos) / 1_000_000);
helper.assertTrue(now - readyWaitStartedNanos < 900_000_000_000L, "Real whole-island preparation exceeded its fifteen-minute test budget");
helper.assertTrue(now - readyLastAdvanceNanos < 300_000_000_000L, "The production queue made no FULL-chunk progress for five minutes");
if (readyLastReportNanos == 0 || now - readyLastReportNanos >= 15_000_000_000L) {
write("preparation", new GsonBuilder().setPrettyPrinting().create().toJson(report));
readyLastReportNanos = now;
}
return false;
}
boolean recordReadiness() {
var statuses = new ArrayList<Map<String, Object>>();
var pending = new ArrayList<String>();
for (var pos : chunks) {
boolean loaded = level.areEntitiesLoaded(pos.pack());
boolean ticking = level.areEntitiesActuallyLoadedAndTicking(pos);
statuses.add(Map.of("x", pos.x(), "z", pos.z(), "entities_loaded", loaded,
"entities_ticking", ticking, "chunk_debug", level.getChunkSource().getChunkDebugData(pos),
"extraction_chunk", pos.x() == 0 && pos.z() == 0));
if (!loaded || !ticking) pending.add(pos.toString());
}
report.put("entity_chunks", statuses); report.put("pending_entity_chunks", pending);
report.put("entity_wait_milliseconds", entityWaitStartedNanos == 0 ? 0
: ((entityReadyNanos == 0 ? System.nanoTime() : entityReadyNanos) - entityWaitStartedNanos) / 1_000_000);
return pending.isEmpty();
}
void inspect(List<ElderGuardian> elders) throws java.io.IOException {
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
var holder = registry.get(Identifier.parse("minecraft:monument")).orElseThrow();
var chunk = level.getChunk(plan.chunkX(), plan.chunkZ());
StructureStart start = level.structureManager().getStartForStructure(holder.value(), chunk);
helper.assertTrue(start != null && start.isValid(), "Reserved start is really created at STRUCTURE_STARTS");
var context = StructurePieceSerializationContext.fromLevel(level);
var tag = start.createTag(context, start.getChunkPos());
var withoutReferences = tag.copy(); withoutReferences.putInt("references", 0);
helper.assertTrue(withoutReferences.equals(plan.tag()), "Native generated pieces equal the pre-admission payload");
long prismarine = 0, lanterns = 0, gold = 0; BlockPos removable = null;
for (var pos : BlockPos.betweenClosed(plan.minX(), plan.minY(), plan.minZ(), plan.maxX(), plan.maxY(), plan.maxZ())) {
var block = level.getBlockState(pos);
if (block.is(Blocks.PRISMARINE) || block.is(Blocks.PRISMARINE_BRICKS) || block.is(Blocks.DARK_PRISMARINE)) { prismarine++; if (removable == null) removable = pos.immutable(); }
if (block.is(Blocks.SEA_LANTERN)) lanterns++;
if (block.is(Blocks.GOLD_BLOCK)) gold++;
}
helper.assertTrue(prismarine > 100 && lanterns > 0 && gold >= 8, "Actual FULL contains a detailed vanilla monument and its eight gold blocks");
report.put("blocks", Map.of("prismarine", prismarine, "sea_lantern", lanterns, "gold_block", gold));
verifyImmersion();
verifyOrdinaryGuardians(start);
verifyLocator(start);
if (!reload) {
elders = elders.stream().sorted(Comparator.comparing(e -> e.getUUID().toString())).toList();
String killed = elders.get(0).getUUID().toString(); elders.get(0).kill(level);
var extracted = elders.get(1); extracted.setNoAi(true); extracted.setNoGravity(true); extracted.setPermanentlyInvulnerable(true);
extracted.setDeltaMovement(Vec3.ZERO); extracted.snapTo(4.5, 330, 4.5); extracted.setPersistenceRequired();
report.put("killed_uuid", killed); report.put("extracted_uuid", extracted.getUUID().toString());
report.put("remaining_uuid", elders.get(2).getUUID().toString());
helper.assertTrue(removable != null, "A real monument block is available for destructive persistence testing");
level.setBlock(removable, Blocks.AIR.defaultBlockState(), 2);
report.put("removed_block", List.of(removable.getX(), removable.getY(), removable.getZ()));
} else {
helper.assertTrue(level.getEntity(UUID.fromString(previous.get("killed_uuid").getAsString())) == null, "Killed elder stays absent");
var extracted = level.getEntity(UUID.fromString(previous.get("extracted_uuid").getAsString()));
helper.assertTrue(extracted instanceof ElderGuardian && extracted.getX() < 16 && extracted.getZ() < 16, "The extracted elder survives outside the monument");
helper.assertTrue(elders.getFirst().getUUID().toString().equals(previous.get("remaining_uuid").getAsString()), "Only the untouched original elder remains");
var at = previous.getAsJsonArray("removed_block"); var state = level.getBlockState(new BlockPos(at.get(0).getAsInt(), at.get(1).getAsInt(), at.get(2).getAsInt()));
helper.assertTrue(!state.is(Blocks.PRISMARINE) && !state.is(Blocks.PRISMARINE_BRICKS) && !state.is(Blocks.DARK_PRISMARINE), "Demolished monument block is not restored");
helper.assertTrue(start.getReferences() == previous.get("references").getAsInt(), "Reload preserves map reference counter");
}
report.put("native_tag", start.createTag(context, start.getChunkPos()).toString());
var census = fr.koka.sanctuary.knowledge.structures.StructureCensusService.snapshot(level.getServer());
var key = new fr.koka.sanctuary.knowledge.structures.StructureCensusMemory.SiteKey(level.dimension().identifier().toString(), "minecraft:monument", plan.chunkX(), 0, plan.chunkZ(), false);
var evidence = census.evidence().sites().get(key);
helper.assertTrue(evidence != null && census.evidence().generated(evidence), "Census records the generated monument");
}
/** Inspect water outside the native body and above its highest possible room; doorway air is not an exterior witness. */
void verifyImmersion() {
int seaLevel = ExpansionOcean23.seaLevel(island);
helper.assertTrue(plan.maxY() + 1 < seaLevel, "The entire native monument fits below its ocean waterline");
var immersion = new LinkedHashMap<String, Object>(); report.put("immersion", immersion);
immersion.put("waterline", seaLevel); immersion.put("roof_y", plan.maxY());
immersion.put("scope", "Actual FULL blocks on all four sides, three blocks outside the body, and across the water above the roof. Interior rooms and doorway openings are excluded. Native ice counts as frozen water.");
var samples = new ArrayList<Map<String, Object>>(); immersion.put("samples", samples);
var counts = new LinkedHashMap<String, Integer>();
// The first level is directly above the native bounding box; subsequent levels
// prove a water column up towards the surface, including frozen ocean material.
var roofLevels = new java.util.TreeSet<Integer>();
roofLevels.add(plan.maxY() + 1); roofLevels.add((plan.maxY() + 1 + seaLevel - 1) / 2); roofLevels.add(seaLevel - 1);
for (int z = plan.minZ() + 4; z <= plan.maxZ() - 4; z += 8)
for (int x = plan.minX() + 4; x <= plan.maxX() - 4; x += 8)
for (int y : roofLevels) immersionSample(new BlockPos(x, y, z), "above_roof", samples, counts);
int[] levels = {plan.minY() + 6, plan.minY() + 14, plan.maxY() + 1};
for (int offset = 8; offset <= 48; offset += 10) for (int y : levels) {
immersionSample(new BlockPos(plan.minX() - 3, y, plan.minZ() + offset), "west", samples, counts);
immersionSample(new BlockPos(plan.maxX() + 3, y, plan.minZ() + offset), "east", samples, counts);
immersionSample(new BlockPos(plan.minX() + offset, y, plan.minZ() - 3), "north", samples, counts);
immersionSample(new BlockPos(plan.minX() + offset, y, plan.maxZ() + 3), "south", samples, counts);
}
immersion.put("counts", counts);
helper.assertTrue(counts.getOrDefault("water", 0) > 0, "Actual water, not only a planned waterline, surrounds the monument");
immersion.put("passed", true);
}
void immersionSample(BlockPos pos, String zone, List<Map<String, Object>> samples, Map<String, Integer> counts) {
var state = level.getBlockState(pos);
boolean water = state.getFluidState().is(FluidTags.WATER);
boolean frozen = state.is(Blocks.ICE) || state.is(Blocks.PACKED_ICE) || state.is(Blocks.BLUE_ICE) || state.is(Blocks.FROSTED_ICE);
samples.add(Map.of("zone", zone, "position", List.of(pos.getX(), pos.getY(), pos.getZ()),
"block", net.minecraft.core.registries.BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(),
"water", water, "frozen", frozen));
helper.assertTrue(water || frozen, "Monument immersion requires real water or native ice at " + zone + " " + pos + ": " + state);
counts.merge(zone, 1, Integer::sum); counts.merge(water ? "water" : "frozen", 1, Integer::sum);
}
/** Exercise the real structure override and NaturalSpawner, without inventing a guardian quota or bypassing water rules. */
@SuppressWarnings("removal")
void verifyOrdinaryGuardians(StructureStart start) {
var override = start.getStructure().spawnOverrides().get(MobCategory.MONSTER);
helper.assertTrue(override != null && override.spawns().unwrap().stream()
.anyMatch(entry -> entry.value().type() == EntityTypes.GUARDIAN), "The native monument retains its ordinary guardian spawn override");
var witnesses = new ArrayList<BlockPos>();
for (var pos : BlockPos.betweenClosed(plan.minX() + 2, plan.minY() + 1, plan.minZ() + 2,
plan.maxX() - 2, plan.maxY() - 1, plan.maxZ() - 2)) {
if (!level.getFluidState(pos).is(FluidTags.WATER) || !level.getFluidState(pos.below()).is(FluidTags.WATER)
|| !SpawnPlacements.isSpawnPositionOk(EntityTypes.GUARDIAN, level, pos)
|| !level.noCollision(EntityTypes.GUARDIAN.getSpawnAABB(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5))) continue;
var actual = generator.getMobsAt(level, level.structureManager(), MobCategory.MONSTER, pos);
helper.assertTrue(actual.equals(override.spawns()), "Real monument references select the exact vanilla monster spawn table");
witnesses.add(pos.immutable());
if (witnesses.size() == 32) break;
}
helper.assertTrue(!witnesses.isEmpty(), "Generated monument contains water positions that support ordinary guardian spawning");
var ordinary = new LinkedHashMap<String, Object>(); report.put("ordinary_guardians", ordinary);
ordinary.put("water_witnesses", witnesses.stream().map(p -> List.of(p.getX(), p.getY(), p.getZ())).toList());
ordinary.put("spawn_table", override.spawns().unwrap().stream().map(e -> Map.of(
"type", e.value().type().toShortString(), "weight", e.weight(), "count", e.value().count().toString())).toList());
ordinary.put("scope", "Bounded NaturalSpawner batches at real water positions; native table, spawn rules, collision and player distance. This does not measure the global mob cap or farm throughput.");
var spawned = new ArrayList<Guardian>();
var player = helper.makeMockServerPlayerInLevel();
try {
player.snapTo((plan.minX() + plan.maxX()) / 2.0 + 0.5, plan.maxY() + 64,
(plan.minZ() + plan.maxZ()) / 2.0 + 0.5);
helper.assertTrue(level.getNearestPlayer(player.getX(), player.getY(), player.getZ(), 1, false) == player,
"The native spawner sees its nearby nonspectator test player");
int batches = 0;
while (spawned.isEmpty() && batches < 128) {
var pos = witnesses.get(batches++ % witnesses.size());
NaturalSpawner.spawnCategoryForPosition(MobCategory.MONSTER, level, level.getChunk(pos), pos,
(type, world, candidate, sourceChunk) -> type == EntityTypes.GUARDIAN && start.getBoundingBox().isInside(candidate),
(mob, sourceChunk) -> { if (mob instanceof Guardian guardian && !(mob instanceof ElderGuardian)) spawned.add(guardian); });
}
ordinary.put("batches", batches);
ordinary.put("spawned", spawned.stream().map(g -> Map.of("uuid", g.getUUID().toString(),
"position", List.of(g.getX(), g.getY(), g.getZ()))).toList());
helper.assertTrue(!spawned.isEmpty(), "The native NaturalSpawner really creates at least one ordinary guardian inside the generated monument");
for (var guardian : spawned) helper.assertTrue(guardian.isAlive() && level.getEntity(guardian.getUUID()) == guardian,
"Naturally spawned ordinary guardian is a live server entity");
} finally {
spawned.forEach(Guardian::discard);
level.getServer().getPlayerList().remove(player);
}
}
void verifyLocator(StructureStart start) throws java.io.IOException {
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
var holder = registry.get(Identifier.parse("minecraft:monument")).orElseThrow();
var wanted = registry.getOrThrow(StructureTags.ON_OCEAN_MONUMENT_MAPS);
helper.assertTrue(wanted.contains(holder), "Vanilla ocean explorer map tag includes the monument");
byte[] beforeJournal = Files.readAllBytes(journal);
var beforeSession = List.copyOf(session.islands());
var beforePublished = List.copyOf(generator.source().islands());
int references = start.getReferences();
boolean realReady = session.island(island.id()).status().equals("ready");
BlockPos origin = start.getPieces().getFirst().getLocatorPosition();
var found = generator.findNearestMapStructure(level, wanted, origin, 1, false);
if (realReady) helper.assertTrue(found != null && found.getSecond().equals(holder) && found.getFirst().equals(origin),
"Native explorer-map tag locates the actual persisted monument when its expansion is READY");
else helper.assertTrue(found == null, "Native explorer-map tag cannot advertise an unfinished expansion");
helper.assertTrue(start.getReferences() == references, "Ordinary tag search does not mark a map reference");
if (!reload) {
var firstMap = generator.findNearestMapStructure(level, wanted, origin, 1, true);
if (realReady) {
helper.assertTrue(firstMap != null && firstMap.getSecond().equals(holder) && firstMap.getFirst().equals(origin)
&& start.getReferences() == references + 1, "Explorer-map tag references only its selected monument once");
var startChunk = level.getChunk(start.getChunkPos().x(), start.getChunkPos().z(), ChunkStatus.STRUCTURE_STARTS, true);
// The native read-only proto wrapper always reports isUnsaved=false,
// although markUnsaved forwards to the real LevelChunk.
var savedChunk = startChunk instanceof net.minecraft.world.level.chunk.ImposterProtoChunk wrapper
? wrapper.getWrapped() : startChunk;
report.put("referenced_start_chunk_view", startChunk.getClass().getSimpleName());
report.put("referenced_start_chunk_storage", savedChunk.getClass().getSimpleName());
helper.assertTrue(savedChunk.isUnsaved(), "The selected start chunk must save its new explorer-map reference even after READY");
report.put("referenced_start_chunk_unsaved", true);
var anotherMap = generator.findNearestMapStructure(level, wanted, origin, 1, true);
helper.assertTrue(anotherMap == null || !anotherMap.getFirst().equals(origin), "skipReferenced never selects the same already referenced monument");
helper.assertTrue(start.getReferences() == references + 1, "Searching for a second map leaves the first monument reference unchanged");
} else helper.assertTrue(firstMap == null && start.getReferences() == references, "An unfinished expansion is neither located nor referenced");
}
var outside = new BlockPos(-20_000_000, 200, -20_000_000);
helper.assertTrue(generator.findNearestMapStructure(level, wanted, outside, 1, false) == null,
"Searching beyond every admitted island cannot create an ocean or monument");
helper.assertTrue(Arrays.equals(beforeJournal, Files.readAllBytes(journal)) && beforeSession.equals(session.islands())
&& beforePublished.equals(generator.source().islands()), "Tag, explorer map and unsuccessful remote searches never create or activate an expansion");
report.put("locator_on_real_ready", realReady); report.put("references", start.getReferences());
report.put("locator", Map.of("tag", StructureTags.ON_OCEAN_MONUMENT_MAPS.location().toString(),
"ready", realReady, "expected_position", List.of(origin.getX(), origin.getY(), origin.getZ()),
"tag_search_verified", true, "remote_search_empty", true, "journal_unchanged", true, "islands_unchanged", true));
}
void release() { if (tickets) chunks.forEach(pos -> ChunkTickets25.remove(level, pos)); tickets = false; }
void write(String name, String json) throws java.io.IOException { Path out = Path.of("diagnostics", "monument25"); Files.createDirectories(out); Files.writeString(out.resolve(name + ".json"), json); }
void fail(Exception error) {
if (tickets) recordReadiness();
release(); report.put("passed", false); report.put("failure", error.toString()); report.put("progress", session.progress());
var stack = new java.io.StringWriter(); error.printStackTrace(new java.io.PrintWriter(stack)); report.put("stacktrace", stack.toString());
try { write("failure", new GsonBuilder().setPrettyPrinting().create().toJson(report)); } catch (Exception ignored) {}
throw new IllegalStateException("Monument25 native lifecycle failed", error);
}
}
}
@@ -0,0 +1,17 @@
package fr.koka.sanctuary.gametest;
/** Development-only counters around the actual vanilla fluid scheduler, never additional ticks. */
public final class FluidTiming25 {
private static long nanos, maximum, calls, executed;
private static int pendingPeak;
private FluidTiming25() {}
public static synchronized void record(long elapsed, long count, int pending) {
nanos += elapsed; maximum = Math.max(maximum, elapsed); calls++; executed += count;
pendingPeak = Math.max(pendingPeak, pending);
}
public static synchronized java.util.Map<String, Object> snapshot() {
return java.util.Map.of("scheduler_calls", calls, "executed_fluid_ticks", executed,
"scheduler_total_ms", nanos / 1_000_000.0, "scheduler_max_ms", maximum / 1_000_000.0,
"pending_peak", pendingPeak, "scope", "Actual vanilla Overworld fluid scheduler, development instrumentation only");
}
}
@@ -0,0 +1,452 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
import fr.koka.sanctuary.worldgen.heritage.HeritagePiece19;
import fr.koka.sanctuary.worldgen.heritage.HeritagePlan19;
import fr.koka.sanctuary.worldgen.heritage.HeritageRenderer19;
import fr.koka.sanctuary.worldgen.heritage.HeritageStructures19;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.RegistryOps;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.RandomSource;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.DoorBlock;
import net.minecraft.world.level.block.TrapDoorBlock;
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.LevelResource;
import net.minecraft.world.level.storage.loot.LootParams;
import net.minecraft.world.level.storage.loot.LootTable;
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
/** Native creation/reload proof confined to a disposable generation19 starter island. */
public final class Heritage19WorldGameTests {
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
@GameTest(maxTicks = 12000)
public void heritagePersistsWithRealDestinations(GameTestHelper helper) {
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
"The heritage proof requires a Sanctuary generator");
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 19, "Use a fresh generation19 test world; do not convert an old save");
new Scenario(helper, generator, Boolean.getBoolean("sanctuary.test.expansionReload")).advance();
}
private static final class Scenario {
final GameTestHelper helper;
final ServerLevel level;
final SanctuaryChunkGenerator generator;
final HeritagePlan19 plan;
final boolean reload;
final Map<String, Object> report = new LinkedHashMap<>();
final List<ChunkPos> chunks = new ArrayList<>();
final List<StructureStart> starts = new ArrayList<>();
JsonObject previous;
BlockPos gold;
int nextChunk;
boolean prepared;
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator, boolean reload) {
this.helper = helper; this.level = helper.getLevel(); this.generator = generator; this.reload = reload;
this.plan = generator.heritagePlan19();
report.put("seed", level.getSeed()); report.put("size", generator.initialDiameter());
report.put("process_id", ProcessHandle.current().pid()); report.put("world_directory", world(level).toString());
report.put("experimental_structures", generator.experimentalStructures());
report.put("structures_enabled", level.structureManager().shouldGenerateStructures());
report.put("scope", "Native local starts, saved sites and routes, player-sized entrances, supports and unopened chests on the starter island. "
+ "All14 tool tables are sampled separately without consuming a saved chest. Door states are restored after traversal. "
+ "No personal save or expansion is opened; one disposable outer-wall edit is checked in a second JVM. "
+ "Missing rails are intentional; this proof checks walking and saved rail shapes, not an uninterrupted minecart journey.");
}
void advance() {
try {
if (!prepared) { report.put("stage", "prepare"); prepare(); prepared = true; if (plan.isEmpty()) { finishDisabled(); return; } }
if (nextChunk < chunks.size()) {
report.put("stage", "load_full_chunks"); var pos = chunks.get(nextChunk++);
helper.assertTrue(level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true) instanceof LevelChunk,
"Heritage footprint must reach native FULL: " + pos);
if (nextChunk % 20 == 0) SanctuaryMod.LOGGER.info("Heritage19 loaded {}/{} footprint chunks", nextChunk, chunks.size());
helper.runAtTickTime(helper.getTick() + 1, this::advance); return;
}
report.put("stage", "verify_native_geometry"); verifyPlaced(); report.put("passed", true);
if (!reload) {
helper.assertTrue(gold != null && level.getBlockState(gold).isCollisionShapeFullBlock(level, gold), "Gold witness must replace a solid wall away from circulation");
level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3); report.put("gold_witness", coords(gold));
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native starts, unopened loot and the wall edit");
report.put("world_flushed_before_shutdown", true);
} else {
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Reload replaced the player's wall edit");
report.put("gold_witness", coords(gold)); report.put("gold_witness_preserved", true);
}
write(level, report, reload); helper.succeed();
} catch (Exception failure) {
report.put("passed", false); report.put("failure", failure.toString());
try { write(level, report, reload); } catch (IOException ignored) { }
throw new IllegalStateException("Heritage19 native " + (reload ? "reload" : "creation") + " failed", failure);
}
}
void prepare() throws IOException {
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
var encoded = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow();
var decoded = ChunkGenerator.CODEC.parse(ops, encoded).getOrThrow();
helper.assertTrue(decoded instanceof SanctuaryChunkGenerator copy && copy.rootGeneration() == 19
&& copy.experimentalStructures() == generator.experimentalStructures(), "Generation19 codec lost its experimental switch");
report.put("generator_codec", encoded); report.put("plan_sha256", planHash(plan));
report.put("planned_sites", plan.sites().stream().map(site -> Map.of("id", site.id(), "kind", site.kind().name(),
"entry", coords(HeritagePiece19.locator(site)), "floor_y", site.floorY(), "foundation_depth", site.foundationDepth(), "footings", site.supports().size())).toList());
report.put("planned_links", plan.links().stream().map(link -> Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(), "path", pathCoords(link))).toList());
report.put("planning_samples", plan.samples()); report.put("planning_columns", plan.columns()); report.put("planning_diagnostics", plan.diagnostics());
helper.assertTrue(plan.samples() <= HeritagePlan19.MAX_SAMPLES && plan.columns() <= HeritagePlan19.MAX_COLUMNS, "Heritage planning exceeded its budget");
verifyLootTables(helper, report);
if (reload) {
previous = JsonParser.parseString(Files.readString(proof(level))).getAsJsonObject();
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(), "Reload needs a successful flushed creation proof");
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must use a second JVM");
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed() && previous.get("size").getAsInt() == generator.initialDiameter()
&& previous.get("world_directory").getAsString().equals(world(level).toString()), "Reload selected another world");
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("plan_sha256").getAsString().equals(planHash(plan)), "Saved settings or planned identities changed on reload");
if (previous.has("gold_witness")) gold = readPos(previous.getAsJsonArray("gold_witness"));
}
if (!generator.experimentalStructures() || !level.structureManager().shouldGenerateStructures()) {
helper.assertTrue(plan.isEmpty() && plan.segments().isEmpty() && plan.links().isEmpty(), "Disabled generation published heritage geometry"); return;
}
helper.assertTrue(!plan.isEmpty(), "Enabled reference island has no heritage site");
report.put("present_site_kinds", plan.sites().stream().map(s -> s.kind().name()).distinct().sorted().toList());
report.put("missing_site_kinds", java.util.Arrays.stream(HeritagePlan19.Kind.values()).filter(k -> plan.sites().stream().noneMatch(s -> s.kind() == k)).map(Enum::name).toList());
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
var origins = new HashSet<ChunkPos>(); var uniqueChunks = new HashSet<ChunkPos>();
plan.sites().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
plan.segments().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
for (var origin : origins.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList()) {
var chunk = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true);
var start = chunk.getStartForStructure(structure.value());
helper.assertTrue(start != null && start.isValid(), "Accepted site/route has no native start at " + origin); starts.add(start);
for (var piece : start.getPieces()) {
helper.assertTrue(piece instanceof HeritagePiece19, "Heritage loaded a foreign piece type");
var box = piece.getBoundingBox();
helper.assertTrue(Math.abs((long) (box.minX() >> 4) - origin.x()) <= 7 && Math.abs((long) (box.maxX() >> 4) - origin.x()) <= 7
&& Math.abs((long) (box.minZ() >> 4) - origin.z()) <= 7 && Math.abs((long) (box.maxZ() >> 4) - origin.z()) <= 7, "Native start references a distant route endpoint");
box.intersectingChunks().forEach(uniqueChunks::add);
}
}
for (var site : plan.sites()) uniqueChunks.add(ChunkPos.containing(HeritagePiece19.locator(site)));
for (var link : plan.links()) for (var point : link.path()) uniqueChunks.add(new ChunkPos(point.x() >> 4, point.z() >> 4));
helper.assertTrue(uniqueChunks.size() <= 1024, "Bounded proof footprint exceeds1024 chunks");
chunks.addAll(uniqueChunks.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList());
}
void finishDisabled() throws IOException {
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
var start = level.getChunk(0, 0, ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(structure.value());
helper.assertTrue(start == null || !start.isValid(), "Disabled heritage emitted a native start");
helper.assertTrue(generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false) == null, "Disabled heritage is locatable");
report.put("empty_plan_verified", true); report.put("passed", true);
if (!reload) { helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save disabled experiment setting"); report.put("world_flushed_before_shutdown", true); }
write(level, report, reload); helper.succeed();
}
void verifyPlaced() {
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
var context = StructurePieceSerializationContext.fromLevel(level); var summaries = new ArrayList<Map<String, Object>>();
int blocks = 0, chests = 0, pieces = 0, supports = 0;
for (var start : starts) {
var origin = start.getChunkPos(); var saved = start.createTag(context, origin); String nativeHash = hash(saved.toString());
var copy = StructureStart.loadStaticStart(context, saved, level.getSeed());
helper.assertTrue(copy != null && copy.isValid() && copy.createTag(context, origin).equals(saved), "Native start failed an exact NBT roundtrip");
var expectedPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
plan.sites().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece19.encodeSite(s)));
plan.segments().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece19.encodeSegment(s)));
var actualPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
if (reload) {
JsonObject prior = null;
for (var value : previous.getAsJsonArray("starts")) { var item = value.getAsJsonObject(); var at = item.getAsJsonArray("start_chunk"); if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = item; }
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(nativeHash), "Reload changed saved geometry or native reference count");
}
var loot = new ArrayList<Map<String, Object>>();
for (var value : start.getPieces()) {
var piece = (HeritagePiece19) value; pieces++;
var tag = piece.createTag(context); var restored = new HeritagePiece19(context, tag);
helper.assertTrue(restored.createTag(context).equals(tag), "Saved piece parameters changed on native load");
actualPayloads.add(tag.getCompound("SanctuaryHeritage").orElseThrow());
var corrupt = tag.copy(); corrupt.getCompound("SanctuaryHeritage").orElseThrow().putInt("Schema", 20);
boolean rejected = false; try { new HeritagePiece19(context, corrupt); } catch (IllegalArgumentException expected) { rejected = true; }
helper.assertTrue(rejected, "Unknown schema must fail closed");
piece.getBoundingBox().intersectingChunks().forEach(at -> helper.assertTrue(level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true)
.getReferencesForStructure(structure.value()).contains(origin.pack()), "FULL chunk lost its native structure reference"));
var expected = rendered(piece); blocks += expected.blocks().size();
for (var entry : expected.blocks().entrySet()) {
var at = entry.getKey(); var wanted = entry.getValue(); helper.assertTrue(piece.getBoundingBox().isInside(at), "Renderer escaped saved bounds");
if (reload && at.equals(gold)) continue;
helper.assertTrue(level.getBlockState(at).is(wanted.getBlock()), "Clipping or decoration changed heritage at " + at + ": expected " + wanted + " got " + level.getBlockState(at));
helper.assertTrue(!wanted.is(Blocks.SPAWNER), "Heritage must not create spawners");
}
if (piece.isSite()) {
supports += verifySiteBearing(helper, generator, plan, piece.site());
if (!reload && gold == null) {
gold = expected.blocks().entrySet().stream().filter(e -> isOuterWall(piece.site(), e.getKey()) && e.getValue().isCollisionShapeFullBlock(level, e.getKey()))
.map(Map.Entry::getKey).sorted(Comparator.<BlockPos>comparingInt(p -> p.getY()).thenComparingInt(p -> p.getX()).thenComparingInt(p -> p.getZ())).findFirst().orElse(null);
}
} else for (var cell : piece.segment().cells()) { supports++; assertRock(helper, new BlockPos(cell.x(), cell.minY() - 1, cell.z())); }
for (var chest : expected.chests()) {
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Deferred chest is missing");
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
helper.assertTrue(entity.getLootTable() != null && entity.getLootTable().equals(chest.table()) && entity.getLootTableSeed() == chest.seed(), "Deferred loot table/seed was lost or consumed");
if (piece.isSite()) helper.assertTrue(chest.table().equals(lootKey(piece.site().kind())), "Site uses another kind's tool table");
loot.add(Map.of("position", coords(chest.position()), "table", chest.table().identifier().toString(), "seed", chest.seed())); chests++;
}
}
helper.assertTrue(actualPayloads.equals(expectedPayloads) && start.getPieces().size() == expectedPayloads.size(), "Native start lost or duplicated planned pieces");
summaries.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", nativeHash, "pieces", start.getPieces().size(), "unopened_chests", loot));
}
report.put("starts", summaries); report.put("native_pieces", pieces); report.put("native_blocks_checked", blocks);
report.put("full_chunks_checked", chunks.size()); report.put("rock_supports_checked", supports); report.put("unopened_deferred_chests", chests);
report.put("stage", "verify_site_routes"); var sites = new ArrayList<Map<String, Object>>(); for (var site : plan.sites()) sites.add(verifyAccess(helper, site)); report.put("native_sites", sites);
report.put("stage", "verify_read_only_landings");
report.put("read_only_landmarks", verifyReadOnlyLandings(helper, generator, plan));
report.put("stage", "verify_island_routes"); report.put("native_links", verifyLinks(helper, plan));
int greatestSpan = plan.links().stream().mapToInt(Heritage19WorldGameTests::routeSpan).max().orElse(0);
report.put("greatest_single_route_span", greatestSpan);
if (level.getSeed() == 0 && generator.initialDiameter() == 724) {
int requiredSpan = generator.initialDiameter() / 4;
report.put("reference_minimum_route_span", requiredSpan);
helper.assertTrue(greatestSpan >= requiredSpan, "Seed0 Medium must demonstrate an island-scale route spanning at least "
+ requiredSpan + " blocks on X or Z; got " + greatestSpan);
}
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
helper.assertTrue(located != null && located.getSecond().is(HeritageStructures19.KEY) && plan.sites().stream().anyMatch(s -> HeritagePiece19.locator(s).equals(located.getFirst())), "Locate must lead to a real exterior site entry");
report.put("locate", coords(located.getFirst()));
}
}
private record Chest(BlockPos position, ResourceKey<LootTable> table, long seed) {}
private record Rendered(Map<BlockPos, BlockState> blocks, List<Chest> chests) {}
private static Rendered rendered(HeritagePiece19 piece) {
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
int x0 = piece.isSite() ? piece.site().minX() : piece.segment().minX();
int y0 = piece.isSite() ? piece.site().floorY() : piece.segment().minY();
int z0 = piece.isSite() ? piece.site().minZ() : piece.segment().minZ();
var writer = new LostCityRenderer.BlockWriter() {
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(new BlockPos(x0 + x, y0 + y, z0 + z), state); }
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
var at = new BlockPos(x0 + x, y0 + y, z0 + z); blocks.put(at, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(at, table, RandomSource.create(seed).nextLong()));
}
};
if (piece.isSite()) HeritageRenderer19.render(piece.site(), writer); else HeritageRenderer19.renderSegment(piece.segment(), writer);
return new Rendered(blocks, chests);
}
private static boolean isOuterWall(HeritagePlan19.Site site, BlockPos pos) {
return pos.getY() >= site.floorY() + 2 && pos.getY() <= site.floorY() + 3
&& (pos.getX() == site.minX() + 2 || pos.getX() == site.maxX() - 2 || pos.getZ() == site.minZ() + 2 || pos.getZ() == site.maxZ() - 2)
&& Math.abs(pos.getX() - site.entry().x()) + Math.abs(pos.getZ() - site.entry().z()) > 6;
}
private static Map<String, Object> verifyAccess(GameTestHelper helper, HeritagePlan19.Site site) {
var level = helper.getLevel(); var expected = rendered(new HeritagePiece19(site)); var doors = new HashMap<BlockPos, BlockState>();
expected.blocks().forEach((at, state) -> {
if ((state.getBlock() instanceof DoorBlock || state.getBlock() instanceof TrapDoorBlock) && state.hasProperty(BlockStateProperties.OPEN)) doors.put(at, level.getBlockState(at));
});
doors.forEach((at, state) -> level.setBlock(at, state.setValue(BlockStateProperties.OPEN, true), 2));
try {
var entry = HeritagePiece19.locator(site);
Predicate<BlockPos> inside = p -> p.equals(entry) || p.getX() >= site.minX() && p.getX() <= site.maxX()
&& p.getZ() >= site.minZ() && p.getZ() <= site.maxZ() && p.getY() >= site.floorY() - 4 && p.getY() < site.maxY();
var reached = walk(helper, entry, inside); int targets = 0;
for (var target : HeritageRenderer19.targets(site)) {
var at = new BlockPos(site.minX() + target.x(), site.floorY() + target.y(), site.minZ() + target.z());
helper.assertTrue(reached.contains(at), "Site " + site.id() + " cannot reach " + target.name() + " at " + at); targets++;
}
return Map.of("id", site.id(), "kind", site.kind().name(), "entry", coords(entry), "standing_cells", reached.size(), "walkable_targets", targets);
} finally { doors.forEach((at, state) -> level.setBlock(at, state, 2)); }
}
private static List<Map<String, Object>> verifyLinks(GameTestHelper helper, HeritagePlan19 plan) {
helper.assertTrue(!plan.links().isEmpty(), "Reference island needs a real route between accepted destinations");
var entries = new HashMap<String, HeritagePlan19.Entry>(); plan.sites().forEach(s -> entries.put(s.id(), s.entry())); plan.landmarks().forEach(s -> entries.put(s.id(), s.entry()));
var cells = new HashMap<Long, HeritagePlan19.Cell>(); plan.segments().forEach(s -> s.cells().forEach(c -> cells.put(column(c.x(), c.z()), c)));
var result = new ArrayList<Map<String, Object>>();
for (var link : plan.links()) {
var from = entries.get(link.fromId()); var to = entries.get(link.toId());
helper.assertTrue(from != null && to != null, "Route points to an absent destination");
var first = link.path().getFirst(); var last = link.path().getLast();
helper.assertTrue(first.x() == from.x() && first.z() == from.z() && first.floorY() + 1 == from.y()
&& last.x() == to.x() && last.z() == to.z() && last.floorY() + 1 == to.y(), "Route does not meet its real entrances");
int present = 0, missing = 0;
for (int i = 0; i < link.path().size(); i++) {
var point = link.path().get(i); var at = new BlockPos(point.x(), point.floorY() + 1, point.z());
helper.assertTrue(standable(helper.getLevel(), at), "Ballast route " + link.id() + " is blocked at " + at);
if (i > 0) { var prior = link.path().get(i - 1); if (point.floorY() > prior.floorY()) helper.assertTrue(clearBody(helper.getLevel(), new BlockPos(prior.x(), prior.floorY() + 2, prior.z())), "Route slope cannot be climbed by a player"); }
var cell = cells.get(column(point.x(), point.z()));
if (cell != null && cell.rail() != HeritagePlan19.Rail.NONE) {
var actual = helper.getLevel().getBlockState(at);
if (cell.railPresent()) {
helper.assertTrue(actual.is(Blocks.RAIL), "Saved existing rail disappeared");
helper.assertTrue(actual.getValue(BlockStateProperties.RAIL_SHAPE).name().equals(cell.rail().name()), "Native rail update changed the intended slope or curve at " + at); present++;
} else { helper.assertTrue(!actual.is(Blocks.RAIL), "An intentionally missing rail regenerated"); missing++; }
}
}
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
result.add(Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(),
"span_x", spanX, "span_z", spanZ, "existing_rails", present, "deliberately_missing_rails", missing, "path", pathCoords(link)));
}
return result;
}
private static int routeSpan(HeritagePlan19.Link link) {
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow()
- link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow()
- link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
return Math.max(spanX, spanZ);
}
private static List<List<Integer>> pathCoords(HeritagePlan19.Link link) {
return link.path().stream().map(point -> List.of(point.x(), point.floorY(), point.z())).toList();
}
private static int verifySiteBearing(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan, HeritagePlan19.Site site) {
int checked = 0;
// The planner certifies a nine-point bearing grid, either directly below its slab or below a saved short footing.
for (int dx : new int[] {1, 8, 15}) for (int dz : new int[] {1, 8, 15}) {
int x = site.minX() + dx, z = site.minZ() + dz;
var support = site.supports().stream().filter(s -> s.x() == x && s.z() == z).findFirst();
int bottom = support.map(HeritagePlan19.Support::minY).orElse(site.minY());
for (int below = 1; below <= 2; below++) {
var at = new BlockPos(x, bottom - below, z); assertRock(helper, at);
helper.assertTrue(plan.protects(at.getX(), at.getY(), at.getZ()) && generator.protectsCityDecoration(at),
"Site's certified bearing layer is not published in the decoration mask: " + site.id() + " at " + at);
helper.assertTrue(!plan.contains(at.getX(), at.getY(), at.getZ()), "Original bearing rock became a writable heritage volume: " + at);
checked++;
}
}
return checked;
}
private static List<Map<String, Object>> verifyReadOnlyLandings(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan) {
var used = new HashSet<String>(); plan.links().forEach(link -> { used.add(link.fromId()); used.add(link.toId()); });
var result = new ArrayList<Map<String, Object>>();
for (var landmark : plan.landmarks()) if (used.contains(landmark.id())) {
var e = landmark.entry(); int checked = 0;
for (int side = -1; side <= 1; side++) {
int x = e.x() + ((e.facing() & 1) == 0 ? side : 0), z = e.z() + ((e.facing() & 1) == 1 ? side : 0);
for (int y = e.y() - 2; y <= e.y() + 2; y++) {
var at = new BlockPos(x, y, z);
helper.assertTrue(plan.protects(x, y, z) && generator.protectsCityDecoration(at), "Read-only arrival lost its decoration mask: " + landmark.id() + " at " + at);
helper.assertTrue(!plan.contains(x, y, z), "Heritage gained write ownership of a historical arrival: " + landmark.id() + " at " + at);
if (y == e.y() - 1) assertRock(helper, at);
if (y >= e.y()) helper.assertTrue(helper.getLevel().getBlockState(at).isAir(), "Decoration obstructed a certified historical arrival: " + at);
checked++;
}
}
result.add(Map.of("id", landmark.id(), "entry", List.of(e.x(), e.y(), e.z()), "read_only_cells", checked));
}
return result;
}
private static void assertRock(GameTestHelper helper, BlockPos at) {
helper.assertTrue(helper.getLevel().getBlockState(at).isSolid() && helper.getLevel().getFluidState(at).isEmpty(), "Lost original rock bearing at " + at);
}
private static void verifyLootTables(GameTestHelper helper, Map<String, Object> report) {
var result = new ArrayList<Map<String, Object>>(); var level = helper.getLevel();
var params = new LootParams.Builder(level).withParameter(LootContextParams.ORIGIN, new Vec3(0, 160, 0)).create(LootContextParamSets.CHEST);
for (var kind : HeritagePlan19.Kind.values()) {
var table = level.getServer().reloadableRegistries().getLootTable(lootKey(kind));
helper.assertTrue(table != LootTable.EMPTY, "Heritage tool table did not load: " + kind);
for (long seed : new long[] {0, 42, -1}) helper.assertTrue(table.getRandomItems(params, seed).stream().anyMatch(stack -> stack.is(tool(kind))), "Tool table is empty or lacks its expected primary tool: " + kind);
result.add(Map.of("kind", kind.name(), "table", lootKey(kind).identifier().toString(), "sampled_seeds", List.of(0, 42, -1)));
}
report.put("tool_tables_sampled_separately", result);
}
private static ResourceKey<LootTable> lootKey(HeritagePlan19.Kind kind) { return ResourceKey.create(Registries.LOOT_TABLE, SanctuaryMod.id("chests/heritage19/" + kind.name().toLowerCase(Locale.ROOT))); }
private static Item tool(HeritagePlan19.Kind kind) {
return switch (kind) {
case OBSERVATORY -> Items.SPYGLASS;
case POST_OFFICE, EXPEDITION_OFFICE, RADIO_RELAY -> Items.COMPASS;
case SUBSTATION, IMPOSSIBLE_SERVICE -> Items.CLOCK;
case FREIGHT_STATION -> Items.MINECART;
case COMMUNAL_DEPOT -> Items.IRON_AXE;
case RAIL_WORKSHOP -> Items.IRON_PICKAXE;
case PUMP_STATION -> Items.BUCKET;
case GREENHOUSE -> Items.IRON_HOE;
case VOID_DOCK -> Items.LEAD;
case PHOTO_CLUB -> Items.BOOK;
case CONSTRUCTION_REFUGE -> Items.IRON_SHOVEL;
};
}
private static Set<BlockPos> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside) {
var level = helper.getLevel(); helper.assertTrue(inside.test(start) && standable(level, start), "Blocked or unsupported exterior entrance: " + start);
var seen = new HashSet<BlockPos>(); var queue = new ArrayDeque<BlockPos>(); seen.add(start); queue.add(start);
while (!queue.isEmpty()) {
var pos = queue.removeFirst();
for (var direction : HORIZONTAL) for (int dy = -1; dy <= 1; dy++) {
var next = pos.relative(direction).offset(0, dy, 0);
if (!inside.test(next) || dy > 0 && !clearBody(level, pos.above())) continue;
if (standable(level, next) && seen.add(next)) queue.add(next);
}
for (int dy : new int[] {-1, 1}) {
var next = pos.offset(0, dy, 0); if (!inside.test(next)) continue;
if ((level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(next).is(Blocks.LADDER) || level.getBlockState(pos.below()).is(Blocks.LADDER))
&& standable(level, next) && seen.add(next)) queue.add(next);
}
helper.assertTrue(seen.size() <= 50_000, "Native site traversal escaped its bounds");
}
return seen;
}
private static boolean standable(ServerLevel level, BlockPos pos) {
if (!clearBody(level, pos)) return false;
if (level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(pos.below()).is(Blocks.LADDER)) return true;
var below = pos.below(); return level.getBlockState(below).getCollisionShape(level, below).toAabbs().stream()
.anyMatch(b -> b.maxY >= .5 && b.minX <= .5 && b.maxX >= .5 && b.minZ <= .5 && b.maxZ >= .5);
}
private static boolean clearBody(ServerLevel level, BlockPos pos) {
var body = new AABB(pos.getX() + .2, pos.getY(), pos.getZ() + .2, pos.getX() + .8, pos.getY() + 1.8, pos.getZ() + .8);
for (var at : new BlockPos[] {pos, pos.above()}) {
if (!level.getFluidState(at).isEmpty()) return false;
for (var shape : level.getBlockState(at).getCollisionShape(level, at).toAabbs()) if (shape.move(at.getX(), at.getY(), at.getZ()).intersects(body)) return false;
}
return true;
}
private static long column(int x, int z) { return (long) x << 32 ^ z & 0xffffffffL; }
private static List<Integer> coords(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
private static BlockPos readPos(com.google.gson.JsonArray value) { return new BlockPos(value.get(0).getAsInt(), value.get(1).getAsInt(), value.get(2).getAsInt()); }
private static Path world(ServerLevel level) { return level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); }
private static Path proof(ServerLevel level) { return world(level).resolve("heritage19-create-proof.json"); }
private static String planHash(HeritagePlan19 plan) { return hash(plan.sites().toString() + plan.landmarks() + plan.links() + plan.segments()); }
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
var path = Path.of("diagnostics", "heritage19-" + (reload ? "reload" : "create") + ".json"); Files.createDirectories(path.getParent());
var value = new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n"; Files.writeString(path, value); if (!reload) Files.writeString(proof(level), value);
}
private static String hash(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
}
}
@@ -0,0 +1,556 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
import fr.koka.sanctuary.worldgen.heritage.HeritagePiece20;
import fr.koka.sanctuary.worldgen.heritage.HeritagePlan19;
import fr.koka.sanctuary.worldgen.heritage.HeritageRenderer20;
import fr.koka.sanctuary.worldgen.heritage.HeritageStructures19;
import fr.koka.sanctuary.worldgen.ruins.RuinsPiece20;
import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17;
import fr.koka.sanctuary.worldgen.ruins.RuinsRenderer20;
import fr.koka.sanctuary.worldgen.ruins.RuinsStructures17;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.RegistryOps;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.RandomSource;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.DoorBlock;
import net.minecraft.world.level.block.TrapDoorBlock;
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.LevelResource;
import net.minecraft.world.level.storage.loot.LootParams;
import net.minecraft.world.level.storage.loot.LootTable;
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
/** Native creation/reload proof confined to a disposable generation20 starter island. */
public final class Heritage20WorldGameTests {
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
@GameTest(maxTicks = 12000)
public void heritagePersistsWithRealDestinations(GameTestHelper helper) {
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
"The heritage proof requires a Sanctuary generator");
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 20, "Use a fresh generation20 test world; do not convert an old save");
new Scenario(helper, generator, Boolean.getBoolean("sanctuary.test.expansionReload")).advance();
}
private static final class Scenario {
final GameTestHelper helper;
final ServerLevel level;
final SanctuaryChunkGenerator generator;
final HeritagePlan19 plan;
final boolean reload;
final Map<String, Object> report = new LinkedHashMap<>();
final List<ChunkPos> chunks = new ArrayList<>();
final List<StructureStart> starts = new ArrayList<>();
final List<StructureStart> ruinsStarts = new ArrayList<>();
JsonObject previous;
BlockPos gold;
int nextChunk;
boolean prepared;
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator, boolean reload) {
this.helper = helper; this.level = helper.getLevel(); this.generator = generator; this.reload = reload;
this.plan = generator.heritagePlan19();
report.put("seed", level.getSeed()); report.put("size", generator.initialDiameter());
report.put("process_id", ProcessHandle.current().pid()); report.put("world_directory", world(level).toString());
report.put("experimental_structures", generator.experimentalStructures());
report.put("structures_enabled", level.structureManager().shouldGenerateStructures());
report.put("scope", "Native local starts, saved sites and routes, player-sized entrances, supports and unopened chests on the starter island. "
+ "All14 tool tables are sampled separately without consuming a saved chest. Door states are restored after traversal. "
+ "No personal save or expansion is opened; one disposable outer-wall edit is checked in a second JVM. "
+ "Missing rails are intentional; this proof checks walking and saved rail shapes, not an uninterrupted minecart journey.");
}
void advance() {
try {
if (!prepared) { report.put("stage", "prepare"); prepare(); prepared = true; if (plan.isEmpty()) { finishDisabled(); return; } }
if (nextChunk < chunks.size()) {
report.put("stage", "load_full_chunks"); var pos = chunks.get(nextChunk++);
helper.assertTrue(level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true) instanceof LevelChunk,
"Heritage footprint must reach native FULL: " + pos);
if (nextChunk % 20 == 0) SanctuaryMod.LOGGER.info("Heritage20 loaded {}/{} footprint chunks", nextChunk, chunks.size());
helper.runAtTickTime(helper.getTick() + 1, this::advance); return;
}
report.put("stage", "verify_native_geometry"); verifyPlaced(); report.put("passed", true);
if (!reload) {
helper.assertTrue(gold != null && level.getBlockState(gold).isCollisionShapeFullBlock(level, gold), "Gold witness must replace a solid wall away from circulation");
level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3); report.put("gold_witness", coords(gold));
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native starts, unopened loot and the wall edit");
report.put("world_flushed_before_shutdown", true);
} else {
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Reload replaced the player's wall edit");
report.put("gold_witness", coords(gold)); report.put("gold_witness_preserved", true);
}
write(level, report, reload); helper.succeed();
} catch (Exception failure) {
report.put("passed", false); report.put("failure", failure.toString());
try { write(level, report, reload); } catch (IOException ignored) { }
throw new IllegalStateException("Heritage20 native " + (reload ? "reload" : "creation") + " failed", failure);
}
}
void prepare() throws IOException {
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
var encoded = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow();
var decoded = ChunkGenerator.CODEC.parse(ops, encoded).getOrThrow();
helper.assertTrue(decoded instanceof SanctuaryChunkGenerator copy && copy.rootGeneration() == 20
&& copy.experimentalStructures() == generator.experimentalStructures(), "Generation20 codec lost its experimental switch");
report.put("generator_codec", encoded); report.put("plan_sha256", planHash(plan));
report.put("planned_sites", plan.sites().stream().map(site -> Map.of("id", site.id(), "kind", site.kind().name(),
"entry", coords(HeritagePiece20.locator(site)), "floor_y", site.floorY(), "foundation_depth", site.foundationDepth(), "footings", site.supports().size())).toList());
report.put("planned_links", plan.links().stream().map(link -> Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(), "path", pathCoords(link))).toList());
report.put("planning_samples", plan.samples()); report.put("planning_columns", plan.columns()); report.put("planning_diagnostics", plan.diagnostics());
helper.assertTrue(plan.samples() <= HeritagePlan19.MAX_SAMPLES && plan.columns() <= HeritagePlan19.MAX_COLUMNS, "Heritage planning exceeded its budget");
verifyLootTables(helper, report);
if (reload) {
previous = JsonParser.parseString(Files.readString(proof(level))).getAsJsonObject();
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(), "Reload needs a successful flushed creation proof");
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must use a second JVM");
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed() && previous.get("size").getAsInt() == generator.initialDiameter()
&& previous.get("world_directory").getAsString().equals(world(level).toString()), "Reload selected another world");
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("plan_sha256").getAsString().equals(planHash(plan)), "Saved settings or planned identities changed on reload");
if (previous.has("gold_witness")) gold = readPos(previous.getAsJsonArray("gold_witness"));
}
if (!generator.experimentalStructures() || !level.structureManager().shouldGenerateStructures()) {
helper.assertTrue(plan.isEmpty() && plan.segments().isEmpty() && plan.links().isEmpty(), "Disabled generation published heritage geometry"); return;
}
helper.assertTrue(!plan.isEmpty(), "Enabled reference island has no heritage site");
report.put("present_site_kinds", plan.sites().stream().map(s -> s.kind().name()).distinct().sorted().toList());
report.put("missing_site_kinds", java.util.Arrays.stream(HeritagePlan19.Kind.values()).filter(k -> plan.sites().stream().noneMatch(s -> s.kind() == k)).map(Enum::name).toList());
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
var origins = new HashSet<ChunkPos>(); var uniqueChunks = new HashSet<ChunkPos>();
plan.sites().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
plan.segments().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
for (var origin : origins.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList()) {
var chunk = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true);
var start = chunk.getStartForStructure(structure.value());
helper.assertTrue(start != null && start.isValid(), "Accepted site/route has no native start at " + origin); starts.add(start);
for (var piece : start.getPieces()) {
helper.assertTrue(piece instanceof HeritagePiece20, "Heritage loaded a foreign piece type");
var box = piece.getBoundingBox();
helper.assertTrue(Math.abs((long) (box.minX() >> 4) - origin.x()) <= 7 && Math.abs((long) (box.maxX() >> 4) - origin.x()) <= 7
&& Math.abs((long) (box.minZ() >> 4) - origin.z()) <= 7 && Math.abs((long) (box.maxZ() >> 4) - origin.z()) <= 7, "Native start references a distant route endpoint");
box.intersectingChunks().forEach(uniqueChunks::add);
}
}
for (var site : plan.sites()) uniqueChunks.add(ChunkPos.containing(HeritagePiece20.locator(site)));
for (var link : plan.links()) for (var point : link.path()) uniqueChunks.add(new ChunkPos(point.x() >> 4, point.z() >> 4));
helper.assertTrue(uniqueChunks.size() <= 1024, "Bounded proof footprint exceeds1024 chunks");
var ruins = generator.ruinsPlan17();
report.put("ruins_plan_sha256", hash(ruins.networks().toString()));
if (reload) helper.assertTrue(previous.get("ruins_plan_sha256").getAsString().equals(hash(ruins.networks().toString())), "Reload changed forgotten room planning");
helper.assertTrue(!ruins.isEmpty(), "Reference20 must demonstrate native experimental room architecture");
var ruinsType = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
var ruinsOrigins = new HashSet<ChunkPos>();
for (var network : ruins.networks()) {
var origin = new ChunkPos(network.startChunkX(), network.startChunkZ()); if (!ruinsOrigins.add(origin)) continue;
var nativeStart = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(ruinsType.value());
helper.assertTrue(nativeStart != null && nativeStart.isValid(), "Missing native20 forgotten network: " + network.id());
ruinsStarts.add(nativeStart);
for (var piece : nativeStart.getPieces()) {
helper.assertTrue(piece instanceof RuinsPiece20, "Generation20 loaded a historical forgotten piece");
piece.getBoundingBox().intersectingChunks().forEach(uniqueChunks::add);
}
}
chunks.addAll(uniqueChunks.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList());
}
void finishDisabled() throws IOException {
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
var start = level.getChunk(0, 0, ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(structure.value());
helper.assertTrue(start == null || !start.isValid(), "Disabled heritage emitted a native start");
helper.assertTrue(generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false) == null, "Disabled heritage is locatable");
report.put("empty_plan_verified", true); report.put("passed", true);
if (!reload) { helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save disabled experiment setting"); report.put("world_flushed_before_shutdown", true); }
write(level, report, reload); helper.succeed();
}
void verifyPlaced() {
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
var context = StructurePieceSerializationContext.fromLevel(level); var summaries = new ArrayList<Map<String, Object>>();
int blocks = 0, chests = 0, pieces = 0, supports = 0;
var connections = NativeConnections20.Stats.empty();
for (var start : starts) {
var origin = start.getChunkPos(); var saved = start.createTag(context, origin); String nativeHash = hash(saved.toString());
var copy = StructureStart.loadStaticStart(context, saved, level.getSeed());
helper.assertTrue(copy != null && copy.isValid() && copy.createTag(context, origin).equals(saved), "Native start failed an exact NBT roundtrip");
var expectedPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
plan.sites().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece20.encodeSite(s)));
plan.segments().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece20.encodeSegment(s)));
var actualPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
if (reload) {
JsonObject prior = null;
for (var value : previous.getAsJsonArray("starts")) { var item = value.getAsJsonObject(); var at = item.getAsJsonArray("start_chunk"); if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = item; }
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(nativeHash), "Reload changed saved geometry or native reference count");
}
var loot = new ArrayList<Map<String, Object>>();
for (var value : start.getPieces()) {
var piece = (HeritagePiece20) value; pieces++;
var tag = piece.createTag(context); var restored = new HeritagePiece20(context, tag);
helper.assertTrue(restored.createTag(context).equals(tag), "Saved piece parameters changed on native load");
actualPayloads.add(tag.getCompound("SanctuaryHeritage").orElseThrow());
var corrupt = tag.copy(); corrupt.getCompound("SanctuaryHeritage").orElseThrow().putInt("Schema", 21);
boolean rejected = false; try { new HeritagePiece20(context, corrupt); } catch (IllegalArgumentException expected) { rejected = true; }
helper.assertTrue(rejected, "Unknown schema must fail closed");
piece.getBoundingBox().intersectingChunks().forEach(at -> helper.assertTrue(level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true)
.getReferencesForStructure(structure.value()).contains(origin.pack()), "FULL chunk lost its native structure reference"));
var expected = rendered(piece); blocks += expected.blocks().size();
for (var entry : expected.blocks().entrySet()) {
var at = entry.getKey(); var wanted = entry.getValue(); helper.assertTrue(piece.getBoundingBox().isInside(at), "Renderer escaped saved bounds");
if (reload && at.equals(gold)) continue;
helper.assertTrue(level.getBlockState(at).is(wanted.getBlock()), "Clipping or decoration changed heritage at " + at + ": expected " + wanted + " got " + level.getBlockState(at));
helper.assertTrue(!wanted.is(Blocks.SPAWNER), "Heritage must not create spawners");
}
connections = connections.add(NativeConnections20.verify(helper, expected.blocks(), reload ? Set.of(gold) : Set.of()));
if (piece.isSite()) {
supports += verifySiteBearing(helper, generator, plan, piece.site());
if (!reload && gold == null) {
gold = expected.blocks().entrySet().stream().filter(e -> isOuterWall(piece.site(), e.getKey()) && e.getValue().isCollisionShapeFullBlock(level, e.getKey()))
.map(Map.Entry::getKey).sorted(Comparator.<BlockPos>comparingInt(p -> p.getY()).thenComparingInt(p -> p.getX()).thenComparingInt(p -> p.getZ())).findFirst().orElse(null);
}
} else for (var cell : piece.segment().cells()) { supports++; assertRock(helper, new BlockPos(cell.x(), cell.minY() - 1, cell.z())); }
for (var chest : expected.chests()) {
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Deferred chest is missing");
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
helper.assertTrue(entity.getLootTable() != null && entity.getLootTable().equals(chest.table()) && entity.getLootTableSeed() == chest.seed(), "Deferred loot table/seed was lost or consumed");
if (piece.isSite()) helper.assertTrue(chest.table().equals(lootKey(piece.site().kind())), "Site uses another kind's tool table");
loot.add(Map.of("position", coords(chest.position()), "table", chest.table().identifier().toString(), "seed", chest.seed())); chests++;
}
}
helper.assertTrue(actualPayloads.equals(expectedPayloads) && start.getPieces().size() == expectedPayloads.size(), "Native start lost or duplicated planned pieces");
summaries.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", nativeHash, "pieces", start.getPieces().size(), "unopened_chests", loot));
}
report.put("connected_blocks", connections);
helper.assertTrue(connections.panes() > 0 && connections.stairs() > 0 && connections.nativeOracles() > 0,
"Reference20 needs real panes/stairs validated by vanilla neighbor rules");
if (level.getSeed() == 0 && generator.initialDiameter() == 724)
helper.assertTrue(connections.crossChunkPaneLinks() > 0, "Reference20 must demonstrate a real pane join across a chunk boundary");
verifyRuins();
report.put("starts", summaries); report.put("native_pieces", pieces); report.put("native_blocks_checked", blocks);
report.put("full_chunks_checked", chunks.size()); report.put("rock_supports_checked", supports); report.put("unopened_deferred_chests", chests);
report.put("stage", "verify_site_routes"); var sites = new ArrayList<Map<String, Object>>(); for (var site : plan.sites()) sites.add(verifyAccess(helper, site)); report.put("native_sites", sites);
report.put("stage", "verify_read_only_landings");
report.put("read_only_landmarks", verifyReadOnlyLandings(helper, generator, plan));
report.put("stage", "verify_island_routes"); report.put("native_links", verifyLinks(helper, plan));
int greatestSpan = plan.links().stream().mapToInt(Heritage20WorldGameTests::routeSpan).max().orElse(0);
report.put("greatest_single_route_span", greatestSpan);
if (level.getSeed() == 0 && generator.initialDiameter() == 724) {
int requiredSpan = generator.initialDiameter() / 4;
report.put("reference_minimum_route_span", requiredSpan);
helper.assertTrue(greatestSpan >= requiredSpan, "Seed0 Medium must demonstrate an island-scale route spanning at least "
+ requiredSpan + " blocks on X or Z; got " + greatestSpan);
}
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
helper.assertTrue(located != null && located.getSecond().is(HeritageStructures19.KEY) && plan.sites().stream().anyMatch(s -> HeritagePiece20.locator(s).equals(located.getFirst())), "Locate must lead to a real exterior site entry");
report.put("locate", coords(located.getFirst()));
}
void verifyRuins() {
var context = StructurePieceSerializationContext.fromLevel(level);
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
var summaries = new ArrayList<Map<String, Object>>(); int count = 0, blockCount = 0, lootCount = 0;
var connections = NativeConnections20.Stats.empty();
for (var start : ruinsStarts) {
var origin = start.getChunkPos(); var saved = start.createTag(context, origin); var digest = hash(saved.toString());
var loaded = StructureStart.loadStaticStart(context, saved, level.getSeed());
helper.assertTrue(loaded != null && loaded.createTag(context, origin).equals(saved), "Forgotten20 native start NBT changed");
var expectedRooms = generator.ruinsPlan17().networks().stream().filter(n -> n.startChunkX() == origin.x() && n.startChunkZ() == origin.z()).flatMap(n -> n.rooms().stream()).toList();
var actualRooms = start.getPieces().stream().map(RuinsPiece20.class::cast).map(RuinsPiece20::room).toList();
helper.assertTrue(expectedRooms.size() == actualRooms.size() && new HashSet<>(expectedRooms).equals(new HashSet<>(actualRooms)), "Forgotten20 native rooms differ from plan");
if (reload) {
JsonObject prior = null;
for (var value : previous.getAsJsonArray("ruins_starts")) { var item = value.getAsJsonObject(); var at = item.getAsJsonArray("start_chunk"); if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = item; }
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(digest), "Forgotten20 native start changed on a second JVM load");
}
for (var raw : start.getPieces()) {
var piece = (RuinsPiece20) raw; var room = piece.room(); count++;
var tag = piece.createTag(context); var roundtrip = new RuinsPiece20(context, tag);
helper.assertTrue(roundtrip.room().equals(room) && roundtrip.createTag(context).equals(tag), "Forgotten20 piece lost saved parameters");
piece.getBoundingBox().intersectingChunks().forEach(at -> helper.assertTrue(level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true).getReferencesForStructure(registry.value()).contains(origin.pack()), "Forgotten20 chunk lost native references"));
var expected = rendered(room); blockCount += expected.blocks().size();
for (var entry : expected.blocks().entrySet()) {
helper.assertTrue(piece.getBoundingBox().isInside(entry.getKey()), "Forgotten20 renderer escaped saved bounds");
helper.assertTrue(level.getBlockState(entry.getKey()).is(entry.getValue().getBlock()), "Forgotten20 native geometry changed at " + entry.getKey());
}
connections = connections.add(NativeConnections20.verify(helper, expected.blocks(), Set.of()));
for (var support : room.supports()) assertRock(helper, new BlockPos(support.x(), support.minY() - 1, support.z()));
for (var chest : expected.chests()) {
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Forgotten20 missing deferred chest");
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
helper.assertTrue(chest.table().equals(entity.getLootTable()) && chest.seed() == entity.getLootTableSeed(), "Forgotten20 consumed/changed saved loot"); lootCount++;
}
}
summaries.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", digest, "pieces", start.getPieces().size()));
}
report.put("ruins_starts", summaries); report.put("ruins_native_pieces", count); report.put("ruins_native_blocks_checked", blockCount);
report.put("ruins_unopened_deferred_chests", lootCount); report.put("ruins_connections", connections);
report.put("ruins_access", generator.ruinsPlan17().networks().stream().map(n -> verifyAccess(helper, n)).toList());
}
}
private record Chest(BlockPos position, ResourceKey<LootTable> table, long seed) {}
private record Rendered(Map<BlockPos, BlockState> blocks, List<Chest> chests) {}
private static Rendered rendered(HeritagePiece20 piece) {
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
int x0 = piece.isSite() ? piece.site().minX() : piece.segment().minX();
int y0 = piece.isSite() ? piece.site().floorY() : piece.segment().minY();
int z0 = piece.isSite() ? piece.site().minZ() : piece.segment().minZ();
var writer = new LostCityRenderer.BlockWriter() {
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(new BlockPos(x0 + x, y0 + y, z0 + z), state); }
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
var at = new BlockPos(x0 + x, y0 + y, z0 + z); blocks.put(at, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(at, table, RandomSource.create(seed).nextLong()));
}
};
if (piece.isSite()) HeritageRenderer20.render(piece.site(), writer); else HeritageRenderer20.renderSegment(piece.segment(), writer);
return new Rendered(blocks, chests);
}
private static Rendered rendered(RuinsPlan17.Room room) {
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
RuinsRenderer20.render(room, new LostCityRenderer.BlockWriter() {
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(at(room, x, y, z), state); }
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
var position = at(room, x, y, z); blocks.put(position, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(position, table, RandomSource.create(seed).nextLong()));
}
});
return new Rendered(blocks, chests);
}
private static Map<String, Object> verifyAccess(GameTestHelper helper, RuinsPlan17.Network network) {
var level = helper.getLevel(); var access = network.rooms().stream().filter(r -> r.kind() == RuinsPlan17.Kind.ACCESS).findFirst().orElseThrow();
var hatches = new HashMap<BlockPos, BlockState>();
rendered(access).blocks().forEach((at, state) -> { if (state.is(Blocks.DARK_OAK_TRAPDOOR)) hatches.put(at, level.getBlockState(at)); });
helper.assertTrue(hatches.size() == 1, "Each network must have one usable wooden surface hatch");
hatches.forEach((at, state) -> {
helper.assertTrue(state.is(Blocks.DARK_OAK_TRAPDOOR) && state.canSurvive(level, at), "Surface hatch is missing its native support");
level.setBlock(at, state.setValue(BlockStateProperties.OPEN, true), 2);
});
try {
Predicate<BlockPos> inside = pos -> network.rooms().stream().anyMatch(r -> r.contains(pos.getX(), pos.getY(), pos.getZ()));
var start = RuinsPiece20.locator(access); var reached = walk(helper, start, inside);
int targets = 0;
for (var room : network.rooms()) for (var target : RuinsRenderer20.targets(room)) {
var point = at(room, target.x(), target.y(), target.z());
helper.assertTrue(reached.contains(point), "Native network " + network.id() + " cannot reach " + room.kind() + "/" + target.name() + " at " + point);
targets++;
}
for (var hatch : hatches.keySet()) helper.assertTrue(reached.contains(hatch), "Surface approach cannot enter its ladder shaft");
return Map.of("id", network.id(), "family", network.family().name(), "rooms", network.rooms().size(),
"entrance", coords(start), "walkable_targets", targets, "standing_cells", reached.size());
} finally { hatches.forEach((at, state) -> level.setBlock(at, state, 2)); }
}
private static BlockPos at(RuinsPlan17.Room room, int x, int y, int z) { return new BlockPos(room.minX() + x, room.minY() + y, room.minZ() + z); }
private static boolean isOuterWall(HeritagePlan19.Site site, BlockPos pos) {
return pos.getY() >= site.floorY() + 2 && pos.getY() <= site.floorY() + 3
&& (pos.getX() == site.minX() + 2 || pos.getX() == site.maxX() - 2 || pos.getZ() == site.minZ() + 2 || pos.getZ() == site.maxZ() - 2)
&& Math.abs(pos.getX() - site.entry().x()) + Math.abs(pos.getZ() - site.entry().z()) > 6;
}
private static Map<String, Object> verifyAccess(GameTestHelper helper, HeritagePlan19.Site site) {
var level = helper.getLevel(); var expected = rendered(new HeritagePiece20(site)); var doors = new HashMap<BlockPos, BlockState>();
expected.blocks().forEach((at, state) -> {
if ((state.getBlock() instanceof DoorBlock || state.getBlock() instanceof TrapDoorBlock) && state.hasProperty(BlockStateProperties.OPEN)) doors.put(at, level.getBlockState(at));
});
doors.forEach((at, state) -> level.setBlock(at, state.setValue(BlockStateProperties.OPEN, true), 2));
try {
var entry = HeritagePiece20.locator(site);
Predicate<BlockPos> inside = p -> p.equals(entry) || p.getX() >= site.minX() && p.getX() <= site.maxX()
&& p.getZ() >= site.minZ() && p.getZ() <= site.maxZ() && p.getY() >= site.floorY() - 4 && p.getY() < site.maxY();
var reached = walk(helper, entry, inside); int targets = 0;
for (var target : HeritageRenderer20.targets(site)) {
var at = new BlockPos(site.minX() + target.x(), site.floorY() + target.y(), site.minZ() + target.z());
helper.assertTrue(reached.contains(at), "Site " + site.id() + " cannot reach " + target.name() + " at " + at); targets++;
}
return Map.of("id", site.id(), "kind", site.kind().name(), "entry", coords(entry), "standing_cells", reached.size(), "walkable_targets", targets);
} finally { doors.forEach((at, state) -> level.setBlock(at, state, 2)); }
}
private static List<Map<String, Object>> verifyLinks(GameTestHelper helper, HeritagePlan19 plan) {
helper.assertTrue(!plan.links().isEmpty(), "Reference island needs a real route between accepted destinations");
var entries = new HashMap<String, HeritagePlan19.Entry>(); plan.sites().forEach(s -> entries.put(s.id(), s.entry())); plan.landmarks().forEach(s -> entries.put(s.id(), s.entry()));
var cells = new HashMap<Long, HeritagePlan19.Cell>(); plan.segments().forEach(s -> s.cells().forEach(c -> cells.put(column(c.x(), c.z()), c)));
var result = new ArrayList<Map<String, Object>>();
for (var link : plan.links()) {
var from = entries.get(link.fromId()); var to = entries.get(link.toId());
helper.assertTrue(from != null && to != null, "Route points to an absent destination");
var first = link.path().getFirst(); var last = link.path().getLast();
helper.assertTrue(first.x() == from.x() && first.z() == from.z() && first.floorY() + 1 == from.y()
&& last.x() == to.x() && last.z() == to.z() && last.floorY() + 1 == to.y(), "Route does not meet its real entrances");
int present = 0, missing = 0;
for (int i = 0; i < link.path().size(); i++) {
var point = link.path().get(i); var at = new BlockPos(point.x(), point.floorY() + 1, point.z());
helper.assertTrue(standable(helper.getLevel(), at), "Ballast route " + link.id() + " is blocked at " + at);
if (i > 0) { var prior = link.path().get(i - 1); if (point.floorY() > prior.floorY()) helper.assertTrue(clearBody(helper.getLevel(), new BlockPos(prior.x(), prior.floorY() + 2, prior.z())), "Route slope cannot be climbed by a player"); }
var cell = cells.get(column(point.x(), point.z()));
if (cell != null && cell.rail() != HeritagePlan19.Rail.NONE) {
var actual = helper.getLevel().getBlockState(at);
if (cell.railPresent()) {
helper.assertTrue(actual.is(Blocks.RAIL), "Saved existing rail disappeared");
helper.assertTrue(actual.getValue(BlockStateProperties.RAIL_SHAPE).name().equals(cell.rail().name()), "Native rail update changed the intended slope or curve at " + at); present++;
} else { helper.assertTrue(!actual.is(Blocks.RAIL), "An intentionally missing rail regenerated"); missing++; }
}
}
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
result.add(Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(),
"span_x", spanX, "span_z", spanZ, "existing_rails", present, "deliberately_missing_rails", missing, "path", pathCoords(link)));
}
return result;
}
private static int routeSpan(HeritagePlan19.Link link) {
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow()
- link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow()
- link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
return Math.max(spanX, spanZ);
}
private static List<List<Integer>> pathCoords(HeritagePlan19.Link link) {
return link.path().stream().map(point -> List.of(point.x(), point.floorY(), point.z())).toList();
}
private static int verifySiteBearing(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan, HeritagePlan19.Site site) {
int checked = 0;
// The planner certifies a nine-point bearing grid, either directly below its slab or below a saved short footing.
for (int dx : new int[] {1, 8, 15}) for (int dz : new int[] {1, 8, 15}) {
int x = site.minX() + dx, z = site.minZ() + dz;
var support = site.supports().stream().filter(s -> s.x() == x && s.z() == z).findFirst();
int bottom = support.map(HeritagePlan19.Support::minY).orElse(site.minY());
for (int below = 1; below <= 2; below++) {
var at = new BlockPos(x, bottom - below, z); assertRock(helper, at);
helper.assertTrue(plan.protects(at.getX(), at.getY(), at.getZ()) && generator.protectsCityDecoration(at),
"Site's certified bearing layer is not published in the decoration mask: " + site.id() + " at " + at);
helper.assertTrue(!plan.contains(at.getX(), at.getY(), at.getZ()), "Original bearing rock became a writable heritage volume: " + at);
checked++;
}
}
return checked;
}
private static List<Map<String, Object>> verifyReadOnlyLandings(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan) {
var used = new HashSet<String>(); plan.links().forEach(link -> { used.add(link.fromId()); used.add(link.toId()); });
var result = new ArrayList<Map<String, Object>>();
for (var landmark : plan.landmarks()) if (used.contains(landmark.id())) {
var e = landmark.entry(); int checked = 0;
for (int side = -1; side <= 1; side++) {
int x = e.x() + ((e.facing() & 1) == 0 ? side : 0), z = e.z() + ((e.facing() & 1) == 1 ? side : 0);
for (int y = e.y() - 2; y <= e.y() + 2; y++) {
var at = new BlockPos(x, y, z);
helper.assertTrue(plan.protects(x, y, z) && generator.protectsCityDecoration(at), "Read-only arrival lost its decoration mask: " + landmark.id() + " at " + at);
helper.assertTrue(!plan.contains(x, y, z), "Heritage gained write ownership of a historical arrival: " + landmark.id() + " at " + at);
if (y == e.y() - 1) assertRock(helper, at);
if (y >= e.y()) helper.assertTrue(helper.getLevel().getBlockState(at).isAir(), "Decoration obstructed a certified historical arrival: " + at);
checked++;
}
}
result.add(Map.of("id", landmark.id(), "entry", List.of(e.x(), e.y(), e.z()), "read_only_cells", checked));
}
return result;
}
private static void assertRock(GameTestHelper helper, BlockPos at) {
helper.assertTrue(helper.getLevel().getBlockState(at).isSolid() && helper.getLevel().getFluidState(at).isEmpty(), "Lost original rock bearing at " + at);
}
private static void verifyLootTables(GameTestHelper helper, Map<String, Object> report) {
var result = new ArrayList<Map<String, Object>>(); var level = helper.getLevel();
var params = new LootParams.Builder(level).withParameter(LootContextParams.ORIGIN, new Vec3(0, 160, 0)).create(LootContextParamSets.CHEST);
for (var kind : HeritagePlan19.Kind.values()) {
var table = level.getServer().reloadableRegistries().getLootTable(lootKey(kind));
helper.assertTrue(table != LootTable.EMPTY, "Heritage tool table did not load: " + kind);
for (long seed : new long[] {0, 42, -1}) helper.assertTrue(table.getRandomItems(params, seed).stream().anyMatch(stack -> stack.is(tool(kind))), "Tool table is empty or lacks its expected primary tool: " + kind);
result.add(Map.of("kind", kind.name(), "table", lootKey(kind).identifier().toString(), "sampled_seeds", List.of(0, 42, -1)));
}
report.put("tool_tables_sampled_separately", result);
}
private static ResourceKey<LootTable> lootKey(HeritagePlan19.Kind kind) { return ResourceKey.create(Registries.LOOT_TABLE, SanctuaryMod.id("chests/heritage19/" + kind.name().toLowerCase(Locale.ROOT))); }
private static Item tool(HeritagePlan19.Kind kind) {
return switch (kind) {
case OBSERVATORY -> Items.SPYGLASS;
case POST_OFFICE, EXPEDITION_OFFICE, RADIO_RELAY -> Items.COMPASS;
case SUBSTATION, IMPOSSIBLE_SERVICE -> Items.CLOCK;
case FREIGHT_STATION -> Items.MINECART;
case COMMUNAL_DEPOT -> Items.IRON_AXE;
case RAIL_WORKSHOP -> Items.IRON_PICKAXE;
case PUMP_STATION -> Items.BUCKET;
case GREENHOUSE -> Items.IRON_HOE;
case VOID_DOCK -> Items.LEAD;
case PHOTO_CLUB -> Items.BOOK;
case CONSTRUCTION_REFUGE -> Items.IRON_SHOVEL;
};
}
private static Set<BlockPos> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside) {
var level = helper.getLevel(); helper.assertTrue(inside.test(start) && standable(level, start), "Blocked or unsupported exterior entrance: " + start);
var seen = new HashSet<BlockPos>(); var queue = new ArrayDeque<BlockPos>(); seen.add(start); queue.add(start);
while (!queue.isEmpty()) {
var pos = queue.removeFirst();
for (var direction : HORIZONTAL) for (int dy = -1; dy <= 1; dy++) {
var next = pos.relative(direction).offset(0, dy, 0);
if (!inside.test(next) || dy > 0 && !clearBody(level, pos.above())) continue;
if (standable(level, next) && seen.add(next)) queue.add(next);
}
for (int dy : new int[] {-1, 1}) {
var next = pos.offset(0, dy, 0); if (!inside.test(next)) continue;
if ((level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(next).is(Blocks.LADDER) || level.getBlockState(pos.below()).is(Blocks.LADDER))
&& standable(level, next) && seen.add(next)) queue.add(next);
}
helper.assertTrue(seen.size() <= 50_000, "Native site traversal escaped its bounds");
}
return seen;
}
private static boolean standable(ServerLevel level, BlockPos pos) {
if (!clearBody(level, pos)) return false;
if (level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(pos.below()).is(Blocks.LADDER)) return true;
var below = pos.below(); return level.getBlockState(below).getCollisionShape(level, below).toAabbs().stream()
.anyMatch(b -> b.maxY >= .5 && b.minX <= .5 && b.maxX >= .5 && b.minZ <= .5 && b.maxZ >= .5);
}
private static boolean clearBody(ServerLevel level, BlockPos pos) {
var body = new AABB(pos.getX() + .2, pos.getY(), pos.getZ() + .2, pos.getX() + .8, pos.getY() + 1.8, pos.getZ() + .8);
for (var at : new BlockPos[] {pos, pos.above()}) {
if (!level.getFluidState(at).isEmpty()) return false;
for (var shape : level.getBlockState(at).getCollisionShape(level, at).toAabbs()) if (shape.move(at.getX(), at.getY(), at.getZ()).intersects(body)) return false;
}
return true;
}
private static long column(int x, int z) { return (long) x << 32 ^ z & 0xffffffffL; }
private static List<Integer> coords(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
private static BlockPos readPos(com.google.gson.JsonArray value) { return new BlockPos(value.get(0).getAsInt(), value.get(1).getAsInt(), value.get(2).getAsInt()); }
private static Path world(ServerLevel level) { return level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); }
private static Path proof(ServerLevel level) { return world(level).resolve("heritage20-create-proof.json"); }
private static String planHash(HeritagePlan19 plan) { return hash(plan.sites().toString() + plan.landmarks() + plan.links() + plan.segments()); }
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
var path = Path.of("diagnostics", "heritage20-" + (reload ? "reload" : "create") + ".json"); Files.createDirectories(path.getParent());
var value = new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n"; Files.writeString(path, value); if (!reload) Files.writeString(proof(level), value);
}
private static String hash(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
}
}
@@ -0,0 +1,554 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
import fr.koka.sanctuary.worldgen.heritage.HeritagePiece21;
import fr.koka.sanctuary.worldgen.heritage.HeritagePlan19;
import fr.koka.sanctuary.worldgen.heritage.HeritageRenderer21;
import fr.koka.sanctuary.worldgen.heritage.HeritageStructures19;
import fr.koka.sanctuary.worldgen.ruins.RuinsPiece20;
import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17;
import fr.koka.sanctuary.worldgen.ruins.RuinsRenderer20;
import fr.koka.sanctuary.worldgen.ruins.RuinsStructures17;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.RegistryOps;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.RandomSource;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.DoorBlock;
import net.minecraft.world.level.block.TrapDoorBlock;
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.LevelResource;
import net.minecraft.world.level.storage.loot.LootParams;
import net.minecraft.world.level.storage.loot.LootTable;
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
/** Native creation/reload proof confined to a disposable generation21 starter island. */
public final class Heritage21WorldGameTests {
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
@GameTest(maxTicks = 12000)
public void heritagePersistsWithRealDestinations(GameTestHelper helper) {
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
"The heritage proof requires a Sanctuary generator");
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 21, "Use a fresh generation21 test world; do not convert an old save");
new Scenario(helper, generator, Boolean.getBoolean("sanctuary.test.expansionReload")).advance();
}
private static final class Scenario {
final GameTestHelper helper;
final ServerLevel level;
final SanctuaryChunkGenerator generator;
final HeritagePlan19 plan;
final boolean reload;
final Map<String, Object> report = new LinkedHashMap<>();
final List<ChunkPos> chunks = new ArrayList<>();
final List<StructureStart> starts = new ArrayList<>();
final List<StructureStart> ruinsStarts = new ArrayList<>();
JsonObject previous;
BlockPos gold;
int nextChunk;
boolean prepared;
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator, boolean reload) {
this.helper = helper; this.level = helper.getLevel(); this.generator = generator; this.reload = reload;
this.plan = generator.heritagePlan19();
report.put("seed", level.getSeed()); report.put("size", generator.initialDiameter());
report.put("process_id", ProcessHandle.current().pid()); report.put("world_directory", world(level).toString());
report.put("experimental_structures", generator.experimentalStructures());
report.put("structures_enabled", level.structureManager().shouldGenerateStructures());
report.put("scope", "Native local starts, saved sites and routes, player-sized entrances, supports and unopened chests on the starter island. "
+ "All14 tool tables are sampled separately without consuming a saved chest. Door states are restored after traversal. "
+ "No personal save or expansion is opened; one disposable outer-wall edit is checked in a second JVM. "
+ "Missing rails are intentional; this proof checks walking and saved rail shapes, not an uninterrupted minecart journey.");
}
void advance() {
try {
if (!prepared) { report.put("stage", "prepare"); prepare(); prepared = true; if (plan.isEmpty()) { finishDisabled(); return; } }
if (nextChunk < chunks.size()) {
report.put("stage", "load_full_chunks"); var pos = chunks.get(nextChunk++);
helper.assertTrue(level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true) instanceof LevelChunk,
"Heritage footprint must reach native FULL: " + pos);
if (nextChunk % 20 == 0) SanctuaryMod.LOGGER.info("Heritage21 loaded {}/{} footprint chunks", nextChunk, chunks.size());
helper.runAtTickTime(helper.getTick() + 1, this::advance); return;
}
report.put("stage", "verify_native_geometry"); verifyPlaced(); report.put("passed", true);
if (!reload) {
helper.assertTrue(gold != null && level.getBlockState(gold).isCollisionShapeFullBlock(level, gold), "Gold witness must replace a solid wall away from circulation");
level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3); report.put("gold_witness", coords(gold));
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native starts, unopened loot and the wall edit");
report.put("world_flushed_before_shutdown", true);
} else {
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Reload replaced the player's wall edit");
report.put("gold_witness", coords(gold)); report.put("gold_witness_preserved", true);
}
write(level, report, reload); helper.succeed();
} catch (Exception failure) {
report.put("passed", false); report.put("failure", failure.toString());
try { write(level, report, reload); } catch (IOException ignored) { }
throw new IllegalStateException("Heritage21 native " + (reload ? "reload" : "creation") + " failed", failure);
}
}
void prepare() throws IOException {
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
var encoded = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow();
var decoded = ChunkGenerator.CODEC.parse(ops, encoded).getOrThrow();
helper.assertTrue(decoded instanceof SanctuaryChunkGenerator copy && copy.rootGeneration() == 21
&& copy.experimentalStructures() == generator.experimentalStructures(), "Generation21 codec lost its experimental switch");
report.put("generator_codec", encoded); report.put("plan_sha256", planHash(plan));
report.put("planned_sites", plan.sites().stream().map(site -> Map.of("id", site.id(), "kind", site.kind().name(),
"entry", coords(HeritagePiece21.locator(site)), "floor_y", site.floorY(), "foundation_depth", site.foundationDepth(), "footings", site.supports().size())).toList());
report.put("planned_links", plan.links().stream().map(link -> Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(), "path", pathCoords(link))).toList());
report.put("planning_samples", plan.samples()); report.put("planning_columns", plan.columns()); report.put("planning_diagnostics", plan.diagnostics());
helper.assertTrue(plan.samples() <= HeritagePlan19.MAX_SAMPLES && plan.columns() <= HeritagePlan19.MAX_COLUMNS, "Heritage planning exceeded its budget");
verifyLootTables(helper, report);
if (reload) {
previous = JsonParser.parseString(Files.readString(proof(level))).getAsJsonObject();
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(), "Reload needs a successful flushed creation proof");
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must use a second JVM");
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed() && previous.get("size").getAsInt() == generator.initialDiameter()
&& previous.get("world_directory").getAsString().equals(world(level).toString()), "Reload selected another world");
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("plan_sha256").getAsString().equals(planHash(plan)), "Saved settings or planned identities changed on reload");
if (previous.has("gold_witness")) gold = readPos(previous.getAsJsonArray("gold_witness"));
}
if (!generator.experimentalStructures() || !level.structureManager().shouldGenerateStructures()) {
helper.assertTrue(plan.isEmpty() && plan.segments().isEmpty() && plan.links().isEmpty(), "Disabled generation published heritage geometry"); return;
}
helper.assertTrue(!plan.isEmpty(), "Enabled reference island has no heritage site");
report.put("present_site_kinds", plan.sites().stream().map(s -> s.kind().name()).distinct().sorted().toList());
report.put("missing_site_kinds", java.util.Arrays.stream(HeritagePlan19.Kind.values()).filter(k -> plan.sites().stream().noneMatch(s -> s.kind() == k)).map(Enum::name).toList());
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
var origins = new HashSet<ChunkPos>(); var uniqueChunks = new HashSet<ChunkPos>();
plan.sites().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
plan.segments().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
for (var origin : origins.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList()) {
var chunk = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true);
var start = chunk.getStartForStructure(structure.value());
helper.assertTrue(start != null && start.isValid(), "Accepted site/route has no native start at " + origin); starts.add(start);
for (var piece : start.getPieces()) {
helper.assertTrue(piece instanceof HeritagePiece21, "Heritage loaded a foreign piece type");
var box = piece.getBoundingBox();
helper.assertTrue(Math.abs((long) (box.minX() >> 4) - origin.x()) <= 7 && Math.abs((long) (box.maxX() >> 4) - origin.x()) <= 7
&& Math.abs((long) (box.minZ() >> 4) - origin.z()) <= 7 && Math.abs((long) (box.maxZ() >> 4) - origin.z()) <= 7, "Native start references a distant route endpoint");
box.intersectingChunks().forEach(uniqueChunks::add);
}
}
for (var site : plan.sites()) uniqueChunks.add(ChunkPos.containing(HeritagePiece21.locator(site)));
for (var link : plan.links()) for (var point : link.path()) uniqueChunks.add(new ChunkPos(point.x() >> 4, point.z() >> 4));
helper.assertTrue(uniqueChunks.size() <= 1024, "Bounded proof footprint exceeds1024 chunks");
var ruins = generator.ruinsPlan17();
report.put("ruins_plan_sha256", hash(ruins.networks().toString()));
if (reload) helper.assertTrue(previous.get("ruins_plan_sha256").getAsString().equals(hash(ruins.networks().toString())), "Reload changed forgotten room planning");
helper.assertTrue(!ruins.isEmpty(), "Reference20 must demonstrate native experimental room architecture");
var ruinsType = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
var ruinsOrigins = new HashSet<ChunkPos>();
for (var network : ruins.networks()) {
var origin = new ChunkPos(network.startChunkX(), network.startChunkZ()); if (!ruinsOrigins.add(origin)) continue;
var nativeStart = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(ruinsType.value());
helper.assertTrue(nativeStart != null && nativeStart.isValid(), "Missing native20 forgotten network: " + network.id());
ruinsStarts.add(nativeStart);
for (var piece : nativeStart.getPieces()) {
helper.assertTrue(piece instanceof RuinsPiece20, "Generation21 loaded a historical forgotten piece");
piece.getBoundingBox().intersectingChunks().forEach(uniqueChunks::add);
}
}
chunks.addAll(uniqueChunks.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList());
}
void finishDisabled() throws IOException {
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
var start = level.getChunk(0, 0, ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(structure.value());
helper.assertTrue(start == null || !start.isValid(), "Disabled heritage emitted a native start");
helper.assertTrue(generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false) == null, "Disabled heritage is locatable");
report.put("empty_plan_verified", true); report.put("passed", true);
if (!reload) { helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save disabled experiment setting"); report.put("world_flushed_before_shutdown", true); }
write(level, report, reload); helper.succeed();
}
void verifyPlaced() {
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
var context = StructurePieceSerializationContext.fromLevel(level); var summaries = new ArrayList<Map<String, Object>>();
int blocks = 0, chests = 0, pieces = 0, supports = 0;
var connections = NativeConnections20.Stats.empty();
for (var start : starts) {
var origin = start.getChunkPos(); var saved = start.createTag(context, origin); String nativeHash = hash(saved.toString());
var copy = StructureStart.loadStaticStart(context, saved, level.getSeed());
helper.assertTrue(copy != null && copy.isValid() && copy.createTag(context, origin).equals(saved), "Native start failed an exact NBT roundtrip");
var expectedPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
plan.sites().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece21.encodeSite(s)));
plan.segments().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece21.encodeSegment(s)));
var actualPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
if (reload) {
JsonObject prior = null;
for (var value : previous.getAsJsonArray("starts")) { var item = value.getAsJsonObject(); var at = item.getAsJsonArray("start_chunk"); if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = item; }
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(nativeHash), "Reload changed saved geometry or native reference count");
}
var loot = new ArrayList<Map<String, Object>>();
for (var value : start.getPieces()) {
var piece = (HeritagePiece21) value; pieces++;
var tag = piece.createTag(context); var restored = new HeritagePiece21(context, tag);
helper.assertTrue(restored.createTag(context).equals(tag), "Saved piece parameters changed on native load");
actualPayloads.add(tag.getCompound("SanctuaryHeritage").orElseThrow());
var corrupt = tag.copy(); corrupt.getCompound("SanctuaryHeritage").orElseThrow().putInt("Schema", 22);
boolean rejected = false; try { new HeritagePiece21(context, corrupt); } catch (IllegalArgumentException expected) { rejected = true; }
helper.assertTrue(rejected, "Unknown schema must fail closed");
piece.getBoundingBox().intersectingChunks().forEach(at -> helper.assertTrue(level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true)
.getReferencesForStructure(structure.value()).contains(origin.pack()), "FULL chunk lost its native structure reference"));
var expected = rendered(piece); blocks += expected.blocks().size();
for (var entry : expected.blocks().entrySet()) {
var at = entry.getKey(); var wanted = entry.getValue(); helper.assertTrue(piece.getBoundingBox().isInside(at), "Renderer escaped saved bounds");
if (reload && at.equals(gold)) continue;
helper.assertTrue(level.getBlockState(at).is(wanted.getBlock()), "Clipping or decoration changed heritage at " + at + ": expected " + wanted + " got " + level.getBlockState(at));
helper.assertTrue(!wanted.is(Blocks.SPAWNER), "Heritage must not create spawners");
}
connections = connections.add(NativeConnections20.verify(helper, expected.blocks(), reload ? Set.of(gold) : Set.of()));
if (piece.isSite()) {
supports += verifySiteBearing(helper, generator, plan, piece.site());
if (!reload && gold == null) {
gold = expected.blocks().entrySet().stream().filter(e -> isOuterWall(piece.site(), e.getKey()) && e.getValue().isCollisionShapeFullBlock(level, e.getKey()))
.map(Map.Entry::getKey).sorted(Comparator.<BlockPos>comparingInt(p -> p.getY()).thenComparingInt(p -> p.getX()).thenComparingInt(p -> p.getZ())).findFirst().orElse(null);
}
} else for (var cell : piece.segment().cells()) { supports++; assertRock(helper, new BlockPos(cell.x(), cell.minY() - 1, cell.z())); }
for (var chest : expected.chests()) {
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Deferred chest is missing");
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
helper.assertTrue(entity.getLootTable() != null && entity.getLootTable().equals(chest.table()) && entity.getLootTableSeed() == chest.seed(), "Deferred loot table/seed was lost or consumed");
if (piece.isSite()) helper.assertTrue(chest.table().equals(lootKey(piece.site().kind())), "Site uses another kind's tool table");
loot.add(Map.of("position", coords(chest.position()), "table", chest.table().identifier().toString(), "seed", chest.seed())); chests++;
}
}
helper.assertTrue(actualPayloads.equals(expectedPayloads) && start.getPieces().size() == expectedPayloads.size(), "Native start lost or duplicated planned pieces");
summaries.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", nativeHash, "pieces", start.getPieces().size(), "unopened_chests", loot));
}
report.put("connected_blocks", connections);
helper.assertTrue(connections.panes() > 0 && connections.stairs() > 0 && connections.nativeOracles() > 0,
"Reference20 needs real panes/stairs validated by vanilla neighbor rules");
if (level.getSeed() == 0 && generator.initialDiameter() == 724)
helper.assertTrue(connections.crossChunkPaneLinks() > 0, "Reference20 must demonstrate a real pane join across a chunk boundary");
verifyRuins();
report.put("starts", summaries); report.put("native_pieces", pieces); report.put("native_blocks_checked", blocks);
report.put("full_chunks_checked", chunks.size()); report.put("rock_supports_checked", supports); report.put("unopened_deferred_chests", chests);
report.put("stage", "verify_site_routes"); var sites = new ArrayList<Map<String, Object>>(); for (var site : plan.sites()) sites.add(verifyAccess(helper, site)); report.put("native_sites", sites);
report.put("stage", "verify_read_only_landings");
report.put("read_only_landmarks", verifyReadOnlyLandings(helper, generator, plan));
report.put("stage", "verify_island_routes"); report.put("native_links", verifyLinks(helper, plan));
int greatestSpan = plan.links().stream().mapToInt(Heritage21WorldGameTests::routeSpan).max().orElse(0);
report.put("greatest_single_route_span", greatestSpan);
var reachedIds = new HashSet<String>(); plan.links().forEach(l -> { reachedIds.add(l.fromId()); reachedIds.add(l.toId()); });
report.put("connected_destination_ids", reachedIds.stream().sorted().toList());
report.put("isolated_destination_ids", plan.sites().stream().map(HeritagePlan19.Site::id).filter(id -> !reachedIds.contains(id)).toList());
report.put("connected_city_arrivals", plan.landmarks().stream().filter(l -> l.kind() == HeritagePlan19.LandmarkKind.CITY && reachedIds.contains(l.id())).map(HeritagePlan19.Landmark::id).toList());
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
helper.assertTrue(located != null && located.getSecond().is(HeritageStructures19.KEY) && plan.sites().stream().anyMatch(s -> HeritagePiece21.locator(s).equals(located.getFirst())), "Locate must lead to a real exterior site entry");
report.put("locate", coords(located.getFirst()));
}
void verifyRuins() {
var context = StructurePieceSerializationContext.fromLevel(level);
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
var summaries = new ArrayList<Map<String, Object>>(); int count = 0, blockCount = 0, lootCount = 0;
var connections = NativeConnections20.Stats.empty();
for (var start : ruinsStarts) {
var origin = start.getChunkPos(); var saved = start.createTag(context, origin); var digest = hash(saved.toString());
var loaded = StructureStart.loadStaticStart(context, saved, level.getSeed());
helper.assertTrue(loaded != null && loaded.createTag(context, origin).equals(saved), "Forgotten20 native start NBT changed");
var expectedRooms = generator.ruinsPlan17().networks().stream().filter(n -> n.startChunkX() == origin.x() && n.startChunkZ() == origin.z()).flatMap(n -> n.rooms().stream()).toList();
var actualRooms = start.getPieces().stream().map(RuinsPiece20.class::cast).map(RuinsPiece20::room).toList();
helper.assertTrue(expectedRooms.size() == actualRooms.size() && new HashSet<>(expectedRooms).equals(new HashSet<>(actualRooms)), "Forgotten20 native rooms differ from plan");
if (reload) {
JsonObject prior = null;
for (var value : previous.getAsJsonArray("ruins_starts")) { var item = value.getAsJsonObject(); var at = item.getAsJsonArray("start_chunk"); if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = item; }
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(digest), "Forgotten20 native start changed on a second JVM load");
}
for (var raw : start.getPieces()) {
var piece = (RuinsPiece20) raw; var room = piece.room(); count++;
var tag = piece.createTag(context); var roundtrip = new RuinsPiece20(context, tag);
helper.assertTrue(roundtrip.room().equals(room) && roundtrip.createTag(context).equals(tag), "Forgotten20 piece lost saved parameters");
piece.getBoundingBox().intersectingChunks().forEach(at -> helper.assertTrue(level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true).getReferencesForStructure(registry.value()).contains(origin.pack()), "Forgotten20 chunk lost native references"));
var expected = rendered(room); blockCount += expected.blocks().size();
for (var entry : expected.blocks().entrySet()) {
helper.assertTrue(piece.getBoundingBox().isInside(entry.getKey()), "Forgotten20 renderer escaped saved bounds");
helper.assertTrue(level.getBlockState(entry.getKey()).is(entry.getValue().getBlock()), "Forgotten20 native geometry changed at " + entry.getKey());
}
connections = connections.add(NativeConnections20.verify(helper, expected.blocks(), Set.of()));
for (var support : room.supports()) assertRock(helper, new BlockPos(support.x(), support.minY() - 1, support.z()));
for (var chest : expected.chests()) {
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Forgotten20 missing deferred chest");
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
helper.assertTrue(chest.table().equals(entity.getLootTable()) && chest.seed() == entity.getLootTableSeed(), "Forgotten20 consumed/changed saved loot"); lootCount++;
}
}
summaries.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", digest, "pieces", start.getPieces().size()));
}
report.put("ruins_starts", summaries); report.put("ruins_native_pieces", count); report.put("ruins_native_blocks_checked", blockCount);
report.put("ruins_unopened_deferred_chests", lootCount); report.put("ruins_connections", connections);
report.put("ruins_access", generator.ruinsPlan17().networks().stream().map(n -> verifyAccess(helper, n)).toList());
}
}
private record Chest(BlockPos position, ResourceKey<LootTable> table, long seed) {}
private record Rendered(Map<BlockPos, BlockState> blocks, List<Chest> chests) {}
private static Rendered rendered(HeritagePiece21 piece) {
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
int x0 = piece.isSite() ? piece.site().minX() : piece.segment().minX();
int y0 = piece.isSite() ? piece.site().floorY() : piece.segment().minY();
int z0 = piece.isSite() ? piece.site().minZ() : piece.segment().minZ();
var writer = new LostCityRenderer.BlockWriter() {
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(new BlockPos(x0 + x, y0 + y, z0 + z), state); }
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
var at = new BlockPos(x0 + x, y0 + y, z0 + z); blocks.put(at, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(at, table, RandomSource.create(seed).nextLong()));
}
};
if (piece.isSite()) HeritageRenderer21.render(piece.site(), writer); else HeritageRenderer21.renderSegment(piece.segment(), writer);
return new Rendered(blocks, chests);
}
private static Rendered rendered(RuinsPlan17.Room room) {
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
RuinsRenderer20.render(room, new LostCityRenderer.BlockWriter() {
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(at(room, x, y, z), state); }
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
var position = at(room, x, y, z); blocks.put(position, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(position, table, RandomSource.create(seed).nextLong()));
}
});
return new Rendered(blocks, chests);
}
private static Map<String, Object> verifyAccess(GameTestHelper helper, RuinsPlan17.Network network) {
var level = helper.getLevel(); var access = network.rooms().stream().filter(r -> r.kind() == RuinsPlan17.Kind.ACCESS).findFirst().orElseThrow();
var hatches = new HashMap<BlockPos, BlockState>();
rendered(access).blocks().forEach((at, state) -> { if (state.is(Blocks.DARK_OAK_TRAPDOOR)) hatches.put(at, level.getBlockState(at)); });
helper.assertTrue(hatches.size() == 1, "Each network must have one usable wooden surface hatch");
hatches.forEach((at, state) -> {
helper.assertTrue(state.is(Blocks.DARK_OAK_TRAPDOOR) && state.canSurvive(level, at), "Surface hatch is missing its native support");
level.setBlock(at, state.setValue(BlockStateProperties.OPEN, true), 2);
});
try {
Predicate<BlockPos> inside = pos -> network.rooms().stream().anyMatch(r -> r.contains(pos.getX(), pos.getY(), pos.getZ()));
var start = RuinsPiece20.locator(access); var reached = walk(helper, start, inside);
int targets = 0;
for (var room : network.rooms()) for (var target : RuinsRenderer20.targets(room)) {
var point = at(room, target.x(), target.y(), target.z());
helper.assertTrue(reached.contains(point), "Native network " + network.id() + " cannot reach " + room.kind() + "/" + target.name() + " at " + point);
targets++;
}
for (var hatch : hatches.keySet()) helper.assertTrue(reached.contains(hatch), "Surface approach cannot enter its ladder shaft");
return Map.of("id", network.id(), "family", network.family().name(), "rooms", network.rooms().size(),
"entrance", coords(start), "walkable_targets", targets, "standing_cells", reached.size());
} finally { hatches.forEach((at, state) -> level.setBlock(at, state, 2)); }
}
private static BlockPos at(RuinsPlan17.Room room, int x, int y, int z) { return new BlockPos(room.minX() + x, room.minY() + y, room.minZ() + z); }
private static boolean isOuterWall(HeritagePlan19.Site site, BlockPos pos) {
return pos.getY() >= site.floorY() + 2 && pos.getY() <= site.floorY() + 3
&& (pos.getX() == site.minX() + 2 || pos.getX() == site.maxX() - 2 || pos.getZ() == site.minZ() + 2 || pos.getZ() == site.maxZ() - 2)
&& Math.abs(pos.getX() - site.entry().x()) + Math.abs(pos.getZ() - site.entry().z()) > 6;
}
private static Map<String, Object> verifyAccess(GameTestHelper helper, HeritagePlan19.Site site) {
var level = helper.getLevel(); var expected = rendered(new HeritagePiece21(site)); var doors = new HashMap<BlockPos, BlockState>();
expected.blocks().forEach((at, state) -> {
if ((state.getBlock() instanceof DoorBlock || state.getBlock() instanceof TrapDoorBlock) && state.hasProperty(BlockStateProperties.OPEN)) doors.put(at, level.getBlockState(at));
});
doors.forEach((at, state) -> level.setBlock(at, state.setValue(BlockStateProperties.OPEN, true), 2));
try {
var entry = HeritagePiece21.locator(site);
Predicate<BlockPos> inside = p -> p.equals(entry) || p.getX() >= site.minX() && p.getX() <= site.maxX()
&& p.getZ() >= site.minZ() && p.getZ() <= site.maxZ() && p.getY() >= site.floorY() - 4 && p.getY() < site.maxY();
var reached = walk(helper, entry, inside); int targets = 0;
for (var target : HeritageRenderer21.targets(site)) {
var at = new BlockPos(site.minX() + target.x(), site.floorY() + target.y(), site.minZ() + target.z());
helper.assertTrue(reached.contains(at), "Site " + site.id() + " cannot reach " + target.name() + " at " + at); targets++;
}
return Map.of("id", site.id(), "kind", site.kind().name(), "entry", coords(entry), "standing_cells", reached.size(), "walkable_targets", targets);
} finally { doors.forEach((at, state) -> level.setBlock(at, state, 2)); }
}
private static List<Map<String, Object>> verifyLinks(GameTestHelper helper, HeritagePlan19 plan) {
helper.assertTrue(!plan.links().isEmpty(), "Reference island needs a real route between accepted destinations");
var entries = new HashMap<String, HeritagePlan19.Entry>(); plan.sites().forEach(s -> entries.put(s.id(), s.entry())); plan.landmarks().forEach(s -> entries.put(s.id(), s.entry()));
var cells = new HashMap<Long, HeritagePlan19.Cell>(); plan.segments().forEach(s -> s.cells().forEach(c -> cells.put(column(c.x(), c.z()), c)));
var result = new ArrayList<Map<String, Object>>();
for (var link : plan.links()) {
var from = entries.get(link.fromId()); var to = entries.get(link.toId());
helper.assertTrue(from != null && to != null, "Route points to an absent destination");
var first = link.path().getFirst(); var last = link.path().getLast();
helper.assertTrue(first.x() == from.x() && first.z() == from.z() && first.floorY() + 1 == from.y()
&& last.x() == to.x() && last.z() == to.z() && last.floorY() + 1 == to.y(), "Route does not meet its real entrances");
int present = 0, missing = 0;
for (int i = 0; i < link.path().size(); i++) {
var point = link.path().get(i); var at = new BlockPos(point.x(), point.floorY() + 1, point.z());
helper.assertTrue(standable(helper.getLevel(), at), "Ballast route " + link.id() + " is blocked at " + at);
if (i > 0) { var prior = link.path().get(i - 1); if (point.floorY() > prior.floorY()) helper.assertTrue(clearBody(helper.getLevel(), new BlockPos(prior.x(), prior.floorY() + 2, prior.z())), "Route slope cannot be climbed by a player"); }
var cell = cells.get(column(point.x(), point.z()));
if (cell != null && cell.rail() != HeritagePlan19.Rail.NONE) {
var actual = helper.getLevel().getBlockState(at);
if (cell.railPresent()) {
helper.assertTrue(actual.is(Blocks.RAIL), "Saved existing rail disappeared");
helper.assertTrue(actual.getValue(BlockStateProperties.RAIL_SHAPE).name().equals(cell.rail().name()), "Native rail update changed the intended slope or curve at " + at); present++;
} else { helper.assertTrue(!actual.is(Blocks.RAIL), "An intentionally missing rail regenerated"); missing++; }
}
}
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
result.add(Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(),
"span_x", spanX, "span_z", spanZ, "existing_rails", present, "deliberately_missing_rails", missing, "path", pathCoords(link)));
}
return result;
}
private static int routeSpan(HeritagePlan19.Link link) {
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow()
- link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow()
- link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
return Math.max(spanX, spanZ);
}
private static List<List<Integer>> pathCoords(HeritagePlan19.Link link) {
return link.path().stream().map(point -> List.of(point.x(), point.floorY(), point.z())).toList();
}
private static int verifySiteBearing(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan, HeritagePlan19.Site site) {
int checked = 0;
// The planner certifies a nine-point bearing grid, either directly below its slab or below a saved short footing.
for (int dx : new int[] {1, 8, 15}) for (int dz : new int[] {1, 8, 15}) {
int x = site.minX() + dx, z = site.minZ() + dz;
var support = site.supports().stream().filter(s -> s.x() == x && s.z() == z).findFirst();
int bottom = support.map(HeritagePlan19.Support::minY).orElse(site.minY());
for (int below = 1; below <= 2; below++) {
var at = new BlockPos(x, bottom - below, z); assertRock(helper, at);
helper.assertTrue(plan.protects(at.getX(), at.getY(), at.getZ()) && generator.protectsCityDecoration(at),
"Site's certified bearing layer is not published in the decoration mask: " + site.id() + " at " + at);
helper.assertTrue(!plan.contains(at.getX(), at.getY(), at.getZ()), "Original bearing rock became a writable heritage volume: " + at);
checked++;
}
}
return checked;
}
private static List<Map<String, Object>> verifyReadOnlyLandings(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan) {
var used = new HashSet<String>(); plan.links().forEach(link -> { used.add(link.fromId()); used.add(link.toId()); });
var result = new ArrayList<Map<String, Object>>();
for (var landmark : plan.landmarks()) if (used.contains(landmark.id())) {
var e = landmark.entry(); int checked = 0;
for (int side = -1; side <= 1; side++) {
int x = e.x() + ((e.facing() & 1) == 0 ? side : 0), z = e.z() + ((e.facing() & 1) == 1 ? side : 0);
for (int y = e.y() - 2; y <= e.y() + 2; y++) {
var at = new BlockPos(x, y, z);
helper.assertTrue(plan.protects(x, y, z) && generator.protectsCityDecoration(at), "Read-only arrival lost its decoration mask: " + landmark.id() + " at " + at);
helper.assertTrue(!plan.contains(x, y, z), "Heritage gained write ownership of a historical arrival: " + landmark.id() + " at " + at);
if (y == e.y() - 1) assertRock(helper, at);
if (y >= e.y()) helper.assertTrue(helper.getLevel().getBlockState(at).isAir(), "Decoration obstructed a certified historical arrival: " + at);
checked++;
}
}
result.add(Map.of("id", landmark.id(), "entry", List.of(e.x(), e.y(), e.z()), "read_only_cells", checked));
}
return result;
}
private static void assertRock(GameTestHelper helper, BlockPos at) {
helper.assertTrue(helper.getLevel().getBlockState(at).isSolid() && helper.getLevel().getFluidState(at).isEmpty(), "Lost original rock bearing at " + at);
}
private static void verifyLootTables(GameTestHelper helper, Map<String, Object> report) {
var result = new ArrayList<Map<String, Object>>(); var level = helper.getLevel();
var params = new LootParams.Builder(level).withParameter(LootContextParams.ORIGIN, new Vec3(0, 160, 0)).create(LootContextParamSets.CHEST);
for (var kind : HeritagePlan19.Kind.values()) {
var table = level.getServer().reloadableRegistries().getLootTable(lootKey(kind));
helper.assertTrue(table != LootTable.EMPTY, "Heritage tool table did not load: " + kind);
for (long seed : new long[] {0, 42, -1}) helper.assertTrue(table.getRandomItems(params, seed).stream().anyMatch(stack -> stack.is(tool(kind))), "Tool table is empty or lacks its expected primary tool: " + kind);
result.add(Map.of("kind", kind.name(), "table", lootKey(kind).identifier().toString(), "sampled_seeds", List.of(0, 42, -1)));
}
report.put("tool_tables_sampled_separately", result);
}
private static ResourceKey<LootTable> lootKey(HeritagePlan19.Kind kind) { return ResourceKey.create(Registries.LOOT_TABLE, SanctuaryMod.id("chests/heritage19/" + kind.name().toLowerCase(Locale.ROOT))); }
private static Item tool(HeritagePlan19.Kind kind) {
return switch (kind) {
case OBSERVATORY -> Items.SPYGLASS;
case POST_OFFICE, EXPEDITION_OFFICE, RADIO_RELAY -> Items.COMPASS;
case SUBSTATION, IMPOSSIBLE_SERVICE -> Items.CLOCK;
case FREIGHT_STATION -> Items.MINECART;
case COMMUNAL_DEPOT -> Items.IRON_AXE;
case RAIL_WORKSHOP -> Items.IRON_PICKAXE;
case PUMP_STATION -> Items.BUCKET;
case GREENHOUSE -> Items.IRON_HOE;
case VOID_DOCK -> Items.LEAD;
case PHOTO_CLUB -> Items.BOOK;
case CONSTRUCTION_REFUGE -> Items.IRON_SHOVEL;
};
}
private static Set<BlockPos> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside) {
var level = helper.getLevel(); helper.assertTrue(inside.test(start) && standable(level, start), "Blocked or unsupported exterior entrance: " + start);
var seen = new HashSet<BlockPos>(); var queue = new ArrayDeque<BlockPos>(); seen.add(start); queue.add(start);
while (!queue.isEmpty()) {
var pos = queue.removeFirst();
for (var direction : HORIZONTAL) for (int dy = -1; dy <= 1; dy++) {
var next = pos.relative(direction).offset(0, dy, 0);
if (!inside.test(next) || dy > 0 && !clearBody(level, pos.above())) continue;
if (standable(level, next) && seen.add(next)) queue.add(next);
}
for (int dy : new int[] {-1, 1}) {
var next = pos.offset(0, dy, 0); if (!inside.test(next)) continue;
if ((level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(next).is(Blocks.LADDER) || level.getBlockState(pos.below()).is(Blocks.LADDER))
&& standable(level, next) && seen.add(next)) queue.add(next);
}
helper.assertTrue(seen.size() <= 50_000, "Native site traversal escaped its bounds");
}
return seen;
}
private static boolean standable(ServerLevel level, BlockPos pos) {
if (!clearBody(level, pos)) return false;
if (level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(pos.below()).is(Blocks.LADDER)) return true;
var below = pos.below(); return level.getBlockState(below).getCollisionShape(level, below).toAabbs().stream()
.anyMatch(b -> b.maxY >= .5 && b.minX <= .5 && b.maxX >= .5 && b.minZ <= .5 && b.maxZ >= .5);
}
private static boolean clearBody(ServerLevel level, BlockPos pos) {
var body = new AABB(pos.getX() + .2, pos.getY(), pos.getZ() + .2, pos.getX() + .8, pos.getY() + 1.8, pos.getZ() + .8);
for (var at : new BlockPos[] {pos, pos.above()}) {
if (!level.getFluidState(at).isEmpty()) return false;
for (var shape : level.getBlockState(at).getCollisionShape(level, at).toAabbs()) if (shape.move(at.getX(), at.getY(), at.getZ()).intersects(body)) return false;
}
return true;
}
private static long column(int x, int z) { return (long) x << 32 ^ z & 0xffffffffL; }
private static List<Integer> coords(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
private static BlockPos readPos(com.google.gson.JsonArray value) { return new BlockPos(value.get(0).getAsInt(), value.get(1).getAsInt(), value.get(2).getAsInt()); }
private static Path world(ServerLevel level) { return level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); }
private static Path proof(ServerLevel level) { return world(level).resolve("heritage21-create-proof.json"); }
private static String planHash(HeritagePlan19 plan) { return hash(plan.sites().toString() + plan.landmarks() + plan.links() + plan.segments()); }
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
var path = Path.of("diagnostics", "heritage21-" + (reload ? "reload" : "create") + ".json"); Files.createDirectories(path.getParent());
var value = new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n"; Files.writeString(path, value); if (!reload) Files.writeString(proof(level), value);
}
private static String hash(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
}
}
@@ -0,0 +1,263 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.IslandCapacity;
import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.Terrain25Runtime;
import fr.koka.sanctuary.worldgen.Terrain25Density;
import fr.koka.sanctuary.worldgen.Terrain25Shape;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.TicketType;
import net.minecraft.tags.FluidTags;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
/** Disposable native world; reads scheduled vanilla fluid movement without scheduling extra ticks. */
public final class Hydrology25WorldGameTests {
private static final int[][] NEIGHBOURS = {{1,0,0},{-1,0,0},{0,0,1},{0,0,-1},{0,-1,0},{0,1,0}};
private record BasinWitness(Terrain25Shape.Chamber chamber, List<BlockPos> surface, List<BlockPos> dryBanks) {}
private record FlowWitness(int sources, int visited, int minimumFlowingY, List<Integer> lowest,
boolean reachedFlank, boolean descended, boolean searchBoundReached,
int minimumConnectedWaterY, List<Integer> lowestConnectedWater,
boolean reachedY0, String observedExtent) {}
@GameTest(maxTicks = 16000)
public void nativeRiverFallsAndRetainedBasins(GameTestHelper helper) throws Exception {
var report = new LinkedHashMap<String,Object>();
try { begin(helper, report); }
catch (Exception error) { failure(report,error); throw error; }
}
private static void begin(GameTestHelper helper, Map<String,Object> report) {
var level = helper.getLevel();
var generator = (SanctuaryChunkGenerator)level.getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 25, "Hydrology witness requires a disposable generation25 world");
int expected = Integer.getInteger("sanctuary.test.hydrology25.expectedFalls", -1);
helper.assertTrue(expected >= 0 && expected <= 2, "Specify the expected 0, 1 or 2 river outlets for this witness");
int observationTicks = Integer.getInteger("sanctuary.test.hydrology25.observationTicks",600);
helper.assertTrue(observationTicks >= 600 && observationTicks <= 3600,
"Hydrology observation must be between600 and3600 native game ticks");
var starter = generator.starterContext();
var capacity = Objects.requireNonNull(IslandCapacity.forGenerator(starter.generator()));
var basins = Terrain25Runtime.admittedLakes(starter.generator(),starter.random());
var density = Terrain25Runtime.view(starter.generator(),starter.random());
var falls = new LinkedHashSet<Terrain25Runtime.Fall>();
var chunks = new TreeSet<ChunkPos>(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z));
report.put("seed",level.getSeed()); report.put("diameter",generator.initialDiameter());
report.put("expected_falls",expected); report.put("process_id",ProcessHandle.current().pid());
report.put("requested_observation_ticks",observationTicks);
var first = capacity.regionAt(-capacity.terrainLimit(),-capacity.terrainLimit());
var last = capacity.regionAt(capacity.terrainLimit(),capacity.terrainLimit());
for (int rx=first.x();rx<=last.x();rx++) for (int rz=first.z();rz<=last.z();rz++) {
var region = new IslandCapacity.Region(rx,rz);
double nx=Math.max(0,Math.max(region.minX(),-region.maxX()));
double nz=Math.max(0,Math.max(region.minZ(),-region.maxZ()));
if (Math.hypot(nx,nz)>=capacity.terrainLimit()) continue;
falls.addAll(Terrain25Runtime.falls(starter.generator(),starter.random(),region));
var water=PopulationHydrologyRuntime.regionPlan(starter.generator(),starter.random(),region).water();
// Complete wet footprints, not just a lake's centre chunk. Current FULL chunks are read,
// never reset or regenerated. Chunk tickets additionally cover their simulation neighbours.
for (var cell:water.cells()) if(cell.hasWater()) addBounds(chunks,cell.x()-1,cell.z()-1,cell.x()+1,cell.z()+1);
for (var spring:water.springs()) {
var b=spring.flowBounds();addBounds(chunks,b.minX(),b.minZ(),b.maxX(),b.maxZ());
}
for (var spill:water.spills()) {
var b=spill.flowBounds();addBounds(chunks,b.minX(),b.minZ(),b.maxX(),b.maxZ());
}
}
helper.assertTrue(falls.size()==expected,"Immutable river plan has exactly the expected number of suitable exits: "+falls.size());
report.put("fall_plan",List.copyOf(falls)); report.put("admitted_basins",basins);
helper.assertTrue(basins.stream().anyMatch(c->c.kind()==Terrain25Shape.Kind.LAKE),"A retained water lake must actually be present in the witness");
if (expected>0) helper.assertTrue(basins.stream().anyMatch(c->c.kind()==Terrain25Shape.Kind.LAVA),
"The flowing witnesses must also contain an admitted lava lake for physical separation checks");
for (var basin:basins) addBounds(chunks,(int)Math.floor(basin.x()-basin.rx()-8),(int)Math.floor(basin.z()-basin.rz()-8),
(int)Math.ceil(basin.x()+basin.rx()+8),(int)Math.ceil(basin.z()+basin.rz()+8));
for (var fall:falls) addBounds(chunks,fall.minX()-7,fall.minZ()-7,fall.maxX()+7,fall.maxZ()+7);
report.put("requested_full_chunks",chunks.size());
var iterator=chunks.iterator();
var witnesses=new ArrayList<BasinWitness>();
var flowHistory=new ArrayList<Map<String,Object>>(); report.put("flow_history",flowHistory);
long started=System.nanoTime(); long[] fullNanos={0}; int[] fullCalls={0},wait={0};
long[] tickingStart={-1}; boolean[] ticketed={false};
Map<String,Object> schedulerStart=FluidTiming25.snapshot(); report.put("scheduler_before",schedulerStart);
Runnable release=()->{
if(ticketed[0])chunks.forEach(cp->ChunkTickets25.remove(level, cp));
ticketed[0]=false;
};
Runnable[] step=new Runnable[1];
step[0]=()->{
try {
if(iterator.hasNext()) {
var cp=iterator.next();report.put("stage","FULL wet footprint "+cp);
long start=System.nanoTime();level.getChunk(cp.x(),cp.z(),ChunkStatus.FULL,true);
fullNanos[0]+=System.nanoTime()-start;fullCalls[0]++;
helper.runAtTickTime(helper.getTick()+1,()->step[0].run());return;
}
if(!ticketed[0]) {
chunks.forEach(cp->ChunkTickets25.add(level, cp));ticketed[0]=true;
}
boolean ready=chunks.stream().allMatch(cp->level.areEntitiesLoaded(cp.pack())&&level.areEntitiesActuallyLoadedAndTicking(cp));
if(!ready) {
report.put("stage","wait for native fluid simulation chunks");report.put("readiness_wait_ticks",wait[0]);
helper.assertTrue(wait[0]++<6000,"All wet footprints and fall columns become natively loaded and ticking");
helper.runAtTickTime(helper.getTick()+1,()->step[0].run());return;
}
if(tickingStart[0]<0) {
tickingStart[0]=helper.getTick();report.put("stage","record retained waterlines before observation");
for(var basin:basins)witnesses.add(captureBasin(helper,level,basin));
report.put("basins_before",basinState(helper,level,witnesses,false));
report.put("full_get_chunk_calls",fullCalls[0]);report.put("full_get_chunk_ms",fullNanos[0]/1_000_000);
report.put("full_loop_elapsed_ms",(System.nanoTime()-started)/1_000_000);
}
long elapsed=helper.getTick()-tickingStart[0];report.put("observation_ticks",elapsed);
report.put("stage","observe unchanged native fluid scheduler");
var flows=falls.stream().map(fall->Map.of("fall",fall,"actual_flow",traceFall(level,density,fall,false))).toList();
flowHistory.add(Map.of("tick",helper.getTick(),"flows",flows));
boolean flowing=flows.stream().allMatch(row->((FlowWitness)row.get("actual_flow")).descended());
if(elapsed<observationTicks||!flowing) {
helper.assertTrue(elapsed<6000,"Every admitted river outlet produces connected flowing water down at least16 blocks, or reaches a natural flank after6");
helper.runAtTickTime(helper.getTick()+100,()->step[0].run());return;
}
report.put("basins_after",basinState(helper,level,witnesses,true));
// Unlike the admission trace, the final read does not stop after16 blocks.
// A natural flank is a valid observation, not an obligation to reach the void.
report.put("final_flow_extents",falls.stream().map(fall->Map.of("fall",fall,
"actual_flow",traceFall(level,density,fall,true))).toList());
report.put("final_flow_extent_scope","Lowest connected water observed at the end inside each outlet's existing X/Z footprint+7, Y0..waterY; horizontal/downward traversal only, at most120000 cells. A natural-flank result does not imply the flow has permanently stopped. No Y0 requirement.");
Map<String,Object> schedulerEnd=FluidTiming25.snapshot();report.put("scheduler_after",schedulerEnd);
long executed=((Number)schedulerEnd.get("executed_fluid_ticks")).longValue()-((Number)schedulerStart.get("executed_fluid_ticks")).longValue();
report.put("executed_native_fluid_ticks",executed);
helper.assertTrue(executed>0,"Actual native fluid ticks were observed; no synthetic scheduler or manual spread");
report.put("observed_server_ticks",helper.getTick());report.put("elapsed_ms",(System.nanoTime()-started)/1_000_000);
report.put("heap_used",Runtime.getRuntime().totalMemory()-Runtime.getRuntime().freeMemory());
report.put("passed",true);write(report,"full.json");release.run();helper.succeed();
} catch(Exception error) {
release.run();failure(report,error);throw new IllegalStateException("Hydrology25 failed: "+error.getMessage(),error);
}
};
helper.runAtTickTime(helper.getTick()+1,()->step[0].run());
}
private static BasinWitness captureBasin(GameTestHelper helper,ServerLevel level,Terrain25Shape.Chamber chamber) {
var surface=new ArrayList<BlockPos>();var banks=new ArrayList<BlockPos>();
int minX=(int)Math.floor(chamber.x()-chamber.rx()-3),maxX=(int)Math.ceil(chamber.x()+chamber.rx()+3);
int minZ=(int)Math.floor(chamber.z()-chamber.rz()-3),maxZ=(int)Math.ceil(chamber.z()+chamber.rz()+3);
for(int x=minX;x<=maxX;x++)for(int z=minZ;z<=maxZ;z++) {
var pos=new BlockPos(x,chamber.fluidY(),z);
// The immutable plan defines the baseline, including cells already missing at FULL.
// Filtering by present fluid here would hide losses before the observation starts.
if(chamber.cut(x,chamber.fluidY(),z)>0) surface.add(pos);
if(banks.size()>=64||Math.floorMod(x+z,3)!=0||chamber.horizontal(x,z)<.55||chamber.horizontal(x,z)>1.2)continue;
// A dry, walkable real bank; no artificial platform or fluid alteration in the test.
for(int y=chamber.fluidY();y<=chamber.fluidY()+8;y++) {
var floor=new BlockPos(x,y,z);
if(chamber.cut(x,y,z)>0||!level.getBlockState(floor).isSolid()||!level.getBlockState(floor.above()).isAir()
||!level.getBlockState(floor.above(2)).isAir())continue;
if(level.getFluidState(floor.above()).isEmpty()&&level.getFluidState(floor.above(2)).isEmpty()) {banks.add(floor);break;}
}
}
helper.assertTrue(surface.size()>=50,"Admitted "+chamber.kind()+" basin "+chamber.id()+" plans at least50 surface fluid blocks, got "+surface.size());
helper.assertTrue(!banks.isEmpty(),"Admitted basin "+chamber.id()+" has a real accessible dry bank");
return new BasinWitness(chamber,List.copyOf(surface),List.copyOf(banks));
}
private static List<Map<String,Object>> basinState(GameTestHelper helper,ServerLevel level,List<BasinWitness> witnesses,boolean after) {
var rows=new ArrayList<Map<String,Object>>();
for(var witness:witnesses) {
var c=witness.chamber();int retained=0,supported=0,dry=0;
for(var pos:witness.surface()) {
if(matches(level,pos,c.kind()))retained++;
if(level.getBlockState(new BlockPos(pos.getX(),c.floorY(),pos.getZ())).isSolid()
&&level.getBlockState(new BlockPos(pos.getX(),c.floorY()-1,pos.getZ())).isSolid())supported++;
}
for(var pos:witness.dryBanks()) if(level.getBlockState(pos).isSolid()
&&level.getFluidState(pos.above()).isEmpty()&&level.getFluidState(pos.above(2)).isEmpty())dry++;
var contact=unexpectedContact(level,c);
rows.add(Map.of("id",c.id(),"kind",c.kind(),"baseline_surface",witness.surface().size(),"retained_surface",retained,
"supported_columns",supported,"dry_banks",dry,"baseline_dry_banks",witness.dryBanks().size(),"unexpected_contact",contact));
helper.assertTrue(contact.isEmpty(),"Water and lava remain separated around admitted basin "+c.id()+": "+contact);
helper.assertTrue(supported==witness.surface().size(),"Natural lake floor remains continuous at two depths: "+c.id());
helper.assertTrue(retained==witness.surface().size(),(after?"After vanilla fluid ticks":"Before observation")+", retained lake waterline is intact: "+c.id());
helper.assertTrue(dry==witness.dryBanks().size(),"The observed dry banks remain solid and free of leaking fluids: "+c.id());
}
return rows;
}
private static List<Integer> unexpectedContact(ServerLevel level,Terrain25Shape.Chamber c) {
for(int x=(int)(c.x()-c.rx()-3);x<=c.x()+c.rx()+3;x++)for(int z=(int)(c.z()-c.rz()-3);z<=c.z()+c.rz()+3;z++) {
if(c.horizontal(x,z)>1.3)continue;
for(int y=c.floorY();y<=c.fluidY()+3;y++) {
var pos=new BlockPos(x,y,z);var fluid=level.getFluidState(pos);
if(!fluid.is(FluidTags.WATER)&&!fluid.is(FluidTags.LAVA))continue;
for(var d:NEIGHBOURS) {
var other=level.getFluidState(pos.offset(d[0],d[1],d[2]));
if(fluid.is(FluidTags.WATER)&&other.is(FluidTags.LAVA)||fluid.is(FluidTags.LAVA)&&other.is(FluidTags.WATER))return coords(pos);
}
}
}
return List.of();
}
private static FlowWitness traceFall(ServerLevel level,Terrain25Density.View density,Terrain25Runtime.Fall fall,boolean fullExtent) {
var queue=new ArrayDeque<BlockPos>();var seen=new HashSet<BlockPos>();
int sources=0,minY=fall.waterY(),minConnectedY=fall.waterY();
BlockPos lowest=null,lowestConnected=null;boolean flank=false;
for(int x=fall.minX();x<=fall.maxX();x++)for(int z=fall.minZ();z<=fall.maxZ();z++) {
if(!fall.channel(x,z)||fall.progress(x,z)<.55||fall.progress(x,z)>.95)continue;
for(int y=fall.waterY()-2;y<=fall.waterY();y++) {
var p=new BlockPos(x,y,z);var f=level.getFluidState(p);
if(f.is(FluidTags.WATER)&&seen.add(p)){queue.addLast(p);if(f.isSource())sources++;}
}
}
// A read-only connectivity search proves the low flowing water belongs to this outlet.
// Only horizontal/downstream neighbours are traversed; an unrelated upper pool cannot count.
while(!queue.isEmpty()&&seen.size()<120_000) {
var pos=queue.removeFirst();var fluid=level.getFluidState(pos);
if(lowestConnected==null||pos.getY()<minConnectedY){minConnectedY=pos.getY();lowestConnected=pos;}
if(!fluid.isSource()&&pos.getY()<minY) {minY=pos.getY();lowest=pos;}
if(!fluid.isSource()&&pos.getY()<=fall.waterY()-6&&level.getBlockState(pos.below()).isSolid()
&&density.natural(SamplerContext.EMPTY_UNCACHED,pos.getX(),pos.getY()-1,pos.getZ())>0)flank=true;
if(!fullExtent&&minY<=fall.waterY()-16)break;
for(int i=0;i<5;i++) {
var d=NEIGHBOURS[i];var n=pos.offset(d[0],d[1],d[2]);
if(n.getX()<fall.minX()-7||n.getX()>fall.maxX()+7||n.getZ()<fall.minZ()-7||n.getZ()>fall.maxZ()+7
||n.getY()<0||n.getY()>fall.waterY()||fall.progress(n.getX(),n.getZ())<.45
||seen.contains(n)||!level.getFluidState(n).is(FluidTags.WATER))continue;
seen.add(n);queue.addLast(n);
}
}
boolean y0=lowestConnected!=null&&minConnectedY==0;
String extent=y0?"water_reached_y0":flank?"water_observed_on_natural_flank_above_y0"
:minY<=fall.waterY()-16?"descending_above_y0":"no_confirmed_descent";
return new FlowWitness(sources,seen.size(),minY,lowest==null?List.of():coords(lowest),flank,
minY<=fall.waterY()-16||flank,seen.size()>=120_000,
lowestConnected==null?-1:minConnectedY,lowestConnected==null?List.of():coords(lowestConnected),y0,extent);
}
private static boolean matches(ServerLevel level,BlockPos pos,Terrain25Shape.Kind kind) {
return level.getFluidState(pos).is(kind==Terrain25Shape.Kind.LAVA?FluidTags.LAVA:FluidTags.WATER);
}
private static void addBounds(Set<ChunkPos> target,int minX,int minZ,int maxX,int maxZ) {
for(int cx=minX>>4;cx<=maxX>>4;cx++)for(int cz=minZ>>4;cz<=maxZ>>4;cz++)target.add(new ChunkPos(cx,cz));
}
private static List<Integer> coords(BlockPos pos) {return List.of(pos.getX(),pos.getY(),pos.getZ());}
private static void write(Map<String,Object> report,String file) throws java.io.IOException {
var directory=Path.of("diagnostics/hydrology25",report.get("diameter")+"-"+report.get("seed"));
Files.createDirectories(directory);Files.writeString(directory.resolve(file),new GsonBuilder().setPrettyPrinting().create().toJson(report));
}
private static void failure(Map<String,Object> report,Exception error) {
report.put("passed",false);report.put("failure",error.toString());report.put("scheduler_at_failure",FluidTiming25.snapshot());
var stack=new java.io.StringWriter();error.printStackTrace(new java.io.PrintWriter(stack));report.put("stacktrace",stack.toString());
SanctuaryMod.LOGGER.error("Hydrology25 failed at {}",report.get("stage"),error);
try {write(report,"failure.json");}catch(java.io.IOException failed){error.addSuppressed(failed);}
}
}
@@ -0,0 +1,166 @@
package fr.koka.sanctuary.gametest;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import fr.koka.sanctuary.knowledge.BlockKnowledgeService;
import fr.koka.sanctuary.knowledge.census.WorldCensusService;
import fr.koka.sanctuary.knowledge.history.MaterialActivity;
import fr.koka.sanctuary.knowledge.history.MaterialActivityHistory;
import fr.koka.sanctuary.knowledge.history.MaterialActivityStore;
import java.io.IOException;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.commands.CommandSource;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.stats.Stats;
import net.minecraft.util.Prediction;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.item.context.UseOnContext;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.LevelResource;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.Vec3;
/** Activity uses native action callbacks and stays separate from current stock and discovery. */
public final class MaterialActivityGameTests {
@GameTest(maxTicks = 100)
public void nativeActionsProduceDatedDeltasWhileObservationsProduceNone(GameTestHelper helper) throws IOException {
var level = helper.getLevel();
var server = level.getServer();
LocalDate day = LocalDate.now(ZoneOffset.UTC);
var before = MaterialActivityHistory.snapshot(server, day);
var originalCounts = Map.copyOf(before.counts());
ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL);
player.setPos(helper.absoluteVec(new Vec3(5.5, 3, 5.5)));
// Giving inventory, restoring old vanilla totals and reading observations are not flows.
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.OAK_LOG, 5));
player.getStats().setValue(player, Stats.BLOCK_MINED.get(Blocks.GOLD_BLOCK), 99);
player.getStats().setValue(player, Stats.ITEM_DROPPED.get(Items.GOLD_INGOT), 999);
BlockKnowledgeService.observeInventory(player);
BlockKnowledgeService.snapshot(player);
WorldCensusService.capture(server);
helper.assertTrue(MaterialActivityHistory.snapshot(server, day).counts().equals(before.counts()),
"Inventory grants, historical totals and census/discovery reads must not invent dated activity");
BlockPos minedPos = helper.absolutePos(new BlockPos(3, 1, 1));
level.setBlockAndUpdate(minedPos, Blocks.STONE.defaultBlockState());
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.DIAMOND_PICKAXE));
helper.assertTrue(player.gameMode.destroyBlock(minedPos), "The player must harvest stone with a suitable tool");
level.setBlockAndUpdate(minedPos, Blocks.STONE.defaultBlockState());
player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY);
player.gameMode.destroyBlock(minedPos);
BlockPos support = helper.absolutePos(new BlockPos(1, 1, 1));
level.setBlockAndUpdate(support, Blocks.STONE.defaultBlockState());
level.setBlockAndUpdate(support.above(), Blocks.AIR.defaultBlockState());
helper.assertTrue(place(player, Items.COBBLESTONE, support), "The native cobblestone placement must succeed");
BlockPos badSupport = helper.absolutePos(new BlockPos(1, 1, 3));
level.setBlockAndUpdate(badSupport, Blocks.STONE.defaultBlockState());
level.setBlockAndUpdate(badSupport.above(), Blocks.AIR.defaultBlockState());
helper.assertTrue(!place(player, Items.OAK_SAPLING, badSupport), "Stone must refuse the native sapling placement");
// Non-block items exercise the general material history rather than the block-only codex.
var item = new ItemEntity(level, player.getX(), player.getY(), player.getZ(), new ItemStack(Items.DIAMOND, 4));
item.setNoPickUpDelay();
level.addFreshEntity(item);
item.playerTouch(player);
helper.assertTrue(item.isRemoved(), "The native item pickup must consume the complete ground stack");
int diamondSlot = player.getInventory().findSlotMatchingItem(new ItemStack(Items.DIAMOND));
helper.assertTrue(diamondSlot >= 0, "The picked-up diamonds must now occupy a player inventory slot");
ItemStack dropped = player.getInventory().removeItem(diamondSlot, 3);
helper.assertTrue(dropped.getCount() == 3, "The native drop fixture must remove exactly three held items");
player.drop(dropped, true, Prediction.PREDICTED);
player.getInventory().dropAll();
// This is vanilla's successful craft-completion callback, not a simulated recipe match.
new ItemStack(Items.BREAD, 3).onCraftedBy(player, 3);
var after = MaterialActivityHistory.snapshot(server, day);
helper.assertTrue(delta(before, after, "mined", "block", "minecraft:stone") == 1,
"Only the successful vanilla harvest may add a mined-block activity");
helper.assertTrue(delta(before, after, "placed", "block", "minecraft:cobblestone") == 1
&& delta(before, after, "placed", "block", "minecraft:oak_sapling") == 0,
"Only confirmed placement may contribute to the dated placement count");
helper.assertTrue(delta(before, after, "picked_up", "item", "minecraft:diamond") == 4,
"A native pickup must record non-block item units exactly once");
helper.assertTrue(delta(before, after, "dropped", "item", "minecraft:diamond") == 3,
"Voluntary drop units must be recorded without including the death-drop path");
helper.assertTrue(delta(before, after, "crafted", "item", "minecraft:bread") == 3,
"Vanilla's craft-completion callback must record its reported output quantity");
helper.assertTrue(after.recorded() && after.unsaved(), "New activity must be identified as recorded and awaiting save");
WorldCensusService.capture(server);
BlockKnowledgeService.snapshot(player);
helper.assertTrue(MaterialActivityHistory.snapshot(server, day).counts().equals(after.counts()),
"Subsequent stock and knowledge reads must not duplicate historical activity");
boolean immutable = false;
try { after.counts().clear(); }
catch (UnsupportedOperationException expected) { immutable = true; }
helper.assertTrue(immutable, "Activity consumers must receive immutable day snapshots");
helper.assertTrue(before.counts().equals(originalCounts),
"The earlier immutable snapshot must retain its original totals");
MaterialActivityHistory.flush(server);
var stored = new MaterialActivityStore(server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-activity"), day).load();
helper.assertTrue(stored.equals(after.counts()), "A fresh store must read the exact flushed native activity totals");
helper.assertTrue(!MaterialActivityHistory.snapshot(server, day).unsaved(), "Successful flush must clear the unsaved status");
helper.succeed();
}
@GameTest(maxTicks = 100)
public void unrecordedDaysRemainUnknownRatherThanZeroActivity(GameTestHelper helper) throws IOException {
LocalDate missingDay = LocalDate.now(ZoneOffset.UTC).minusYears(1000);
var snapshot = MaterialActivityHistory.snapshot(helper.getLevel().getServer(), missingDay);
helper.assertTrue(!snapshot.recorded() && !snapshot.unsaved() && snapshot.counts().isEmpty(),
"An absent day must be explicitly unrecorded, without manufacturing a zero-activity record");
helper.succeed();
}
@GameTest(maxTicks = 100)
public void registeredCensusAndHistoryCommandsAcceptNamespacedIdsAndDates(GameTestHelper helper) throws CommandSyntaxException {
var server = helper.getLevel().getServer();
var source = server.createCommandSourceStack().withLevel(helper.getLevel())
.withSource(CommandSource.NULL).withSuppressedOutput();
var dispatcher = server.getCommands().getDispatcher();
String today = LocalDate.now(ZoneOffset.UTC).toString();
String missing = LocalDate.now(ZoneOffset.UTC).minusYears(1000).toString();
for (String command : List.of("sanctuary census", "sanctuary census block minecraft:stone",
"sanctuary census item minecraft:diamond", "sanctuary history",
"sanctuary history " + today, "sanctuary history " + missing)) {
var parsed = dispatcher.parse(command, source);
helper.assertTrue(!parsed.getReader().canRead(),
"The registered dispatcher must consume the complete namespaced identifier or date: " + command);
helper.assertTrue(dispatcher.execute(parsed) == 1,
"The registered read-only census/history command must execute successfully: " + command);
}
helper.assertTrue(dispatcher.execute("sanctuary census block minecraft:missing_census_material", source) == 0,
"An unknown material must fail explicitly instead of claiming a valid zero count");
helper.assertTrue(dispatcher.execute("sanctuary history not-a-date", source) == 0,
"An invalid history date must fail with the native command's validation result");
helper.succeed();
}
private static long delta(MaterialActivityHistory.Snapshot before, MaterialActivityHistory.Snapshot after,
String action, String registry, String id) {
var key = new MaterialActivity(action, registry, id);
return after.counts().getOrDefault(key, 0L) - before.counts().getOrDefault(key, 0L);
}
private static boolean place(ServerPlayer player, Item item, BlockPos support) {
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(item));
var hit = new BlockHitResult(Vec3.atCenterOf(support), Direction.UP, support, false);
return ((BlockItem) item).place(new BlockPlaceContext(new UseOnContext(player, InteractionHand.MAIN_HAND, hit))).consumesAction();
}
}
@@ -0,0 +1,150 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
import fr.koka.sanctuary.worldgen.mineral.MineralSanctuaryPiece23;
import fr.koka.sanctuary.worldgen.mineral.MineralSanctuaryPlan23;
import fr.koka.sanctuary.worldgen.mineral.MineralSanctuaryRenderer25;
import fr.koka.sanctuary.worldgen.mineral.MineralSanctuaryStructures23;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.StairBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.loot.LootTable;
/** Native block and NBT checks for an accepted vault; never turns conditional absence into presence evidence. */
public final class Mineral25NativeChecks {
private Mineral25NativeChecks() {}
public static void verify(GameTestHelper helper, Map<String, Object> report) {
var level = helper.getLevel();
helper.assertTrue(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator host
&& host.rootGeneration() == 25, "Mineral25 checks require the actual generation25 world");
var generator = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
var plan = generator.mineralSanctuary23();
var evidence = new LinkedHashMap<String, Object>();
report.put("mineral_sanctuary25", evidence);
evidence.put("presence_verified", false); evidence.put("plan_diagnostics", plan.diagnostic());
evidence.put("plan_columns", plan.columns()); evidence.put("plan_samples", plan.samples());
var biomeRegistry = level.registryAccess().lookupOrThrow(Registries.BIOME);
int tagMemberships = 0;
for (String family : List.of("forgotten_networks", "heritage_networks")) {
var key = net.minecraft.tags.TagKey.create(Registries.BIOME,
net.minecraft.resources.Identifier.fromNamespaceAndPath("sanctuary", "has_structure/" + family));
var allowed = biomeRegistry.getOrThrow(key);
for (String name : fr.koka.sanctuary.worldgen.Terrain25Biomes.NAMES) {
var biome = biomeRegistry.getOrThrow(fr.koka.sanctuary.worldgen.Terrain25Biomes.key(name));
helper.assertTrue(allowed.contains(biome), "Every generation25 clone remains eligible for " + family + ": " + name);
tagMemberships++;
}
}
evidence.put("terrain25_structure_tag_memberships_verified", tagMemberships);
if (plan.isEmpty()) {
evidence.put("status", "not_admitted");
evidence.put("reason", !level.structureManager().shouldGenerateStructures() || !generator.experimentalStructures()
? "structures_disabled" : "no_suitable_candidate");
return;
}
evidence.put("status", "checking_accepted_plan");
var c = plan.chamber();
evidence.put("chamber", List.of(c.minX(), c.minY(), c.minZ(), c.width(), c.height(), c.depth(), c.facing()));
var chunks = c.bounds().intersectingChunks().toList();
for (var pos : chunks) helper.assertTrue(level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true) instanceof LevelChunk,
"Accepted mineral geometry is checked in genuinely FULL chunks: " + pos);
evidence.put("full_chunks", chunks.size());
var type = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(MineralSanctuaryStructures23.KEY).value();
var sourceChunk = level.getChunk(c.startChunkX(), c.startChunkZ());
var start = sourceChunk.getStartForStructure(type);
evidence.put("start_chunk", List.of(c.startChunkX(), c.startChunkZ()));
evidence.put("native_start_present", start != null);
evidence.put("native_start_valid", start != null && start.isValid());
evidence.put("native_piece_count", start == null ? 0 : start.getPieces().size());
evidence.put("starts_in_chunk", sourceChunk.getAllStarts().entrySet().stream().map(entry -> Map.of(
"id", level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getKey(entry.getKey()).toString(),
"valid", entry.getValue().isValid(), "pieces", entry.getValue().getPieces().size())).toList());
var atEntrance = level.getBiome(c.locator());
evidence.put("entrance_biome", atEntrance.unwrapKey().orElseThrow().identifier().toString());
evidence.put("entrance_biome_in_structure_tag", type.biomes().contains(atEntrance));
helper.assertTrue(start != null && start.isValid() && start.getPieces().size() == 1,
"An accepted mineral vault has its single native saved piece");
helper.assertTrue(start.getPieces().getFirst() instanceof MineralSanctuaryPiece23 mineral && mineral.chamber().equals(c),
"Native mineral piece exactly matches the accepted immutable geometry");
var context = StructurePieceSerializationContext.fromLevel(level);
var tag = start.createTag(context, start.getChunkPos());
var pieceTag = tag.getList("Children").orElseThrow().getCompound(0).orElseThrow();
helper.assertTrue(pieceTag.getInt("SanctuaryMineralRenderer").orElse(-1) == 25,
"The actually generated piece persists the renderer25 discriminator");
var decoded = StructureStart.loadStaticStart(context, tag.copy(), level.getSeed());
helper.assertTrue(decoded != null && decoded.isValid() && tag.equals(decoded.createTag(context, decoded.getChunkPos())),
"The complete native mineral start survives NBT read/write exactly, without replanning");
helper.assertTrue(decoded.getPieces().getFirst() instanceof MineralSanctuaryPiece23 mineral && mineral.chamber().equals(c),
"NBT re-reading retains the exact mineral chamber");
evidence.put("native_tag", tag.toString()); evidence.put("nbt_round_trip", true);
int bottom = c.floor(c.width() / 2, c.depth() / 2);
int frame = 0, aperture = 0, accessAir = 0;
for (int lateral = -3; lateral <= 3; lateral++) for (int y = bottom; y <= bottom + 8; y++) {
var pos = portalPosition(c, lateral, 0, y);
boolean border = Math.abs(lateral) == 3 || y == bottom || y == bottom + 8;
var actual = level.getBlockState(pos);
helper.assertTrue(border ? actual.is(Blocks.BLACKSTONE) : actual.isAir(),
"The real central frame is 7x9 blackstone around an inactive 5x7 opening: " + pos + "=" + actual);
if (border) frame++; else aperture++;
}
helper.assertTrue(frame == 28 && aperture == 35, "Exact frame and aperture block counts");
for (int lateral = -2; lateral <= 2; lateral++) for (int normal = -2; normal <= 2; normal++) {
var ground = portalPosition(c, lateral, normal, bottom);
helper.assertTrue(level.getBlockState(ground).isCollisionShapeFullBlock(level, ground),
"Both sides of the inactive frame have continuous usable ground: " + ground);
for (int y = bottom + 1; y <= bottom + 7; y++) {
var pos = portalPosition(c, lateral, normal, y);
helper.assertTrue(level.getBlockState(pos).isAir(), "The opening and approaches on both sides stay clear: " + pos);
accessAir++;
}
}
Map<BlockPos, BlockState> expectedStairs = new HashMap<>();
MineralSanctuaryRenderer25.render(c, new LostCityRenderer.BlockWriter() {
@Override public void set(int x, int y, int z, BlockState state) {
if (state.getBlock() instanceof StairBlock) expectedStairs.put(new BlockPos(c.minX() + x, c.minY() + y, c.minZ() + z), state);
}
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> loot, long seed) {
throw new AssertionError("The mineral sanctuary has natural veins, not loot containers");
}
});
helper.assertTrue(!expectedStairs.isEmpty(), "The accepted vault includes actual stone terraces with stairs");
var shapes = new TreeMap<String, Integer>();
for (var entry : expectedStairs.entrySet()) {
var pos = entry.getKey(); var actual = level.getBlockState(pos);
helper.assertTrue(actual.equals(entry.getValue()), "Native placement retains the resolved stair orientation and corner: " + pos + "=" + actual);
helper.assertTrue(actual.is(Blocks.STONE_STAIRS) || actual.is(Blocks.COBBLESTONE_STAIRS), "Mineral stair palette");
helper.assertTrue(level.getBlockState(pos.below()).isCollisionShapeFullBlock(level, pos.below()), "A placed stair has solid natural support: " + pos);
shapes.merge(actual.getValue(StairBlock.SHAPE).getSerializedName(), 1, Integer::sum);
}
int portalBlocks = 0;
for (var pos : BlockPos.betweenClosed(c.minX(), c.minY(), c.minZ(), c.maxX(), c.maxY(), c.maxZ()))
if (level.getBlockState(pos).is(Blocks.NETHER_PORTAL)) portalBlocks++;
helper.assertTrue(portalBlocks == 0, "The mineral vault does not activate any Nether portal");
evidence.put("frame_blocks", frame); evidence.put("aperture_air_blocks", aperture);
evidence.put("clear_access_air_blocks", accessAir); evidence.put("actual_stairs", expectedStairs.size());
evidence.put("actual_stair_shapes", shapes); evidence.put("nether_portal_blocks", portalBlocks);
evidence.put("status", "present_verified"); evidence.put("presence_verified", true);
}
private static BlockPos portalPosition(MineralSanctuaryPlan23.Chamber c, int lateral, int normal, int y) {
return new BlockPos(c.centerX() + (c.facing() % 2 == 0 ? lateral : normal),
c.minY() + y, c.centerZ() + (c.facing() % 2 == 0 ? normal : lateral));
}
}
@@ -0,0 +1,314 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.expansion.*;
import fr.koka.sanctuary.mixin.MonumentChildren23Accessor;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryOps;
import net.minecraft.tags.StructureTags;
import net.minecraft.world.level.chunk.PalettedContainerFactory;
import net.minecraft.world.level.chunk.ProtoChunk;
import net.minecraft.world.level.chunk.UpgradeData;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces;
/** Exact native catalogue and pure admission, followed by real native start serialization in a disposable test world. */
public final class Native23WorldGameTests {
@GameTest(maxTicks = 18000)
public void nativeCatalogueAdmissionAndPersistence(GameTestHelper helper) throws Exception {
try { runNativeLifecycle(helper); }
catch (Exception failure) { writeFailure(helper, Map.of(), failure); throw failure; }
}
private void runNativeLifecycle(GameTestHelper helper) throws Exception {
var level = helper.getLevel();
var generator = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 23, "Requires generation23 disposable development world");
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
var catalogue = ExpansionStructures23.catalogue();
helper.assertTrue(catalogue.size() == 52 && catalogue.stream().map(ExpansionStructureCatalog23.Entry::id).distinct().count() == 52, "All52 distinct built-in structures are classified");
helper.assertTrue(catalogue.stream().filter(e -> e.availability() == ExpansionStructureCatalog23.Availability.OVERWORLD).count() == 46, "46 admissible Overworld structures; ancient city awaits Cavernes");
for (var entry : catalogue) helper.assertTrue(registry.get(Identifier.parse(entry.id())).isPresent(), "Exact26.3-pre2 registry contains " + entry.id());
var nativeIds = new HashSet<String>();
registry.listElements().forEach(h -> { if (h.key().identifier().getNamespace().equals("minecraft")) nativeIds.add(h.key().identifier().toString()); });
helper.assertTrue(nativeIds.equals(catalogue.stream().map(ExpansionStructureCatalog23.Entry::id).collect(java.util.stream.Collectors.toSet())), "Catalogue exactly matches native registry, without omitted structures");
helper.assertTrue(ExpansionStructures23.family("other:custom").equals("unknown"), "Modded structures stay independently identifiable");
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
Structure trial = registry.getValue(Identifier.withDefaultNamespace("trial_chambers"));
var original = Structure.DIRECT_CODEC.encodeStart(ops, trial).getOrThrow().getAsJsonObject();
var moved = Structure.DIRECT_CODEC.encodeStart(ops, ExpansionStructures23.withStartHeight(trial, level.registryAccess(), 172)).getOrThrow().getAsJsonObject();
var originalUnchanged = original.deepCopy(); originalUnchanged.remove("start_height"); moved.remove("start_height");
helper.assertTrue(originalUnchanged.equals(moved), "Only absolute start_height changes; pools, processors, aliases, contents stay native");
helper.assertTrue(original.equals(Structure.DIRECT_CODEC.encodeStart(ops, trial).getOrThrow()), "Registered trial structure is not mutated");
var report = new LinkedHashMap<String, Object>();
report.put("catalogue", catalogue); report.put("generation", generator.rootGeneration()); report.put("world_seed", level.getSeed());
var prior = generator.source().islands();
ExpansionIsland witness = null;
for (int seed = 1; seed <= 8 && witness == null; seed++) {
var candidate = island("admission", 512, seed, "natural").withStructurePriorities(List.of("camp"));
try { ExpansionStructures23.verifyExpansion(level, candidate, deadline(45)); witness = candidate; }
catch (ExpansionStructures23.NoSuitableStructureException refused) { /* Try a different immutable candidate. */ }
}
helper.assertTrue(witness != null, "At least one native structure can be admitted on a temperate expansion");
helper.assertTrue(prior.equals(generator.source().islands()), "Preflight never activates or mutates the island snapshot");
var nodes = ExpansionStructures23.candidates(witness);
helper.assertTrue(nodes.equals(ExpansionStructures23.candidates(witness.ready())), "Candidate anchors do not depend on reserved/ready state");
var emitted = new ArrayList<StructureStart>();
try {
var published = new ArrayList<>(prior); published.add(witness.ready()); generator.setIslands(published);
for (var node : nodes) {
var proto = new ProtoChunk(node.chunk(), UpgradeData.EMPTY, level, PalettedContainerFactory.create(level.registryAccess()), null);
ExpansionStructures23.generate(generator, level.registryAccess(), null, level.structureManager(), proto,
level.getStructureTemplateManager(), level.dimension());
for (StructureStart start : proto.getAllStarts().values()) if (start.isValid()) emitted.add(start);
if (!emitted.isEmpty()) break;
}
} finally { generator.setIslands(prior); }
helper.assertTrue(!emitted.isEmpty(), "Accepted preflight replays as a native start through production generate");
var serialization = StructurePieceSerializationContext.fromLevel(level);
for (StructureStart start : emitted) assertRoundTrip(helper, start, serialization);
report.put("guaranteed_island", witness); report.put("guaranteed_start", emitted.getFirst().createTag(serialization, emitted.getFirst().getChunkPos()).toString());
// A not-yet-published Lost City already reserves its complete underground/support envelope.
// Exact descriptor admitted by the separate cold Session.create1024 Lost City witness.
var cityWitness = new ExpansionIsland("admission_23_0", "sanctuary", "east", "lost_city", 1024,
2416, 304, -6422943410799146666L, "natural", "reserved", 23);
ExpansionStructures23.verifyExpansion(level, cityWitness, ExpansionAdmission.searchDeadline());
helper.assertTrue(!generator.lostCityPlan(cityWitness).isEmpty() && !generator.undergroundPlan16(cityWitness).networks().isEmpty(),
"Unpublished witness has a real city district and underground network before native admission replay");
boolean cityEmitted = false;
try {
var published = new ArrayList<>(prior); published.add(cityWitness.ready()); generator.setIslands(published);
helper.assertTrue(!generator.lostCityPlan(cityWitness).isEmpty() && !generator.undergroundPlan16(cityWitness).networks().isEmpty(),
"Publishing keeps the nonempty city and underground construction mask");
for (var node : ExpansionStructures23.candidates(cityWitness)) {
var proto = new ProtoChunk(node.chunk(), UpgradeData.EMPTY, level, PalettedContainerFactory.create(level.registryAccess()), null);
ExpansionStructures23.generate(generator, level.registryAccess(), null, level.structureManager(), proto, level.getStructureTemplateManager(), level.dimension());
if (proto.getAllStarts().values().stream().anyMatch(StructureStart::isValid)) { cityEmitted = true; break; }
}
} finally { generator.setIslands(prior); }
helper.assertTrue(cityEmitted, "Lost City preflight remains valid after its full construction mask is published");
report.put("lost_city_guaranteed_island", cityWitness);
helper.assertTrue(registry.getOrThrow(StructureTags.EYE_OF_ENDER_LOCATED).contains(registry.get(Identifier.withDefaultNamespace("stronghold")).orElseThrow()), "Eye of Ender uses native stronghold tag");
helper.assertTrue(registry.getOrThrow(StructureTags.ON_OCEAN_MONUMENT_MAPS).contains(registry.get(Identifier.withDefaultNamespace("monument")).orElseThrow()), "Explorer maps keep the native monument identity");
var witnesses = new ArrayList<ExpansionIsland>();
var selectedStarts = new ArrayList<ExpansionStructures23.Accepted>();
Path proof = level.getServer().getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("native23-full-create.json");
boolean reload = Boolean.getBoolean("sanctuary.test.expansionReload");
var previous = reload ? JsonParser.parseString(Files.readString(proof)).getAsJsonObject() : null;
for (String id : List.of("stronghold", "trial_chambers", "monument", "igloo", "desert_pyramid")) {
ExpansionStructures23.Accepted accepted = null;
ExpansionIsland acceptedIsland = null;
for (int seed = 1; seed <= 16 && accepted == null; seed++) {
var candidate = reload ? new GsonBuilder().create().fromJson(previous.getAsJsonObject(id).get("island"), ExpansionIsland.class)
: island("native_" + id, 1024, seed * 101L + 37, id.equals("monument") ? "ocean" : "natural")
.withStructurePriorities(List.of(id));
accepted = ExpansionStructures23.previewSelected(level, candidate, "minecraft:" + id, deadline(90));
if (accepted != null && id.equals("igloo") && accepted.start().getPieces().size() == 1) accepted = null;
if (accepted != null) acceptedIsland = candidate;
if (reload) break;
}
helper.assertTrue(accepted != null, "Native " + id + " has an admissible site in floating terrain");
assertRoundTrip(helper, accepted.start(), serialization);
helper.assertTrue(accepted.start().getStructure() == registry.getValue(Identifier.withDefaultNamespace(id)), "Saved structure remains original registry object");
if (id.equals("stronghold")) helper.assertTrue(accepted.start().getPieces().stream().anyMatch(p -> p.getClass().getSimpleName().equals("PortalRoom")), "Native stronghold retains its End portal room");
if (id.equals("monument")) {
helper.assertTrue(accepted.start().getBoundingBox().minY() != 39, "Monument moved to real expansion seabed");
var loaded = StructureStart.loadStaticStart(serialization, accepted.start().createTag(serialization, accepted.start().getChunkPos()), level.getSeed());
var before = ((MonumentChildren23Accessor) accepted.start().getPieces().getFirst()).sanctuary$children();
var after = ((MonumentChildren23Accessor) loaded.getPieces().getFirst()).sanctuary$children();
helper.assertTrue(before.size() == after.size(), "Monument room graph reconstructed with island seed, not world seed");
for (int i = 0; i < before.size(); i++) helper.assertTrue(before.get(i).getBoundingBox().equals(after.get(i).getBoundingBox())
&& before.get(i).getClass().equals(after.get(i).getClass()), "Internal monument pieces preserve altitude and layout on reload");
}
report.put(id, Map.of("island", acceptedIsland, "tag", accepted.start().createTag(serialization, accepted.start().getChunkPos()).toString()));
witnesses.add(acceptedIsland.ready()); selectedStarts.add(accepted);
}
helper.assertTrue(NativeStructurePlacement23.seaLevel() == null, "No placement context leaks out of native generation");
var output = Path.of("diagnostics", "native23"); Files.createDirectories(output);
report.put("admission_passed", true);
Files.writeString(output.resolve("admission.json"), new GsonBuilder().setPrettyPrinting().create().toJson(report));
var published = new ArrayList<>(prior); published.addAll(witnesses); generator.setIslands(published);
var fullChunks = new java.util.TreeSet<net.minecraft.world.level.ChunkPos>(java.util.Comparator.comparingInt(net.minecraft.world.level.ChunkPos::x).thenComparingInt(net.minecraft.world.level.ChunkPos::z));
for (var selected : selectedStarts) selected.start().getBoundingBox().intersectingChunks().forEach(fullChunks::add);
var iterator = fullChunks.iterator();
var monument = selectedStarts.stream().filter(s -> s.structure().unwrapKey().orElseThrow().identifier().getPath().equals("monument")).findFirst().orElseThrow().start();
var entityChunks = monument.getBoundingBox().intersectingChunks().toList();
boolean[] entityTickets = {false};
int[] entityWait = {0};
Runnable releaseEntityTickets = () -> {
if (!entityTickets[0]) return;
for (var pos : entityChunks) level.getChunkSource().removeTicketWithRadius(net.minecraft.server.level.TicketType.DRAGON, pos, 2);
entityTickets[0] = false;
};
Runnable[] step = new Runnable[1];
step[0] = () -> {
try {
if (iterator.hasNext()) {
var chunk = iterator.next(); level.getChunk(chunk.x(), chunk.z(), net.minecraft.world.level.chunk.status.ChunkStatus.FULL, true);
// GameTest removes the executing Runnable from its map; enqueue a fresh callback.
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run()); return;
}
if (!entityTickets[0]) {
report.put("guardians_before_entity_tickets", guardianDiagnostics(level, monument, entityChunks, 0));
// FULL's short loading ticket does not keep its saved entities visible. This native
// ticket loads and simulates without persisting, and is released on every exit.
for (var pos : entityChunks) level.getChunkSource().addTicketWithRadius(net.minecraft.server.level.TicketType.DRAGON, pos, 2);
entityTickets[0] = true;
}
var guardianState = guardianDiagnostics(level, monument, entityChunks, entityWait[0]);
report.put("guardian_diagnostics", guardianState);
if (((Number) guardianState.get("count")).intValue() < 3 && entityWait[0]++ < 200) {
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run()); return;
}
fr.koka.sanctuary.SanctuaryMod.LOGGER.info("Native23 guardian visibility: {}", guardianState);
var fullTags = new LinkedHashMap<String, String>();
var blocks = new LinkedHashMap<String, Map<String, Integer>>();
report.put("full_chunks", fullChunks.size()); report.put("full_blocks", blocks);
for (var selected : selectedStarts) {
var expected = selected.start(); var position = expected.getChunkPos();
var chunk = level.getChunk(position.x(), position.z(), net.minecraft.world.level.chunk.status.ChunkStatus.FULL, true);
var actual = chunk.getStartForStructure(selected.structure().value());
helper.assertTrue(actual != null && actual.isValid(), "FULL production preserves the naturally selected structure");
helper.assertTrue(actual.getBoundingBox().equals(expected.getBoundingBox()), "FULL lifecycle preserves admitted geometry");
assertRoundTrip(helper, actual, serialization);
String id = selected.structure().unwrapKey().orElseThrow().identifier().getPath();
String saved = actual.createTag(serialization, position).toString();
if (reload) helper.assertTrue(previous.getAsJsonObject("full_tags").get(id).getAsString().equals(saved), "New JVM restores " + id + " with unchanged start and references");
var counts = countImportantBlocks(level, actual); blocks.put(id, counts);
if (id.equals("stronghold")) helper.assertTrue(counts.getOrDefault("end_portal_frame", 0) >= 12 && counts.getOrDefault("spawner", 0) >= 1 && counts.getOrDefault("chest", 0) >= 1,
"Stronghold postProcess creates usable portal frames, spawner and unopened loot chests");
if (id.equals("trial_chambers")) helper.assertTrue(counts.getOrDefault("trial_spawner", 0) > 0 && counts.getOrDefault("vault", 0) > 0,
"Trial chambers keep native trial spawners and vaults");
if (id.equals("igloo")) helper.assertTrue(counts.getOrDefault("ladder", 0) > 0 && counts.getOrDefault("chest", 0) > 0 && counts.getOrDefault("snow_block", 0) > 0,
"Projected igloo retains aligned entrance, shaft and native laboratory chest");
if (id.equals("desert_pyramid")) helper.assertTrue(counts.getOrDefault("sandstone", 0) > 50 && counts.getOrDefault("chest", 0) >= 4 && counts.getOrDefault("tnt", 0) >= 9,
"Projected desert pyramid retains its four loot chests and buried native trap");
if (id.equals("monument")) {
helper.assertTrue(counts.getOrDefault("prismarine", 0) > 100 && counts.getOrDefault("water", 0) > 100, "Monument has real submerged rooms after FEATURES");
int guardians = level.getEntitiesOfClass(net.minecraft.world.entity.monster.ElderGuardian.class,
net.minecraft.world.phys.AABB.of(actual.getBoundingBox())).size();
helper.assertTrue(guardians >= 3, "Native monument creates its three elder guardians");
counts.put("elder_guardians", guardians);
var oceanIsland = witnesses.stream().filter(i -> i.id().equals("native_monument")).findFirst().orElseThrow();
var ocean = ExpansionOcean23.column(oceanIsland, oceanIsland.centerX(), oceanIsland.centerZ());
helper.assertTrue(level.getBlockState(new net.minecraft.core.BlockPos(oceanIsland.centerX(), ocean.waterLevel(), oceanIsland.centerZ())).is(net.minecraft.world.level.block.Blocks.WATER), "Ocean surface remains water after features");
helper.assertTrue(level.getBlockState(new net.minecraft.core.BlockPos(oceanIsland.centerX(), ocean.floorY() - 3, oceanIsland.centerZ())).isSolid(), "Ocean retains a sealed geological floor after features");
}
}
verifyLocator(helper, generator, witnesses, selectedStarts, reload);
for (var selected : selectedStarts) {
var pos = selected.start().getChunkPos(); var actual = level.getChunk(pos.x(), pos.z(), net.minecraft.world.level.chunk.status.ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(selected.structure().value());
fullTags.put(selected.structure().unwrapKey().orElseThrow().identifier().getPath(), actual.createTag(serialization, pos).toString());
}
report.put("process_id", ProcessHandle.current().pid()); report.put("full_chunks", fullChunks.size()); report.put("full_blocks", blocks); report.put("full_tags", fullTags);
report.put("passed", true);
if (reload) helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload is a different JVM");
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Persist disposable full chunks and native starts");
var json = new GsonBuilder().setPrettyPrinting().create().toJson(report);
Files.writeString(output.resolve(reload ? "reload.json" : "full.json"), json);
if (!reload) Files.writeString(proof, json);
releaseEntityTickets.run(); generator.setIslands(prior); helper.succeed();
} catch (Exception failure) {
writeFailure(helper, report, failure);
releaseEntityTickets.run(); generator.setIslands(prior); throw new IllegalStateException(failure);
}
};
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run());
}
private static Map<String, Object> guardianDiagnostics(net.minecraft.server.level.ServerLevel level, StructureStart monument,
List<net.minecraft.world.level.ChunkPos> chunks, int waitedTicks) {
var guardians = level.getEntitiesOfClass(net.minecraft.world.entity.monster.ElderGuardian.class, net.minecraft.world.phys.AABB.of(monument.getBoundingBox()));
var state = new LinkedHashMap<String, Object>();
state.put("count", guardians.size()); state.put("difficulty", level.getDifficulty().toString()); state.put("waited_ticks", waitedTicks);
state.put("positions", guardians.stream().map(e -> List.of(e.getX(), e.getY(), e.getZ())).toList());
state.put("chunks", chunks.stream().map(p -> Map.of("x", p.x(), "z", p.z(),
"full_loaded", level.getChunkSource().getChunkNow(p.x(), p.z()) != null,
"entities_loaded", level.areEntitiesLoaded(p.pack()), "entities_ticking", level.areEntitiesActuallyLoadedAndTicking(p))).toList());
return state;
}
private static void writeFailure(GameTestHelper helper, Map<String, Object> partial, Exception failure) {
try {
var report = new LinkedHashMap<String, Object>(partial);
report.put("passed", false); report.put("failure_class", failure.getClass().getName()); report.put("failure", String.valueOf(failure.getMessage()));
report.put("difficulty", helper.getLevel().getDifficulty().toString()); report.put("process_id", ProcessHandle.current().pid());
var output = Path.of("diagnostics", "native23"); Files.createDirectories(output);
Files.writeString(output.resolve("failure.json"), new GsonBuilder().setPrettyPrinting().create().toJson(report));
} catch (Exception diagnosticFailure) { failure.addSuppressed(diagnosticFailure); }
}
private static ExpansionIsland island(String id, int diameter, long seed, String relief) {
int x = switch (id) { case "native_stronghold" -> 8192; case "native_trial_chambers" -> 12288; case "native_monument" -> 16384;
case "native_igloo" -> 28672; case "native_desert_pyramid" -> 32768; default -> 4096; };
String climate = id.equals("native_igloo") ? "glacial" : id.equals("native_desert_pyramid") ? "arid" : "temperate";
return new ExpansionIsland(id, "sanctuary", "east", climate, diameter, x, 4096, seed, relief, "reserved", 23);
}
private static Map<String, Integer> countImportantBlocks(net.minecraft.server.level.ServerLevel level, StructureStart start) {
var counts = new LinkedHashMap<String, Integer>();
var important = java.util.Set.of("end_portal_frame", "spawner", "chest", "trial_spawner", "vault", "prismarine", "water", "wet_sponge", "ladder", "snow_block", "sandstone", "tnt");
for (var piece : start.getPieces()) {
var box = piece.getBoundingBox();
var pos = new net.minecraft.core.BlockPos.MutableBlockPos();
for (int z = box.minZ(); z <= box.maxZ(); z++) for (int x = box.minX(); x <= box.maxX(); x++) for (int y = box.minY() - (start.getStructure() instanceof net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure ? 16 : 0); y <= box.maxY(); y++) {
var state = level.getBlockState(pos.set(x, y, z));
String id = net.minecraft.core.registries.BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath();
if (important.contains(id)) counts.merge(id, 1, Integer::sum);
}
}
return counts;
}
private static void verifyLocator(GameTestHelper helper, SanctuaryChunkGenerator generator, List<ExpansionIsland> islands,
List<ExpansionStructures23.Accepted> selected, boolean reload) {
var level = helper.getLevel(); var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
var beforeSnapshot = generator.source().islands();
for (var chosen : selected) {
var id = chosen.structure().unwrapKey().orElseThrow().identifier().getPath();
if (id.equals("igloo")) continue; // No built-in igloo explorer-map tag.
var tag = id.equals("desert_pyramid") ? StructureTags.ON_DESERT_PYRAMID_MAPS : id.equals("stronghold") ? StructureTags.EYE_OF_ENDER_LOCATED : id.equals("monument") ? StructureTags.ON_OCEAN_MONUMENT_MAPS : StructureTags.ON_BURIED_TRIAL_CHAMBERS_MAPS;
var located = generator.findNearestMapStructure(level, registry.getOrThrow(tag), chosen.start().getPieces().getFirst().getLocatorPosition(), 8, false);
helper.assertTrue(located != null && located.getSecond().equals(chosen.structure()), "Native tag locator finds actual " + id + " starts");
}
var tracked = new ArrayList<StructureStart>();
for (var island : islands) for (var candidate : ExpansionStructures23.candidates(island)) {
var chunk = level.getChunk(candidate.chunk().x(), candidate.chunk().z(), net.minecraft.world.level.chunk.status.ChunkStatus.STRUCTURE_STARTS, true);
chunk.getAllStarts().values().stream().filter(StructureStart::isValid).forEach(tracked::add);
}
var counts = new java.util.IdentityHashMap<StructureStart, Integer>(); tracked.forEach(s -> counts.put(s, s.getReferences()));
var wanted = HolderSet.direct(selected.stream().map(ExpansionStructures23.Accepted::structure).toList());
var origin = selected.getFirst().start().getPieces().getFirst().getLocatorPosition();
var found = generator.findNearestMapStructure(level, wanted, origin, 128, false);
helper.assertTrue(found != null, "Mixed vanilla holder sets locate a real start");
helper.assertTrue(tracked.stream().allMatch(s -> counts.get(s) == s.getReferences()), "Read-only locate consumes no references");
if (!reload) {
found = generator.findNearestMapStructure(level, wanted, origin, 128, true);
helper.assertTrue(found != null, "skipReferenced locates an unreferenced winner");
int changed = 0, increment = 0;
for (var start : tracked) { int delta = start.getReferences() - counts.get(start); if (delta != 0) changed++; increment += delta; }
helper.assertTrue(changed == 1 && increment == 1, "Exactly one winning start gains one reference");
}
helper.assertTrue(beforeSnapshot.equals(generator.source().islands()), "Tags, maps and locator never activate another expansion");
}
private static long deadline(int seconds) { return System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(seconds); }
private static void assertRoundTrip(GameTestHelper helper, StructureStart start, StructurePieceSerializationContext context) {
var tag = start.createTag(context, start.getChunkPos());
var loaded = StructureStart.loadStaticStart(context, tag, helper.getLevel().getSeed());
helper.assertTrue(loaded != null && loaded.isValid() && loaded.getBoundingBox().equals(start.getBoundingBox()), "Native start bounds survive reload");
helper.assertTrue(tag.equals(loaded.createTag(context, start.getChunkPos())), "Native start metadata and content survive reload");
}
}
@@ -0,0 +1,93 @@
package fr.koka.sanctuary.gametest;
import java.util.Map;
import java.util.Set;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.block.FenceBlock;
import net.minecraft.world.level.block.IronBarsBlock;
import net.minecraft.world.level.block.StairBlock;
import net.minecraft.world.level.block.WallBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
/** Independent oracle: actual native neighbor-update rules, with datapack tags loaded by the test server. */
public final class NativeConnections20 {
private NativeConnections20() {}
public record Stats(int panes, int fences, int walls, int stairs, int nativeOracles, int externalNeighbors,
int crossChunkPaneLinks, int settledExternalStates, int unsettledExternalStates) {
public Stats add(Stats other) { return new Stats(panes + other.panes, fences + other.fences, walls + other.walls,
stairs + other.stairs, nativeOracles + other.nativeOracles, externalNeighbors + other.externalNeighbors,
crossChunkPaneLinks + other.crossChunkPaneLinks, settledExternalStates + other.settledExternalStates,
unsettledExternalStates + other.unsettledExternalStates); }
public static Stats empty() { return new Stats(0, 0, 0, 0, 0, 0, 0, 0, 0); }
}
public static Stats verify(GameTestHelper helper, Map<BlockPos, BlockState> geometry, Set<BlockPos> edits) {
var level = helper.getLevel(); int panes = 0, fences = 0, walls = 0, stairs = 0, oracles = 0, exterior = 0, cross = 0, settled = 0, unsettled = 0;
for (var entry : geometry.entrySet()) {
var pos = entry.getKey(); var wanted = entry.getValue(); if (edits.contains(pos)) continue;
boolean pane = wanted.getBlock() instanceof IronBarsBlock, fence = wanted.getBlock() instanceof FenceBlock;
boolean wall = wanted.getBlock() instanceof WallBlock, stair = wanted.getBlock() instanceof StairBlock;
if (!pane && !fence && !wall && !stair) continue;
if (pane) panes++; if (fence) fences++; if (wall) walls++; if (stair) stairs++;
var actual = level.getBlockState(pos);
helper.assertTrue(sameNonConnectionState(actual, wanted),
"Material, orientation, half or waterlogging changed at " + pos + ": expected " + wanted + " got " + actual);
boolean knownNeighborhood = true;
for (var side : Direction.values()) {
var at = pos.relative(side); var expectedNeighbor = geometry.get(at); var actualNeighbor = level.getBlockState(at);
// A changed neighbor's shape can propagate a legitimate exterior connection, e.g. stacked walls.
if (edits.contains(at) || (expectedNeighbor == null ? !actualNeighbor.isAir()
: !expectedNeighbor.equals(actualNeighbor))) knownNeighborhood = false;
}
var updated = actual;
for (var side : Direction.values()) {
var at = pos.relative(side);
updated = updated.updateShape(level, level, pos, side, at, level.getBlockState(at), RandomSource.create(20));
}
if (knownNeighborhood) {
helper.assertTrue(actual.equals(wanted), "Native detailed state differs inside known geometry at " + pos
+ ": expected " + wanted + " got " + actual);
helper.assertTrue(updated.equals(actual), "Vanilla neighbor rules disagree with generation20 connections at " + pos
+ ": saved " + actual + " native " + updated);
oracles++;
} else {
exterior++;
if (updated.equals(actual)) { settled++; oracles++; }
else {
// A fresh structure has not necessarily received an update from untouched surrounding rock.
// Permit only its exact planned state until that update; a partially/wrongly changed state fails.
helper.assertTrue(actual.equals(wanted) && sameNonConnectionState(updated, wanted),
"Exterior connection is neither the untouched plan nor a vanilla fixed point at " + pos
+ ": plan " + wanted + " saved " + actual + " native " + updated);
unsettled++;
}
}
if (pane) for (var side : new Direction[] {Direction.EAST, Direction.SOUTH}) {
var at = pos.relative(side); var neighbor = level.getBlockState(at);
if (neighbor.getBlock() instanceof IronBarsBlock && (pos.getX() >> 4 != at.getX() >> 4 || pos.getZ() >> 4 != at.getZ() >> 4)) {
var property = side == Direction.EAST ? BlockStateProperties.EAST : BlockStateProperties.SOUTH;
helper.assertTrue(actual.getValue(property), "Pane connection disappeared exactly at a native chunk boundary: " + pos);
cross++;
}
}
}
return new Stats(panes, fences, walls, stairs, oracles, exterior, cross, settled, unsettled);
}
/** Normalize only geometry-derived properties. Everything else, including fluids and facing, stays exact. */
private static boolean sameNonConnectionState(BlockState actual, BlockState wanted) {
if (!actual.is(wanted.getBlock())) return false;
if (wanted.getBlock() instanceof IronBarsBlock || wanted.getBlock() instanceof FenceBlock) {
for (var property : new net.minecraft.world.level.block.state.properties.BooleanProperty[] {
BlockStateProperties.NORTH, BlockStateProperties.EAST, BlockStateProperties.SOUTH, BlockStateProperties.WEST})
actual = actual.setValue(property, wanted.getValue(property));
} else if (wanted.getBlock() instanceof WallBlock) {
for (var property : WallBlock.PROPERTY_BY_DIRECTION.values()) actual = actual.setValue(property, wanted.getValue(property));
actual = actual.setValue(WallBlock.UP, wanted.getValue(WallBlock.UP));
} else if (wanted.getBlock() instanceof StairBlock) actual = actual.setValue(StairBlock.SHAPE, wanted.getValue(StairBlock.SHAPE));
return actual.equals(wanted);
}
}
@@ -0,0 +1,243 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import fr.koka.sanctuary.expansion.ExpansionOcean23;
import fr.koka.sanctuary.expansion.NativeStructurePlacement23;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.GenerationChunkHolder;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.tags.FluidTags;
import net.minecraft.util.RandomSource;
import net.minecraft.util.StaticCache2D;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ImposterProtoChunk;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkPyramid;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.feature.BlueIceFeature;
import net.minecraft.world.level.levelgen.feature.IcebergFeature;
import net.minecraft.world.level.storage.LevelResource;
/** Full native decoration on six disposable ocean islands; this fixture never reserves an expansion. */
public final class Ocean23WorldGameTests {
private record Witness(ExpansionIsland island, BlockPos deep, BlockPos shore, BlockPos exterior) {}
@GameTest(maxTicks = 18000)
public void oceanSizesClimatesAndNativeIce(GameTestHelper helper) throws Exception {
var level = helper.getLevel();
var generator = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 23, "Ocean matrix requires a disposable generation23 world");
var prior = generator.source().islands();
var directory = level.getServer().getWorldPath(LevelResource.ROOT).resolve("data").resolve(generator.journalDirectory());
var journalBefore = files(directory);
var witnesses = new ArrayList<Witness>();
int index = 0;
for (int diameter : new int[]{512, 1024}) for (String climate : List.of("glacial", "temperate", "warm")) {
var island = new ExpansionIsland("ocean_" + diameter + "_" + climate, "sanctuary", "east", climate,
diameter, 8192 + index * 4096, 8192, 23042L + index * 101L, "ocean", "ready", 23);
witnesses.add(witness(island)); index++;
}
var published = new ArrayList<>(prior); witnesses.forEach(w -> published.add(w.island()));
var chunks = new TreeMap<Long, ChunkPos>();
for (var w : witnesses) {
for (var position : List.of(w.deep(), w.shore(), w.exterior())) {
var chunk = ChunkPos.containing(position); chunks.put(chunk.pack(), chunk);
}
// Native icebergs can write eleven blocks to either side of their origin.
if (w.island().climate().equals("glacial")) ChunkPos.rangeClosed(ChunkPos.containing(w.deep()), 1)
.forEach(chunk -> chunks.put(chunk.pack(), chunk));
}
var iterator = chunks.values().iterator();
var report = new LinkedHashMap<String, Object>();
generator.setIslands(published);
Runnable[] step = new Runnable[1];
step[0] = () -> {
try {
if (iterator.hasNext()) {
var chunk = iterator.next(); report.put("stage", "full " + chunk);
level.getChunk(chunk.x(), chunk.z(), ChunkStatus.FULL, true);
// Reusing the executing Runnable would discard its next scheduled step.
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run()); return;
}
var profiles = new ArrayList<Map<String, Object>>();
report.put("profiles", profiles);
for (var w : witnesses) {
report.put("stage", "inspect " + w.island().id()); profiles.add(inspect(helper, generator, w));
}
report.put("stage", "restore and verify journal");
helper.assertTrue(ExpansionOcean23.decorationSeaLevel() == null && NativeStructurePlacement23.seaLevel() == null,
"All ocean and structure scopes are restored after native decoration");
helper.assertTrue(generator.getSeaLevel() == -64, "Local oceans do not replace the global void sea level");
generator.setIslands(prior);
helper.assertTrue(generator.source().islands().equals(prior), "The original activation snapshot is restored");
helper.assertTrue(equalFiles(journalBefore, files(directory)), "Temporary test islands never alter the expansion journal or certificates");
report.put("generation", 23); report.put("world_seed", level.getSeed()); report.put("profiles", profiles);
report.put("full_chunks_requested", chunks.size()); report.put("process_id", ProcessHandle.current().pid());
report.put("journal_unchanged", true); report.put("passed", true);
Path output = Path.of("diagnostics", "ocean23"); Files.createDirectories(output);
Files.writeString(output.resolve("full.json"), new GsonBuilder().setPrettyPrinting().create().toJson(report));
helper.succeed();
} catch (Exception error) {
generator.setIslands(prior);
report.put("passed", false); report.put("failure", error.toString());
var stack = new java.io.StringWriter(); error.printStackTrace(new java.io.PrintWriter(stack));
report.put("stacktrace", stack.toString());
fr.koka.sanctuary.SanctuaryMod.LOGGER.error("Ocean23 matrix failed at {}", report.get("stage"), error);
try {
Path output = Path.of("diagnostics", "ocean23"); Files.createDirectories(output);
Files.writeString(output.resolve("failure.json"), new GsonBuilder().setPrettyPrinting().create().toJson(report));
} catch (java.io.IOException writeError) { error.addSuppressed(writeError); }
throw new IllegalStateException("Ocean23 matrix failed: " + error.getMessage(), error);
}
};
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run());
}
private static Witness witness(ExpansionIsland island) {
var deep = new BlockPos(island.centerX() + 8, ExpansionOcean23.seaLevel(island) - 8, island.centerZ() + 8);
BlockPos shore = null;
// Quart-aligned samples exercise the stored biome palette without biome-boundary jitter.
for (int z = island.centerZ() - island.diameter() / 2; z <= island.centerZ() + island.diameter() / 2 && shore == null; z += 4)
for (int x = island.centerX() - island.diameter() / 2; x <= island.centerX() + island.diameter() / 2; x += 4) {
var c = ExpansionOcean23.column(island, x, z);
if (!c.water() && c.influence() == 1 && c.floorY() <= c.waterLevel() + 8) {
shore = new BlockPos(x, (c.floorY() + 4) & ~3, z); break;
}
}
if (shore == null) throw new IllegalStateException("Ocean profile lacks a dry shoreline witness");
var exterior = new BlockPos(island.centerX() + island.terrainRadius() + 96, deep.getY(), island.centerZ());
return new Witness(island, deep, shore, exterior);
}
private static Map<String, Object> inspect(GameTestHelper helper, SanctuaryChunkGenerator generator, Witness w) {
var level = helper.getLevel(); var island = w.island();
var c = ExpansionOcean23.column(island, w.deep().getX(), w.deep().getZ());
helper.assertTrue(c.deep(), "Matrix sample lies in the deep ocean: " + island.id());
var deepChunk = (LevelChunk) level.getChunk(w.deep().getX() >> 4, w.deep().getZ() >> 4, ChunkStatus.FULL, true);
int solid = 0, submerged = 0;
for (int z = deepChunk.getPos().getMinBlockZ(); z <= deepChunk.getPos().getMaxBlockZ(); z += 4)
for (int x = deepChunk.getPos().getMinBlockX(); x <= deepChunk.getPos().getMaxBlockX(); x += 4) {
var column = ExpansionOcean23.column(island, x, z);
if (deepChunk.getBlockState(new BlockPos(x, column.floorY() - 3, z)).isSolid()) solid++;
var state = deepChunk.getBlockState(new BlockPos(x, column.waterLevel() - 3, z));
if (state.getFluidState().is(FluidTags.WATER) || state.is(Blocks.ICE) || state.is(Blocks.PACKED_ICE) || state.is(Blocks.BLUE_ICE)) submerged++;
}
helper.assertTrue(solid >= 12, "Native features retain a supported seabed: " + island.id() + " " + solid + "/16");
helper.assertTrue(submerged >= 12, "The deep basin remains water or natural ice after FEATURES: " + island.id() + " " + submerged + "/16");
String deepBiome = biome(level, w.deep());
helper.assertTrue(deepBiome.equals("minecraft:" + ExpansionOcean23.biome(island, c)), "Stored deep-ocean biome matches climate: " + island.id());
var shoreColumn = ExpansionOcean23.column(island, w.shore().getX(), w.shore().getZ());
helper.assertTrue(level.getBlockState(w.shore().atY(shoreColumn.floorY() - 8)).isSolid(), "Dry shore has a real foundation: " + island.id());
String shoreBiome = biome(level, w.shore());
helper.assertTrue(shoreBiome.equals("minecraft:" + ExpansionOcean23.shoreBiome(island)), "Stored shoreline biome matches its profile: " + island.id());
var outside = level.getChunk(w.exterior().getX() >> 4, w.exterior().getZ() >> 4, ChunkStatus.FULL, true);
for (int y = level.getMinY(); y <= level.getMaxY(); y++)
helper.assertTrue(outside.getBlockState(w.exterior().atY(y)).isAir(), "Ocean leaves exterior void empty: " + island.id() + " y=" + y);
helper.assertTrue(biome(level, w.exterior()).equals("minecraft:the_void"), "Exterior biome remains void");
var result = new LinkedHashMap<String, Object>();
result.put("island", island); result.put("deep", w.deep().toShortString()); result.put("shore", w.shore().toShortString());
result.put("water_level", c.waterLevel()); result.put("floor", c.floorY()); result.put("deep_biome", deepBiome);
result.put("shore_biome", shoreBiome); result.put("solid_samples", solid); result.put("submerged_samples", submerged);
if (island.climate().equals("glacial")) result.put("forced_native_ice_witness", ice(helper, generator, w));
return result;
}
private static Map<String, Object> ice(GameTestHelper helper, SanctuaryChunkGenerator generator, Witness witness) {
var level = helper.getLevel(); var origin = witness.deep(); var centre = ChunkPos.containing(origin);
int sea = ExpansionOcean23.seaLevel(witness.island());
var holders = StaticCache2D.<GenerationChunkHolder>create(centre.x(), centre.z(), 1, (x, z) -> {
var full = (LevelChunk) level.getChunk(x, z, ChunkStatus.FULL, true);
var writable = new ImposterProtoChunk(full, true);
return new GenerationChunkHolder(full.getPos()) {
@Override protected void addSaveDependency(java.util.concurrent.CompletableFuture<?> dependency) {}
@Override public int getTicketLevel() { return 0; }
@Override public int getQueueLevel() { return 0; }
@Override public ChunkAccess getChunkIfPresentUnchecked(ChunkStatus status) { return writable; }
};
});
var centreChunk = level.getChunk(centre.x(), centre.z(), ChunkStatus.FULL, true);
var probe = new ScopeProbe(level, holders, centreChunk);
probe.probing = true;
boolean observed = false;
try { generator.applyBiomeDecoration(probe, centreChunk, level.structureManager()); }
catch (ScopeObserved stop) { observed = true; }
helper.assertTrue(observed && probe.generatorSea == sea && probe.regionSea == sea && !probe.nativePlacement,
"Production FEATURES scope sets both sea levels without bypassing water retention");
helper.assertTrue(ExpansionOcean23.decorationSeaLevel() == null && generator.getSeaLevel() == -64,
"Production FEATURES scope restores its level even when decoration fails");
var region = new WorldGenRegion(level, holders, ChunkPyramid.GENERATION_PYRAMID.getStepTo(ChunkStatus.FEATURES), centreChunk);
Integer previous = ExpansionOcean23.enterDecoration(sea);
int iceAbove = 0, highest = Integer.MIN_VALUE;
try {
// This native draw has height 9 / width 11. Seed 23042 instead yields
// height 3 / width 2: vanilla may legitimately return true without ice.
helper.assertTrue(new IcebergFeature(Blocks.PACKED_ICE.defaultBlockState()).place(region, generator,
RandomSource.create(23041L), origin.atY(0)), "Native iceberg feature runs in the local ocean scope");
for (int x = origin.getX() - 11; x <= origin.getX() + 11; x++)
for (int z = origin.getZ() - 11; z <= origin.getZ() + 11; z++) for (int y = sea + 1; y <= sea + 48; y++) {
var state = region.getBlockState(new BlockPos(x, y, z));
if (state.is(Blocks.PACKED_ICE) || state.is(Blocks.BLUE_ICE)) { iceAbove++; highest = Math.max(highest, y); }
}
helper.assertTrue(iceAbove > 10 && highest > sea,
"Native iceberg forms above the floating ocean: ice=" + iceAbove + ", highest=" + highest + ", sea=" + sea);
var blue = origin.offset(5, 0, 5).atY(sea - 4);
region.setBlock(blue, Blocks.WATER.defaultBlockState(), 2, 0);
region.setBlock(blue.east(), Blocks.PACKED_ICE.defaultBlockState(), 2, 0);
helper.assertTrue(new BlueIceFeature().place(region, generator, RandomSource.create(23043L), blue)
&& region.getBlockState(blue).is(Blocks.BLUE_ICE), "Native blue ice accepts the same local sea level");
var floor = ExpansionOcean23.column(witness.island(), origin.getX(), origin.getZ());
var retained = origin.atY(floor.floorY() - 3);
var before = region.getBlockState(retained);
helper.assertTrue(!region.setBlock(retained, Blocks.AIR.defaultBlockState(), 2, 0)
&& region.getBlockState(retained).equals(before), "Decoration sea-level scope cannot punch through the retaining shell");
} finally { ExpansionOcean23.restoreDecoration(previous); }
helper.assertTrue(NativeStructurePlacement23.seaLevel() == null && ExpansionOcean23.decorationSeaLevel() == null,
"Native ice witnesses leave no thread-local state");
return Map.of("iceberg_feature_seed", 23041L, "ice_above_sea", iceAbove, "highest_ice", highest, "sea_level", sea,
"blue_ice", true, "production_scope_checked", true, "retention_preserved", true);
}
/** Abort at the first random-seed read, before replaying any structures or features. */
private static final class ScopeProbe extends WorldGenRegion {
boolean probing, nativePlacement; int generatorSea, regionSea;
ScopeProbe(ServerLevel level, StaticCache2D<GenerationChunkHolder> holders, ChunkAccess centre) {
super(level, holders, ChunkPyramid.GENERATION_PYRAMID.getStepTo(ChunkStatus.FEATURES), centre);
}
@Override public long getSeed() {
if (!probing) return super.getSeed();
generatorSea = getLevel().getChunkSource().getGenerator().getSeaLevel(); regionSea = getSeaLevel();
nativePlacement = NativeStructurePlacement23.seaLevel() != null;
throw new ScopeObserved();
}
}
private static final class ScopeObserved extends RuntimeException {}
private static String biome(ServerLevel level, BlockPos pos) {
return level.getNoiseBiome(pos.getX() >> 2, pos.getY() >> 2, pos.getZ() >> 2).unwrapKey().orElseThrow().identifier().toString();
}
private static Map<String, byte[]> files(Path directory) throws java.io.IOException {
var files = new TreeMap<String, byte[]>();
if (Files.exists(directory)) try (var paths = Files.walk(directory)) {
for (var path : paths.filter(Files::isRegularFile).toList()) files.put(directory.relativize(path).toString(), Files.readAllBytes(path));
}
return files;
}
private static boolean equalFiles(Map<String, byte[]> before, Map<String, byte[]> after) {
return before.keySet().equals(after.keySet()) && before.entrySet().stream().allMatch(e -> Arrays.equals(e.getValue(), after.get(e.getKey())));
}
}
@@ -57,12 +57,21 @@ public final class PopulationDiagnostics {
"New trees must refuse older saved worlds");
helper.assertFalse(new PopulationDecorationsFeature().place(level, old, RandomSource.create(0), BlockPos.ZERO),
"New cave decorations must refuse older saved worlds");
helper.assertFalse(Terrain25Runtime.enabled(old), "Terrain25 cannot attach to a historical settings key: " + key);
}
if (level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator) {
if (level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator unified) {
var expected = new java.util.HashSet<>(SanctuaryBiomeSource.REQUIRED_BIOMES);
if (unified.source().citiesEnabled()) expected.add("sanctuary:lost_city");
if (unified.rootGeneration() == 23 || unified.rootGeneration() == 24 || unified.rootGeneration() == 25) fr.koka.sanctuary.expansion.ExpansionOcean23.BIOMES
.forEach(name -> expected.add("minecraft:" + name));
if (unified.rootGeneration() == 25) Terrain25Biomes.NAMES
.forEach(name -> expected.add(Terrain25Biomes.key(name).identifier().toString()));
helper.assertTrue(generator.getBiomeSource() instanceof SanctuaryBiomeSource
&& generator.getBiomeSource().possibleBiomes().size() == (((SanctuaryChunkGenerator) level.getChunkSource().getGenerator()).source().citiesEnabled() ? 45 : 44)
&& generator.getBiomeSource().possibleBiomes().stream().map(b -> b.getRegisteredName()).collect(java.util.stream.Collectors.toSet()).containsAll(SanctuaryBiomeSource.REQUIRED_BIOMES),
"The unified generator retains all 44 historical biome holders, with Lost City added in city-enabled generations");
&& generator.getBiomeSource().possibleBiomes().stream().map(b -> b.getRegisteredName())
.collect(java.util.stream.Collectors.toSet()).equals(expected),
"The exact saved biome catalogue belongs to the active generation; historical holders stay intact");
helper.assertTrue(Terrain25Runtime.enabled(generator) == (unified.rootGeneration() == 25),
"Only generation25 activates the new terrain/hydrology pass");
helper.assertTrue(binding.random() != level.getChunkSource().randomState(),
"The unified starter must use its real terrain state, never the expansion host fallback");
} else {
@@ -78,6 +87,7 @@ public final class PopulationDiagnostics {
var level = helper.getLevel();
var binding = binding(level);
var generator = binding.generator();
if (Terrain25Runtime.enabled(generator)) { verifyDensity25(helper, binding); return; }
var capacity = IslandCapacity.forGenerator(generator);
var density = level.registryAccess().lookupOrThrow(Registries.DENSITY_FUNCTION).getOrThrow(
ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("population_" + capacity.players()))).value();
@@ -142,11 +152,68 @@ public final class PopulationDiagnostics {
capacity.players(), level.getSeed(), solidColumns, outerColumns);
}
private static void verifyDensity25(GameTestHelper helper, PopulationGenerationContext.Binding binding) throws IOException {
var level = helper.getLevel(); var generator = binding.generator(); var random = binding.random();
var capacity = IslandCapacity.forGenerator(generator);
var registered = level.registryAccess().lookupOrThrow(Registries.DENSITY_FUNCTION).getOrThrow(
ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("terrain25_" + capacity.players()))).value();
helper.assertTrue(registered instanceof Terrain25Density terrain && terrain.capacity() == capacity,
"Terrain25 settings and its named density agree on saved capacity");
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
var view = Terrain25Runtime.view(generator, random);
var replay = RandomState.create(level.registryAccess().lookupOrThrow(Registries.NOISE), random.seed(), generator.generatorSettings().value());
helper.assertTrue(view.plan().equals(Terrain25Runtime.view(generator, replay).plan()),
"Fresh native random states reproduce the same immutable terrain25 plan");
int step = Math.max(8, (int) Math.ceil(capacity.terrainLimit() / 32.0));
int extent = (int) Math.ceil(capacity.terrainLimit() / (double) step) * step;
int solidColumns = 0, emptyColumns = 0, replayed = 0, cutSamples = 0;
Set<Integer> bottoms = new TreeSet<>();
StringBuilder map = new StringBuilder("x,z,bottom_y,top_y\n");
for (int x = -extent; x <= extent; x += step) for (int z = -extent; z <= extent; z += step) {
int bottom = -1, top = -1;
for (int y = 0; y < 384; y += 8) {
float value = random.sampleBlockValueUncached(density, x, y, z);
float intact = view.natural(net.minecraft.world.level.levelgen.densityfunction.SamplerContext.EMPTY_UNCACHED, x, y, z);
helper.assertTrue(Float.isFinite(value) && Float.isFinite(intact), "Terrain25 samples remain finite");
helper.assertTrue(value <= 0 || intact > 0, "Cavities remove intact rock rather than add artificial masses");
if (y > Terrain25Shape.MAX_TERRAIN_Y || y == 0 || Math.hypot(x, z) >= capacity.terrainLimit())
helper.assertTrue(value <= 0, "Terrain25 respects its finite footprint and vertical bounds");
if (intact > 0 && value <= 0) cutSamples++;
if (value > 0) { if (bottom < 0) bottom = y; top = y; }
if (x % (step * 8) == 0 && z % (step * 8) == 0) {
helper.assertTrue(Float.floatToIntBits(value) == Float.floatToIntBits(replay.sampleBlockValueUncached(density, x, y, z)),
"Actual terrain25 density is bit-identical in a fresh native random state");
replayed++;
}
}
if (top >= 0) { solidColumns++; bottoms.add(bottom); } else emptyColumns++;
map.append(x).append(',').append(z).append(',').append(bottom).append(',').append(top).append('\n');
}
helper.assertTrue(solidColumns > 100 && emptyColumns > 100 && bottoms.size() >= 4 && cutSamples > 0 && replayed > 100,
"The actual terrain25 has natural masses, void, varied underside and sampled cave cuts");
Path directory = Path.of("diagnostics"); Files.createDirectories(directory);
String prefix = "terrain25-" + capacity.players() + "-seed-" + level.getSeed();
Files.writeString(directory.resolve(prefix + "-density.csv"), map.toString());
Files.writeString(directory.resolve(prefix + "-density.json"), json(Map.of(
"generation", 25, "players", capacity.players(), "seed", level.getSeed(), "step", step,
"solid_columns", solidColumns, "empty_columns", emptyColumns, "underside_heights", bottoms,
"cave_cut_samples", cutSamples, "bit_exact_replayed_samples", replayed, "passed", true)));
}
static void verifyExterior(GameTestHelper helper) {
var level = helper.getLevel();
var capacity = IslandCapacity.forGenerator(binding(level).generator());
int edge = Math.floorDiv(capacity.terrainLimit() + 48, 16) + 1;
for (int[] point : new int[][]{{edge,0},{-edge-1,0},{0,edge},{0,-edge-1},{256,0},{-257,0}}) {
if (level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator host && host.rootGeneration() == 25) {
var initialFlows = fr.koka.sanctuary.expansion.ExpansionInitialFlows25.prepare(host);
int attempts = 0;
while (host.aerialPlan24().claimsChunks().contains(ChunkPos.pack(point[0], point[1]))
|| initialFlows.contains(ChunkPos.pack(point[0], point[1]))) {
helper.assertTrue(++attempts <= 64, "A finite reservation must leave an exterior void witness");
if (point[0] != 0) point[0] += Integer.signum(point[0]); else point[1] += Integer.signum(point[1]);
}
}
var chunk = full(helper, level, point[0], point[1]);
for (var section : chunk.getSections()) helper.assertTrue(section.hasOnlyAir(),
"Completed terrain and decoration must remain void beyond this capacity: " + chunk.getPos());
@@ -210,8 +277,30 @@ public final class PopulationDiagnostics {
}
void prepare() throws IOException {
helper.assertTrue(regionsAtInitialSpawn > 0 && regionsAtInitialSpawn <= 4,
"Initial spawn must plan only nearby regions, not materialize the large island: " + regionsAtInitialSpawn);
boolean heritage = level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator sanctuary
&& (sanctuary.rootGeneration() == 25 || sanctuary.rootGeneration() == 24 || sanctuary.rootGeneration() == 23 || sanctuary.rootGeneration() == 22 || (sanctuary.rootGeneration() == 19 || sanctuary.rootGeneration() == 20 || sanctuary.rootGeneration() == 21) && sanctuary.experimentalStructures());
// Generation19 explicitly prospects destinations across the root island before publishing them.
// A consulted hydrology region is a bounded immutable plan, not a loaded/generated chunk.
// Historical generators retain their original near-spawn contract.
int regionLimit = 4;
if (heritage) {
int extent = capacity.terrainLimit();
var first = capacity.regionAt(-extent, -extent); var last = capacity.regionAt(extent, extent);
regionLimit = 0;
for (int x = first.x(); x <= last.x(); x++) for (int z = first.z(); z <= last.z(); z++) {
var region = new IslandCapacity.Region(x, z);
double nearestX = Math.max(0, Math.max(region.minX(), -region.maxX()));
double nearestZ = Math.max(0, Math.max(region.minZ(), -region.maxZ()));
if (Math.hypot(nearestX, nearestZ) < extent) regionLimit++;
}
helper.assertTrue(regionLimit <= 9, "Heritage starter hydrology must fit within nine local regions");
var plan = ((SanctuaryChunkGenerator) level.getChunkSource().getGenerator()).heritagePlan19();
helper.assertTrue(plan.columns() <= fr.koka.sanctuary.worldgen.heritage.HeritagePlan19.MAX_COLUMNS
&& plan.samples() <= fr.koka.sanctuary.worldgen.heritage.HeritagePlan19.MAX_SAMPLES,
"Island-wide heritage prospecting exceeded its independent column/probe budget");
}
helper.assertTrue(regionsAtInitialSpawn > 0 && regionsAtInitialSpawn <= regionLimit,
"Initial hydrology exceeded its geographic planning scope (limit " + regionLimit + "): " + regionsAtInitialSpawn);
List<IslandCapacity.Region> targets = new ArrayList<>();
targets.add(new IslandCapacity.Region(0,0));
if (capacity.players() == 20) targets.add(new IslandCapacity.Region(1,0));
@@ -0,0 +1,223 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import fr.koka.sanctuary.worldgen.PopulationGenerationContext;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.city.*;
import fr.koka.sanctuary.worldgen.heritage.*;
import fr.koka.sanctuary.worldgen.transit.*;
import fr.koka.sanctuary.worldgen.ruins.*;
import fr.koka.sanctuary.worldgen.mineral.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.RegistryOps;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.LevelResource;
/** Read-only admission audit, then native starts and reload in the disposable development world. */
public final class Secrets23WorldGameTests {
@GameTest(maxTicks = 12000)
public void naturalSecretsPersist(GameTestHelper helper) throws Exception {
var level = helper.getLevel();
var generator = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 23, "Use a fresh23 development world");
if (!level.structureManager().shouldGenerateStructures()) { verifyGlobalOff(helper, generator); return; }
var root = ExpansionIsland.origin(generator.initialDiameter(), level.getSeed(), 23);
var heritage = generator.heritagePlan19();
var transit = generator.transitPlan21();
var mineral = generator.mineralSanctuary23();
var ruins = generator.ruinsPlan17();
helper.assertTrue(generator.lostCityPlan(root).isEmpty(), "No surface city on the natural starter island");
helper.assertTrue(heritage.sites().stream().allMatch(s -> s.kind() == HeritagePlan19.Kind.OBSERVATORY), "Only isolated observatory is selected");
helper.assertTrue(ruins.networks().size() <= 1, "At most one hidden workshop network");
for (var network : ruins.networks()) {
helper.assertTrue(network.rooms().stream().anyMatch(r -> r.kind() == RuinsPlan17.Kind.WORKSHOP), "Secret network contains its workshop");
helper.assertTrue(network.rooms().stream().allMatch(r -> r.kind() == RuinsPlan17.Kind.ACCESS
|| r.kind() == RuinsPlan17.Kind.CORRIDOR || r.kind() == RuinsPlan17.Kind.WORKSHOP), "No unselected rooms");
}
for (var segment : heritage.segments()) for (var cell : segment.cells())
helper.assertTrue(Math.hypot(cell.x(), cell.z()) >= 48, "Surface routes preserve natural arrival");
if (generator.experimentalStructures()) helper.assertTrue(heritage.segments().stream()
.anyMatch(s -> s.kind() == HeritagePlan19.RouteKind.FREIGHT && s.cells().stream().anyMatch(c -> c.railPresent())),
"Reference island retains a real repairable rail run, not only endpoint markers");
var binding = Objects.requireNonNull(PopulationGenerationContext.of(level));
var terrain = new HeritageTerrain21(root, binding.generator(), binding.random(), level, p -> false);
int columns = 0;
// The three samplers have independent bounded caches, just like production planning.
terrain = new HeritageTerrain21(root, binding.generator(), binding.random(), level, p -> false);
for (var site : heritage.sites()) {
helper.assertTrue(site.supports().isEmpty(), "No heritage rescue piers");
for (int x = site.minX(); x <= site.maxX(); x++) for (int z = site.minZ(); z <= site.maxZ(); z++) {
helper.assertTrue(Math.abs(site.floorY() - terrain.surfaceY(x, z)) <= 2, "Building cut/fill stays within two blocks");
helper.assertTrue(terrain.solid(x, site.minY() - 1, z) && terrain.solid(x, site.minY() - 2, z), "Every foundation column rests on original rock"); columns++;
}
}
helper.assertTrue(!transit.isEmpty(), "The reference island retains its core traversal");
var hall = generator.undergroundHall22();
helper.assertTrue(!hall.isEmpty(), "Core hall exists independently of a surface town");
helper.assertTrue(hall.networks().stream().flatMap(n -> n.modules().stream()).noneMatch(m -> m.kind() == LostCityUnderground16.Kind.POOLROOM), "Pool rooms stay outside the active selection");
for (var module : transit.modules()) for (var support : module.supports())
helper.assertTrue(support.maxY() - support.minY() + 1 <= 16, "Core traversal retains bounded engineered supports");
if (!generator.experimentalStructures()) helper.assertTrue(heritage.sites().isEmpty() && heritage.segments().isEmpty() && ruins.isEmpty() && mineral.isEmpty(), "OFF removes optional catalogue, not core hall and traversal");
var gson = new GsonBuilder().setPrettyPrinting().create();
var encoded = ChunkGenerator.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow();
var geometry = gson.toJsonTree(Map.of("mineral", mineral.isEmpty() ? List.of() : List.of(mineral.chamber()), "ruins", ruins.networks(), "heritage_sites", heritage.sites(),
"heritage_links", heritage.links(), "heritage_segments", heritage.segments(), "transit", transit.modules(), "independent_hall", hall.networks()));
var report = new LinkedHashMap<String, Object>();
report.put("seed", level.getSeed()); report.put("size", generator.initialDiameter());
report.put("process_id", ProcessHandle.current().pid()); report.put("generator_codec", encoded); report.put("geometry", geometry);
report.put("experimental", generator.experimentalStructures()); report.put("city_districts", 0); report.put("mineral_accepted", !mineral.isEmpty()); report.put("ruins_networks", ruins.networks().size());
report.put("heritage_sites", heritage.sites().size()); report.put("heritage_links", heritage.links().size()); report.put("transit_modules", transit.modules().size());
report.put("independent_hall_networks", hall.networks().size());
report.put("core_entries", transit.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.ACCESS).map(m -> List.of(m.centerX(), m.surfaceY() + 1, m.minZ() - 2)).toList());
report.put("hall_entries", hall.networks().stream().map(n -> { var at = LostCityUndergroundStructures22.locator(n); return List.of(at.getX(), at.getY(), at.getZ()); }).toList());
report.put("natural_columns_audited", columns);
report.put("mineral_diagnostics", mineral.diagnostic());
report.put("heritage_diagnostics", heritage.diagnostics()); report.put("transit_diagnostics", transit.diagnostic());
var starts = new ArrayList<StructureStart>();
var origins = new LinkedHashMap<ResourceKey<Structure>, Set<ChunkPos>>();
origins.put(LostCityStructures.KEY, new HashSet<>()); origins.put(HeritageStructures19.KEY, new HashSet<>()); origins.put(TransitStructures21.KEY, new HashSet<>()); origins.put(LostCityUndergroundStructures22.KEY, new HashSet<>());
origins.put(RuinsStructures17.KEY, new HashSet<>()); origins.put(MineralSanctuaryStructures23.KEY, new HashSet<>());
ruins.networks().forEach(n -> origins.get(RuinsStructures17.KEY).add(new ChunkPos(n.startChunkX(), n.startChunkZ())));
if (!mineral.isEmpty()) origins.get(MineralSanctuaryStructures23.KEY).add(new ChunkPos(mineral.chamber().startChunkX(), mineral.chamber().startChunkZ()));
heritage.sites().forEach(s -> origins.get(HeritageStructures19.KEY).add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
heritage.segments().forEach(s -> origins.get(HeritageStructures19.KEY).add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
transit.modules().forEach(m -> origins.get(TransitStructures21.KEY).add(new ChunkPos(m.startChunkX(), m.startChunkZ())));
hall.networks().forEach(n -> origins.get(LostCityUndergroundStructures22.KEY).add(new ChunkPos(n.startChunkX(), n.startChunkZ())));
var chunks = new TreeSet<ChunkPos>(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z));
var nativeTags = new TreeMap<String, String>();
var context = StructurePieceSerializationContext.fromLevel(level);
for (var entry : origins.entrySet()) for (var origin : entry.getValue()) {
var type = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(entry.getKey()).value();
var start = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(type);
helper.assertTrue(start != null && start.isValid(), "Accepted geometry must have a native start");
for (var piece : start.getPieces()) {
helper.assertTrue(!(piece instanceof LostCityFoundationPiece16), "No artificial city foundation emitted");
if (entry.getKey().equals(HeritageStructures19.KEY)) helper.assertTrue(piece instanceof HeritagePiece21, "Use published21 architecture");
if (entry.getKey().equals(LostCityUndergroundStructures22.KEY)) helper.assertTrue(piece instanceof LostCityUndergroundPiece20, "Independent hall keeps published20 room geometry");
if (entry.getKey().equals(TransitStructures21.KEY)) helper.assertTrue(piece instanceof TransitPiece21 || piece instanceof TransitPiece22, "Published21 tunnel geometry with sealed22 corners");
if (entry.getKey().equals(MineralSanctuaryStructures23.KEY)) helper.assertTrue(piece instanceof MineralSanctuaryPiece23, "Saved mineral23 geometry");
if (entry.getKey().equals(RuinsStructures17.KEY)) helper.assertTrue(piece instanceof RuinsPiece20, "Saved workshop20 geometry");
piece.getBoundingBox().intersectingChunks().forEach(chunks::add);
}
var tag = start.createTag(context, origin);
var loaded = StructureStart.loadStaticStart(context, tag, level.getSeed());
helper.assertTrue(loaded != null && tag.equals(loaded.createTag(context, origin)), "Native start round trip");
nativeTags.put(entry.getKey().identifier() + "/" + origin.x() + "/" + origin.z(), tag.toString()); starts.add(start);
}
if (chunks.isEmpty()) chunks.add(new ChunkPos(0, 0));
report.put("native_starts", nativeTags); report.put("full_chunks", chunks.size());
Path world = level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
Path proof = world.resolve("secrets23-create.json");
boolean reload = Boolean.getBoolean("sanctuary.test.expansionReload");
JsonObject previous = reload ? JsonParser.parseString(Files.readString(proof)).getAsJsonObject() : null;
if (reload) {
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload follows a successful separate JVM");
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("geometry").equals(geometry), "Generation, options and accepted/refused placements survive reload");
helper.assertTrue(previous.get("native_starts").equals(gson.toJsonTree(nativeTags)), "Native saved starts survive reload");
}
var iterator = chunks.iterator();
Runnable finish = () -> {
try {
// Refresh already-generated transit chunks: the earlier short FULL tickets can expire
// while the catalogue loads. The walk checks real native collision shapes, including turns.
var transitChunks = new HashSet<ChunkPos>();
transit.modules().forEach(m -> TransitPiece21.bounds(m).intersectingChunks().forEach(transitChunks::add));
for (var pos : transitChunks) level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
int nativeRails = 0;
for (var segment : heritage.segments()) {
level.getChunk(segment.startChunkX(), segment.startChunkZ(), ChunkStatus.FULL, true);
for (var cell : segment.cells()) if (cell.railPresent()) {
var at = new BlockPos(cell.x(), cell.floorY() + 1, cell.z());
helper.assertTrue(level.getBlockState(at).is(Blocks.RAIL), "Planned rail survives in actual FULL chunk");
helper.assertTrue(level.getBlockState(at.below()).isSolid(), "Native rail remains supported by its ballast");
nativeRails++;
}
}
if (generator.experimentalStructures()) helper.assertTrue(nativeRails > 0, "Real repairable railway is present after chunk decoration");
report.put("native_rail_blocks", nativeRails);
report.put("transit_native_walk", Transit21WorldGameTests.verifyAccess(helper, transit));
report.put("hall_native_access", Underground22NativeChecks.verify(helper, hall.isEmpty() ? generator.undergroundPlan16(root) : hall));
if (!mineral.isEmpty()) {
var c = mineral.chamber();
for (var pos : c.bounds().intersectingChunks().toList()) level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
for (int dz = -19; dz <= 19; dz += 3) for (int dx = -19; dx <= 19; dx += 3) {
var floor = new BlockPos(c.centerX() + dx, c.minY() + 2, c.centerZ() + dz);
helper.assertTrue(level.getBlockState(floor).isSolid(), "Mineral native central floor remains walkable");
helper.assertTrue(level.getBlockState(floor.above()).isAir() && level.getBlockState(floor.above(2)).isAir(), "Mineral native central room remains clear");
}
}
if (!heritage.sites().isEmpty()) {
var site = heritage.sites().getFirst();
var witness = new BlockPos(site.centerX(), site.floorY() - 1, site.centerZ());
if (reload) helper.assertTrue(level.getBlockState(witness).is(Blocks.GOLD_BLOCK), "Personal edit in disposable test survives reload");
else {
helper.assertTrue(level.getBlockState(witness).isSolid(), "Accepted building foundation generated in real FULL chunks");
level.setBlock(witness, Blocks.GOLD_BLOCK.defaultBlockState(), 3);
}
report.put("saved_edit", List.of(witness.getX(), witness.getY(), witness.getZ()));
}
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native world before shutdown");
report.put("passed", true);
var directory = Path.of("diagnostics", "secrets23-" + level.getSeed() + "-" + generator.initialDiameter()); Files.createDirectories(directory);
var text = gson.toJson(report) + "\n"; Files.writeString(directory.resolve(reload ? "reload.json" : "create.json"), text);
if (!reload) Files.writeString(proof, text);
helper.succeed();
} catch (Exception failure) { throw new IllegalStateException(failure); }
};
Runnable[] load = new Runnable[1];
load[0] = () -> {
if (!iterator.hasNext()) { finish.run(); return; }
var pos = iterator.next(); level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
helper.runAtTickTime(helper.getTick() + 1, () -> load[0].run());
};
load[0].run();
}
private static void verifyGlobalOff(GameTestHelper helper, SanctuaryChunkGenerator generator) throws Exception {
var level = helper.getLevel();
var chunks = new TreeSet<ChunkPos>(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z));
chunks.add(new ChunkPos(0, 0)); chunks.add(new ChunkPos(4, 4)); chunks.add(new ChunkPos(-4, -4));
generator.heritagePlan19().sites().forEach(s -> chunks.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
generator.heritagePlan19().segments().forEach(s -> chunks.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
generator.ruinsPlan17().networks().forEach(n -> chunks.add(new ChunkPos(n.startChunkX(), n.startChunkZ())));
generator.transitPlan21().modules().forEach(m -> chunks.add(new ChunkPos(m.startChunkX(), m.startChunkZ())));
generator.undergroundHall22().networks().forEach(n -> chunks.add(new ChunkPos(n.startChunkX(), n.startChunkZ())));
var mineral = generator.mineralSanctuary23();
if (!mineral.isEmpty()) chunks.add(new ChunkPos(mineral.chamber().startChunkX(), mineral.chamber().startChunkZ()));
var manufactured = Set.of(Blocks.RAIL, Blocks.STONE_BRICKS, Blocks.STONE_BRICK_STAIRS, Blocks.CHISELED_STONE_BRICKS,
Blocks.IRON_BARS, Blocks.SMOOTH_STONE);
int checked = 0;
for (var pos : chunks) {
var chunk = level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
helper.assertTrue(chunk.getAllStarts().values().stream().noneMatch(StructureStart::isValid), "GlobalOFF emits no native structure start");
var at = new BlockPos.MutableBlockPos();
for (int z = pos.getMinBlockZ(); z <= pos.getMaxBlockZ(); z++) for (int x = pos.getMinBlockX(); x <= pos.getMaxBlockX(); x++)
for (int y = 1; y < 383; y++) {
at.set(x, y, z);
helper.assertTrue(!manufactured.contains(chunk.getBlockState(at).getBlock()), "GlobalOFF leaves no generated secret architecture or rails");
checked++;
}
}
var directory = Path.of("diagnostics", "secrets23-" + level.getSeed() + "-" + generator.initialDiameter()); Files.createDirectories(directory);
Files.writeString(directory.resolve("global-off.json"), new GsonBuilder().setPrettyPrinting().create().toJson(
Map.of("passed", true, "structures_enabled", false, "chunks", chunks.size(), "blocks_checked", checked)) + "\n");
helper.succeed();
}
}
@@ -0,0 +1,156 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import fr.koka.sanctuary.worldgen.PopulationGenerationContext;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.city.*;
import fr.koka.sanctuary.worldgen.heritage.*;
import fr.koka.sanctuary.worldgen.transit.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.RegistryOps;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.LevelResource;
/** Read-only admission audit, then native starts and reload in the disposable development world. */
public final class Selective22WorldGameTests {
@GameTest(maxTicks = 12000)
public void naturalSitesAndRefusalsPersist(GameTestHelper helper) throws Exception {
var level = helper.getLevel();
var generator = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 22, "Use a fresh22 development world");
var root = ExpansionIsland.origin(generator.initialDiameter(), level.getSeed(), 22);
var city = generator.cityPlacement21(root);
var heritage = generator.heritagePlan19();
var transit = generator.transitPlan21();
helper.assertTrue(city != null && city.reservation() == null && city.supports().isEmpty(), "No forced city envelope or rescue piers");
var binding = Objects.requireNonNull(PopulationGenerationContext.of(level));
var terrain = new HeritageTerrain21(root, binding.generator(), binding.random(), level, p -> false);
int columns = 0;
for (var district : city.surface().districts()) for (var parcel : district.parcels()) {
for (int x = parcel.minX(); x <= parcel.maxX(); x++) for (int z = parcel.minZ(); z <= parcel.maxZ(); z++) {
int floor = parcel.surfaceY(x - parcel.minX(), z - parcel.minZ());
helper.assertTrue(Math.abs(floor - terrain.surfaceY(x, z)) <= 2, "City must fit natural relief"); columns++;
}
}
// The three samplers have independent bounded caches, just like production planning.
terrain = new HeritageTerrain21(root, binding.generator(), binding.random(), level, p -> false);
for (var site : heritage.sites()) {
helper.assertTrue(site.supports().isEmpty(), "No heritage rescue piers");
for (int x = site.minX(); x <= site.maxX(); x++) for (int z = site.minZ(); z <= site.maxZ(); z++) {
helper.assertTrue(Math.abs(site.floorY() - terrain.surfaceY(x, z)) <= 2, "Building cut/fill stays within two blocks");
helper.assertTrue(terrain.solid(x, site.minY() - 1, z) && terrain.solid(x, site.minY() - 2, z), "Every foundation column rests on original rock"); columns++;
}
}
helper.assertTrue(!transit.isEmpty(), "The reference island retains its core traversal");
var hall = generator.undergroundHall22();
helper.assertTrue(!hall.isEmpty() || generator.undergroundPlan16(root).networks().stream().flatMap(n -> n.modules().stream())
.anyMatch(m -> m.kind() == LostCityUnderground16.Kind.EXPANSION_HALL), "Core hall exists with or without a surface town");
for (var module : transit.modules()) for (var support : module.supports())
helper.assertTrue(support.maxY() - support.minY() + 1 <= 16, "Core traversal retains bounded engineered supports");
if (!generator.experimentalStructures()) helper.assertTrue(heritage.isEmpty() && generator.ruinsPlan17().isEmpty(), "OFF removes optional catalogue, not core hall and traversal");
var gson = new GsonBuilder().setPrettyPrinting().create();
var encoded = ChunkGenerator.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow();
var geometry = gson.toJsonTree(Map.of("city", city.surface().districts(), "heritage_sites", heritage.sites(),
"heritage_links", heritage.links(), "heritage_segments", heritage.segments(), "transit", transit.modules(), "independent_hall", hall.networks()));
var report = new LinkedHashMap<String, Object>();
report.put("seed", level.getSeed()); report.put("size", generator.initialDiameter());
report.put("process_id", ProcessHandle.current().pid()); report.put("generator_codec", encoded); report.put("geometry", geometry);
report.put("experimental", generator.experimentalStructures()); report.put("city_districts", city.surface().districts().size());
report.put("heritage_sites", heritage.sites().size()); report.put("heritage_links", heritage.links().size()); report.put("transit_modules", transit.modules().size());
report.put("independent_hall_networks", hall.networks().size());
report.put("core_entries", transit.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.ACCESS).map(m -> List.of(m.centerX(), m.surfaceY() + 1, m.minZ() - 2)).toList());
report.put("hall_entries", hall.networks().stream().map(n -> { var at = LostCityUndergroundStructures22.locator(n); return List.of(at.getX(), at.getY(), at.getZ()); }).toList());
report.put("natural_columns_audited", columns); report.put("city_diagnostics", city.diagnostics());
report.put("heritage_diagnostics", heritage.diagnostics()); report.put("transit_diagnostics", transit.diagnostic());
var starts = new ArrayList<StructureStart>();
var origins = new LinkedHashMap<ResourceKey<Structure>, Set<ChunkPos>>();
origins.put(LostCityStructures.KEY, new HashSet<>()); origins.put(HeritageStructures19.KEY, new HashSet<>()); origins.put(TransitStructures21.KEY, new HashSet<>()); origins.put(LostCityUndergroundStructures22.KEY, new HashSet<>());
city.surface().districts().forEach(d -> origins.get(LostCityStructures.KEY).add(new ChunkPos(d.startChunkX(), d.startChunkZ())));
heritage.sites().forEach(s -> origins.get(HeritageStructures19.KEY).add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
heritage.segments().forEach(s -> origins.get(HeritageStructures19.KEY).add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
transit.modules().forEach(m -> origins.get(TransitStructures21.KEY).add(new ChunkPos(m.startChunkX(), m.startChunkZ())));
hall.networks().forEach(n -> origins.get(LostCityUndergroundStructures22.KEY).add(new ChunkPos(n.startChunkX(), n.startChunkZ())));
var chunks = new TreeSet<ChunkPos>(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z));
var nativeTags = new TreeMap<String, String>();
var context = StructurePieceSerializationContext.fromLevel(level);
for (var entry : origins.entrySet()) for (var origin : entry.getValue()) {
var type = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(entry.getKey()).value();
var start = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(type);
helper.assertTrue(start != null && start.isValid(), "Accepted geometry must have a native start");
for (var piece : start.getPieces()) {
helper.assertTrue(!(piece instanceof LostCityFoundationPiece16), "No artificial city foundation emitted");
if (entry.getKey().equals(HeritageStructures19.KEY)) helper.assertTrue(piece instanceof HeritagePiece21, "Use published21 architecture");
if (entry.getKey().equals(LostCityUndergroundStructures22.KEY)) helper.assertTrue(piece instanceof LostCityUndergroundPiece20, "Independent hall keeps published20 room geometry");
if (entry.getKey().equals(TransitStructures21.KEY)) helper.assertTrue(piece instanceof TransitPiece21 || piece instanceof TransitPiece22, "Published21 tunnel geometry with sealed22 corners");
piece.getBoundingBox().intersectingChunks().forEach(chunks::add);
}
var tag = start.createTag(context, origin);
var loaded = StructureStart.loadStaticStart(context, tag, level.getSeed());
helper.assertTrue(loaded != null && tag.equals(loaded.createTag(context, origin)), "Native start round trip");
nativeTags.put(entry.getKey().identifier() + "/" + origin.x() + "/" + origin.z(), tag.toString()); starts.add(start);
}
if (chunks.isEmpty()) chunks.add(new ChunkPos(0, 0));
report.put("native_starts", nativeTags); report.put("full_chunks", chunks.size());
Path world = level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
Path proof = world.resolve("selective22-create.json");
boolean reload = Boolean.getBoolean("sanctuary.test.expansionReload");
JsonObject previous = reload ? JsonParser.parseString(Files.readString(proof)).getAsJsonObject() : null;
if (reload) {
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload follows a successful separate JVM");
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("geometry").equals(geometry), "Generation, options and accepted/refused placements survive reload");
helper.assertTrue(previous.get("native_starts").equals(gson.toJsonTree(nativeTags)), "Native saved starts survive reload");
}
var iterator = chunks.iterator();
Runnable finish = () -> {
try {
// Refresh already-generated transit chunks: the earlier short FULL tickets can expire
// while the catalogue loads. The walk checks real native collision shapes, including turns.
var transitChunks = new HashSet<ChunkPos>();
transit.modules().forEach(m -> TransitPiece21.bounds(m).intersectingChunks().forEach(transitChunks::add));
for (var pos : transitChunks) level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
report.put("transit_native_walk", Transit21WorldGameTests.verifyAccess(helper, transit));
report.put("hall_native_access", Underground22NativeChecks.verify(helper, hall.isEmpty() ? generator.undergroundPlan16(root) : hall));
if (!heritage.sites().isEmpty()) {
var site = heritage.sites().getFirst();
var witness = new BlockPos(site.centerX(), site.floorY() - 1, site.centerZ());
if (reload) helper.assertTrue(level.getBlockState(witness).is(Blocks.GOLD_BLOCK), "Personal edit in disposable test survives reload");
else {
helper.assertTrue(level.getBlockState(witness).isSolid(), "Accepted building foundation generated in real FULL chunks");
level.setBlock(witness, Blocks.GOLD_BLOCK.defaultBlockState(), 3);
}
report.put("saved_edit", List.of(witness.getX(), witness.getY(), witness.getZ()));
}
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native world before shutdown");
report.put("passed", true);
var directory = Path.of("diagnostics", "selective22-" + level.getSeed() + "-" + generator.initialDiameter()); Files.createDirectories(directory);
var text = gson.toJson(report) + "\n"; Files.writeString(directory.resolve(reload ? "reload.json" : "create.json"), text);
if (!reload) Files.writeString(proof, text);
helper.succeed();
} catch (Exception failure) { throw new IllegalStateException(failure); }
};
Runnable[] load = new Runnable[1];
load[0] = () -> {
if (!iterator.hasNext()) { finish.run(); return; }
var pos = iterator.next(); level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
helper.runAtTickTime(helper.getTick() + 1, () -> load[0].run());
};
load[0].run();
}
}
@@ -0,0 +1,185 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.biome.FixedBiomeSource;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
/** Registry-backed diagnostics of nine plans; no evaluated world chunks are loaded or modified. */
public final class Terrain25Diagnostics {
@GameTest(maxTicks = 24000)
public void nineInitialTerrainPlans(GameTestHelper helper) throws Exception {
var registries=helper.getLevel().registryAccess();
var settings=registries.lookupOrThrow(Registries.NOISE_SETTINGS);
var noises=registries.lookupOrThrow(Registries.NOISE);
var forest=registries.lookupOrThrow(Registries.BIOME).getOrThrow(Terrain25Biomes.key("oak_forest"));
List<Map<String,Object>> reports=new ArrayList<>();
int acceptedWater=0,acceptedLava=0,totalFalls=0;
Path reportPath=Path.of("diagnostics/terrain25/plans.json"); Files.createDirectories(reportPath.getParent());
for(int players:new int[]{5,10,20})for(long seed:new long[]{0,42,2026}) {
long start=System.nanoTime();
var holder=settings.getOrThrow(ResourceKey.create(Registries.NOISE_SETTINGS,SanctuaryMod.id("unified_25_"+players)));
var generator=new NoiseBasedChunkGenerator(new FixedBiomeSource(forest),holder);
var state=RandomState.create(noises,seed,holder.value());
var view=Terrain25Runtime.view(generator,state); var plan=view.plan();
var capacity=IslandCapacity.fromPlayers(players);
Map<String,Object> row=new LinkedHashMap<>(); row.put("seed",seed);row.put("diameter",players==5?512:players==10?724:1024);
int max=-1,columns=0,raised=0,vegetationRoom=0,solidColumns=0; long digest=0xcbf29ce484222325L;
var context=SamplerContext.builder().enableCaches().build();
for(int x=-capacity.terrainLimit();x<=capacity.terrainLimit();x+=16)for(int z=-capacity.terrainLimit();z<=capacity.terrainLimit();z+=16) {
if(Math.hypot(x,z)>=capacity.radius()*.82)continue;
columns++;if(plan.lift(x,z)>0)raised++;
int top=top(view,context,x,z);max=Math.max(max,top);
if(top>=0){solidColumns++;if(top<335)vegetationRoom++;}
for(int y:new int[]{0,32,80,120,151,192,256,300,358,359,383}) {
float density=view.sampleValue(context,x,y,z);digest=(digest^Float.floatToIntBits(density))*0x100000001b3L;
if(y>358)helper.assertTrue(density<=0,"Terrain stays below the immutable build-height margin");
}
}
for(var peak:plan.peaks())max=Math.max(max,top(view,context,(int)Math.round(peak.x()),(int)Math.round(peak.z())));
var lakes=Terrain25Runtime.admittedLakes(generator,state);
long water=lakes.stream().filter(c->c.kind()==Terrain25Shape.Kind.LAKE).count();
long lava=lakes.stream().filter(c->c.kind()==Terrain25Shape.Kind.LAVA).count();
acceptedWater+=water;acceptedLava+=lava;
List<Map<String,Object>> regions=new ArrayList<>();
var first=capacity.regionAt(-capacity.terrainLimit(),-capacity.terrainLimit());
var last=capacity.regionAt(capacity.terrainLimit(),capacity.terrainLimit());
for(int rx=first.x();rx<=last.x();rx++)for(int rz=first.z();rz<=last.z();rz++) {
var region=new IslandCapacity.Region(rx,rz);
double nx=Math.max(0,Math.max(region.minX(),-region.maxX())),nz=Math.max(0,Math.max(region.minZ(),-region.maxZ()));
if(Math.hypot(nx,nz)>=capacity.terrainLimit())continue;
var waterPlan=PopulationHydrologyRuntime.regionPlan(generator,state,region).water();
var falls=Terrain25Runtime.falls(generator,state,region);
for(var fall:falls) {
helper.assertTrue(Math.hypot(fall.endX()-fall.startX(),fall.endZ()-fall.startZ())<=64.75,"Outlet search remains within64 blocks including integer rounding");
helper.assertTrue(fall.width()>=8&&fall.width()<=16,"Waterfall lip width816");
}
totalFalls+=falls.size();
regions.add(Map.of("region",List.of(rx,rz),"rivers",waterPlan.features().stream().filter(f->f.kind()==PopulationHydrology.Kind.RIVER).count(),"falls",falls));
}
List<Map<String,Object>> chambers=new ArrayList<>();
for(var c:plan.chambers()) {
int dryFloor=0,open=0,roof=0;
for(int dx=-12;dx<=12;dx+=4)for(int dz=-12;dz<=12;dz+=4){int x=(int)c.x()+dx,z=(int)c.z()+dz;
if(view.sampleValue(context,x,c.floorY(),z)>0&&view.sampleValue(context,x,c.floorY()+1,z)<=0)dryFloor++;
if(view.sampleValue(context,x,(int)c.y(),z)<=0)open++;
if(view.sampleValue(context,x,(int)(c.y()+c.ry()+2),z)>0)roof++;
}
chambers.add(Map.of("kind",c.kind(),"center",List.of(c.x(),c.y(),c.z()),"radii",List.of(c.rx(),c.ry(),c.rz()),
"floor_y",c.floorY(),"dry_floor_samples_of_49",dryFloor,"air_samples_of_49",open,"roof_samples_of_49",roof,"fluid_admitted",lakes.contains(c),"dry_floor_certified",plan.certifiedDryFloors().contains(c.id()),
"dry_fit",plan.dryFits().getOrDefault(c.id(),new Terrain25Shape.DryFit(0,false,"not-a-dry-room"))));
}
row.put("certified_dry_floor_checks",verifyDryFloors(helper,view));
row.put("native_calcite_coating",verifyCoating(helper,generator,state));
row.put("exact_column_scan_equivalence",verifyColumnScans(helper,generator,state,capacity));
row.put("maximum_surface",max);row.put("massif_fraction",raised/(double)columns);row.put("room_for_vegetation_columns",vegetationRoom);
row.put("solid_surface_columns",solidColumns);row.put("sampled_columns",columns);row.put("chambers",chambers);
row.put("admitted_water_lakes",water);row.put("admitted_lava_lakes",lava);row.put("regions",regions);
row.put("density_digest",Long.toUnsignedString(digest,16));row.put("planning_ms",(System.nanoTime()-start)/1_000_000);
reports.add(row); write(reportPath,Map.of("scope","Native samplers and plans only; no blocks, ticks, entities or visual verification", "complete",false,"worlds",reports));
SanctuaryMod.LOGGER.info("Terrain25 diagnostics: seed {} size {} top {} water {} lava {} planning {}ms",seed,row.get("diameter"),max,water,lava,row.get("planning_ms"));
helper.assertTrue(max<=358,"Terrain ceiling");
helper.assertTrue(vegetationRoom>solidColumns*.5,"A majority of sampled existing land retains building and vegetation headroom");
}
boolean hasWater=acceptedWater>0,hasLava=acceptedLava>0;
write(reportPath,Map.of("scope","Native samplers and plans only; no blocks, ticks, entities or visual verification","complete",true,
"worlds",reports,"accepted_water_lakes",acceptedWater,"accepted_lava_lakes",acceptedLava,"accepted_waterfalls",totalFalls,
"water_lake_witness",hasWater,"lava_lake_witness",hasLava));
helper.assertTrue(hasWater,"At least one genuinely admitted water lake across the nine native witness worlds; refusal alone is not success");
helper.assertTrue(hasLava,"At least one genuinely admitted lava lake across the nine native witness worlds; refusal alone is not success");
helper.succeed();
}
private static Map<String,Object> verifyDryFloors(GameTestHelper helper,Terrain25Density.View view) {
int tested=0,certified=0,rejected=0;var details=new ArrayList<Map<String,Object>>();
for(var chamber:view.plan().chambers())if(chamber.kind()==Terrain25Shape.Kind.DRY) {
var fit=view.plan().dryFits().get(chamber.id());
helper.assertTrue(fit!=null&&fit.attempts()<=48,"Every dry-room natural fit has a bounded, explicit result");
if(!fit.certified()){rejected++;details.add(Map.of("id",chamber.id(),"certified",false,"reason",fit.diagnostic()));continue;}
certified++;int columns=0;
for(int x=(int)Math.floor(chamber.x()-chamber.rx()-2);x<=chamber.x()+chamber.rx()+2;x++)
for(int z=(int)Math.floor(chamber.z()-chamber.rz()-2);z<=chamber.z()+chamber.rz()+2;z++) {
if(chamber.cut(x,chamber.floorY()+1,z)<=0)continue;columns++;
// Full continuity is certified in the pure planner. Here a representative
// native grid checks all three layers after every new cavity and fissure.
if(Math.floorMod(x,3)!=0||Math.floorMod(z,3)!=0)continue;
for(int y=chamber.floorY()-2;y<=chamber.floorY();y++) {
helper.assertTrue(view.natural(SamplerContext.EMPTY_UNCACHED,x,y,z)>0,"A certified dry floor must already exist in intact rock");
helper.assertTrue(view.sampleValue(SamplerContext.EMPTY_UNCACHED,x,y,z)>0,"Another cavity or fracture must not puncture a certified dry floor");tested++;
}
}
details.add(Map.of("id",chamber.id(),"certified",true,"continuous_columns",columns,"attempts",fit.attempts()));
}
return Map.of("certified_rooms",certified,"refused_rooms",rejected,"native_floor_voxels",tested,"rooms",details);
}
/** Compare old/new loop results on actual native densities, and count their exact probes. */
private static Map<String,Object> verifyColumnScans(GameTestHelper helper,NoiseBasedChunkGenerator generator,
RandomState state,IslandCapacity capacity) {
var view=Terrain25Runtime.view(generator,state);var coordinates=new LinkedHashSet<PopulationHydrology.Point>();
for(int ix=-4;ix<=4;ix++)for(int iz=-4;iz<=4;iz++)
coordinates.add(new PopulationHydrology.Point((int)Math.round(capacity.radius()*.95*ix/4),
(int)Math.round(capacity.radius()*.95*iz/4)));
for(var peak:view.plan().peaks())coordinates.add(new PopulationHydrology.Point((int)Math.round(peak.x()),(int)Math.round(peak.z())));
int oldNatural=0,newNatural=0,oldGroves=0,newGroves=0,groveCases=0;
for(var point:coordinates) {
int x=point.x(),z=point.z();float[] intact=new float[384],finalDensity=new float[384];
Arrays.fill(intact,-1);Arrays.fill(finalDensity,-1);
for(int y=1;y<=358;y++) {
intact[y]=view.natural(SamplerContext.EMPTY_UNCACHED,x,y,z);
if(intact[y]>0)finalDensity[y]=view.sampleValue(SamplerContext.EMPTY_UNCACHED,x,y,z);
}
int previousTop=-1,previousBottom=-1,optimizedTop=-1,optimizedBottom=-1;
for(int y=358;y>0;y--){oldNatural++;if(intact[y]>0){previousTop=y;break;}}
if(previousTop>=0)for(int y=1;y<=previousTop;y++){oldNatural++;if(intact[y]>0){previousBottom=y;break;}}
int bound=Terrain25ColumnBounds.highestPossibleRock(view.plan().lift(x,z));
for(int y=bound;y>=Terrain25ColumnBounds.LOWEST_POSSIBLE_ROCK;y--){newNatural++;if(intact[y]>0){optimizedTop=y;break;}}
if(optimizedTop>=0)for(int y=Terrain25ColumnBounds.LOWEST_POSSIBLE_ROCK;y<=optimizedTop;y++){newNatural++;if(intact[y]>0){optimizedBottom=y;break;}}
helper.assertTrue(previousTop==optimizedTop&&previousBottom==optimizedBottom,"Exact natural column bounds preserve both extrema at "+x+","+z);
helper.assertTrue(Terrain25Runtime.naturalTop(generator,state,x,z)==previousTop,"Cached production natural top matches the original complete scan");
for(int ceiling:new int[]{383,previousTop,previousTop-10}) {
int before=-1,after=-1;
for(int y=Math.min(383,ceiling);y>=24;y--){oldGroves++;if(finalDensity[y]>0){before=y;break;}}
for(int y=Math.min(383,Math.min(ceiling,optimizedTop));y>=24;y--){newGroves++;if(finalDensity[y]>0){after=y;break;}}
helper.assertTrue(before==after,"Groves retain the identical final natural surface with open, current or lowered ceiling");groveCases++;
}
}
helper.assertTrue(newNatural<oldNatural&&newGroves<oldGroves,"The bounded scans must remove actual probes, not only preserve their results");
var result=new LinkedHashMap<String,Object>();result.put("native_columns",coordinates.size());result.put("grove_ceiling_cases",groveCases);
result.put("old_natural_density_probes",oldNatural);result.put("bounded_natural_density_probes",newNatural);
result.put("old_grove_density_probes",oldGroves);result.put("bounded_grove_density_probes",newGroves);
result.put("natural_probe_reduction",1-newNatural/(double)oldNatural);result.put("grove_probe_reduction",1-newGroves/(double)oldGroves);
result.put("scope","Exact density-probe counts with unchanged native results; not a wall-clock speed claim");return result;
}
private static Map<String,Integer> verifyCoating(GameTestHelper helper,NoiseBasedChunkGenerator generator,RandomState state) {
var view=Terrain25Runtime.view(generator,state);int core=0,shell=0;
Terrain25Coating.Solid intact=(x,y,z)->view.natural(SamplerContext.EMPTY_UNCACHED,x,y,z)>0;
for(var peak:view.plan().peaks()) {
int x=(int)Math.round(peak.x()),z=(int)Math.round(peak.z());
for(int y=281;y<=358;y+=3) {
if(!intact.at(x,y,z)||view.sampleValue(SamplerContext.EMPTY_UNCACHED,x,y,z)<=0)continue;
boolean coats=Terrain25Coating.calcite(x,y,z,view.plan().lift(x,z),intact);
var actual=Terrain25Runtime.finalBlock(generator,state,x,y,z,net.minecraft.world.level.block.Blocks.STONE.defaultBlockState());
if(actual.isAir()||!actual.getFluidState().isEmpty())continue;
helper.assertTrue(actual.is(net.minecraft.world.level.block.Blocks.CALCITE)==coats,"Cached calcite coating agrees with the intact native rock envelope");
if(coats)shell++;else core++;
}
}
helper.assertTrue(core>0,"High massifs retain an actual rock core beneath their calcite exterior");
helper.assertTrue(shell>0,"High massifs retain an exposed calcite coating");
return Map.of("checked_core_positions",core,"checked_coating_positions",shell);
}
private static int top(Terrain25Density.View view,SamplerContext context,int x,int z) {
for(int y=358;y>0;y-=4)if(view.sampleValue(context,x,y,z)>0){for(int fine=Math.min(358,y+3);fine>=y;fine--)if(view.sampleValue(context,x,fine,z)>0)return fine;}
return -1;
}
private static void write(Path path,Object report) throws Exception {Files.writeString(path,new GsonBuilder().setPrettyPrinting().create().toJson(report)+"\n");}
}
@@ -0,0 +1,539 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import fr.koka.sanctuary.expansion.ExpansionStructures23;
import fr.koka.sanctuary.knowledge.structures.StructureCensusService;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.aerial.*;
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryOps;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.TicketType;
import net.minecraft.world.entity.npc.villager.Villager;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.LevelResource;
import net.minecraft.world.level.storage.loot.LootTable;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
/** One real world per size/seed. Separate JVM phases preserve looting, mining and extracted occupants. */
public final class Terrain25WorldGameTests {
private static final com.google.gson.Gson JSON = new GsonBuilder().setPrettyPrinting().create();
@GameTest(maxTicks = 24000)
public void naturalTerrainAndFiveSitesPersistAfterUse(GameTestHelper helper) throws Exception {
var report = new LinkedHashMap<String, Object>();
try { begin(helper, report); }
catch (Exception failure) { failure(report, failure); throw failure; }
}
private static void begin(GameTestHelper helper, Map<String, Object> report) throws Exception {
var level = helper.getLevel();
var generator = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 25, "Terrain25 requires a disposable generation25 world");
String phase = System.getProperty("sanctuary.test.terrain25.phase", "create");
helper.assertTrue(List.of("create", "reload1", "reload2").contains(phase), "Known aerial lifecycle phase");
boolean create = phase.equals("create");
var plan = generator.aerialPlan24();
report.put("phase", phase); report.put("seed", level.getSeed()); report.put("diameter", generator.initialDiameter());
report.put("process_id", ProcessHandle.current().pid()); report.put("experimental", generator.experimentalStructures());
report.put("generate_structures", level.structureManager().shouldGenerateStructures());
if (!level.structureManager().shouldGenerateStructures() || !generator.experimentalStructures()) {
boolean globalOff = !level.structureManager().shouldGenerateStructures();
helper.assertTrue(create, "Options-OFF witness starts in a fresh disposable world");
helper.assertTrue(plan.isEmpty() && plan.claimsChunks().isEmpty(), "Disabled aerial generation reserves no space");
helper.assertTrue(StructureCensusService.aerialSites(level).isEmpty(), "Disabled aerial sites are not presented as planned");
var underground = fr.koka.sanctuary.worldgen.underground25.UndergroundStructures25.plan(generator);
helper.assertTrue(underground.starts().isEmpty() && underground.rooms().isEmpty(), "OptionsOFF leave no underground25 native start or dungeon room plan");
helper.assertTrue(generator.mineralSanctuary23().isEmpty() && generator.ruinsPlan17().isEmpty()
&& generator.heritagePlan19().sites().isEmpty() && generator.heritagePlan19().segments().isEmpty(),
"OptionsOFF remove the optional mineral secret, workshop, observatory and abandoned railway plans");
if (globalOff) helper.assertTrue(generator.transitPlan21().isEmpty() && generator.undergroundHall22().isEmpty(),
"The global Minecraft switch also disables core traversal and hall");
var binding = generator.starterContext();
helper.assertTrue(fr.koka.sanctuary.worldgen.Terrain25Runtime.enabled(binding.generator()), "OptionsOFF retain the named generation25 terrain delegate");
var shape = fr.koka.sanctuary.worldgen.Terrain25Runtime.shape(binding.generator(), binding.random());
helper.assertTrue(!shape.peaks().isEmpty(), "OptionsOFF retain the natural mountain plan");
var neutral = new LinkedHashSet<ChunkPos>();
for (var peak : shape.peaks()) {
int x = (int) Math.round(peak.x()), z = (int) Math.round(peak.z());
int top = fr.koka.sanctuary.worldgen.Terrain25Runtime.naturalTop(binding.generator(), binding.random(), x, z);
if (top > 260 && !generator.intersectsExperimentalStructures(x, top - 12, z, x, 383, z))
neutral.add(ChunkPos.containing(new BlockPos(x, top, z)));
if (neutral.size() == 3) break;
}
helper.assertTrue(neutral.size() == 3, "Three bounded high natural terrain witnesses are available with optionsOFF");
var forbiddenOptional = Set.of("sanctuary:mineral_sanctuary", "sanctuary:forgotten_networks", "sanctuary:heritage_networks",
"sanctuary:aerial_balloon", "sanctuary:aerial_merchant_ship", "sanctuary:aerial_mineral_islet",
"sanctuary:underground_abandoned_village", "sanctuary:miner_cabin");
var manufactured = Set.of(Blocks.RAIL, Blocks.POWERED_RAIL, Blocks.DETECTOR_RAIL, Blocks.ACTIVATOR_RAIL,
Blocks.STONE_BRICKS, Blocks.STONE_BRICK_STAIRS, Blocks.CHISELED_STONE_BRICKS, Blocks.IRON_BARS, Blocks.SMOOTH_STONE);
var terrainEvidence = new ArrayList<Map<String, Object>>();
long vanillaStarts = 0, rails = 0;
for (var at : neutral) {
var chunk = level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true);
var starts = chunk.getAllStarts().values().stream().filter(StructureStart::isValid).toList();
if (globalOff) helper.assertTrue(starts.isEmpty(), "GlobalOFF emits no native start in real FULL terrain");
for (var start : starts) {
String key = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getKey(start.getStructure()).toString();
helper.assertTrue(!forbiddenOptional.contains(key), "ExperimentalOFF cannot emit an optional Sanctuary secret start: " + key);
if (key.startsWith("minecraft:")) vanillaStarts++;
}
long highRock = 0, solids = 0;
for (var pos : BlockPos.betweenClosed(at.getMinBlockX(), 1, at.getMinBlockZ(), at.getMaxBlockX(), 383, at.getMaxBlockZ())) {
var state = chunk.getBlockState(pos);
if (state.is(net.minecraft.tags.BlockTags.RAILS)) rails++;
if (globalOff) helper.assertTrue(!manufactured.contains(state.getBlock()), "GlobalOFF leaves no secret architecture or rail blocks");
if (state.isCollisionShapeFullBlock(level, pos) && !state.is(net.minecraft.tags.BlockTags.LEAVES)
&& !state.is(net.minecraft.tags.BlockTags.LOGS)) {
solids++; if (pos.getY() >= 250 && pos.getY() <= 358) highRock++;
}
}
helper.assertTrue(highRock > 0 && solids > 0, "OptionsOFF retain real high generation25 land, not only an empty plan");
var biome = level.getBiome(new BlockPos(at.getMiddleBlockX(), 270, at.getMiddleBlockZ()));
helper.assertTrue(biome.unwrapKey().orElseThrow().identifier().toString().startsWith("sanctuary:terrain25_"),
"OptionsOFF retain the generation25 natural biome distribution");
terrainEvidence.add(Map.of("chunk", List.of(at.x(), at.z()), "solid_blocks", solids, "rock_above_y250", highRock,
"biome", biome.unwrapKey().orElseThrow().identifier().toString(), "native_starts", starts.size()));
}
report.put("off_mode", globalOff ? "global" : "experimental_only"); report.put("native_terrain", terrainEvidence);
report.put("underground25_plan_empty", true); report.put("optional_secret_and_rail_plans_empty", true);
report.put("vanilla_generation_allowed", !globalOff); report.put("vanilla_starts_in_sample", vanillaStarts);
report.put("rail_blocks_in_sample", rails); report.put("core_traversal_modules", generator.transitPlan21().modules().size());
report.put("core_hall_networks", generator.undergroundHall22().networks().size());
report.put("scope", globalOff ? "No structure starts or constructed blocks in three FULL native terrain witnesses."
: "Optional Sanctuary plans and starts disabled; core traversal, core hall and vanilla structures retain their own rules. Native mine rails are allowed.");
report.put("passed", true); write(Path.of("diagnostics/terrain25/world/" + (globalOff ? "global-off" : "experimental-off") + ".json"), report);
helper.succeed(); return;
}
helper.assertTrue(plan.sites().size() == 5, "All five initial aerial sites have a finite saved plan");
helper.assertTrue(plan.sites().stream().filter(AerialPlan24.Site::merchant).count() == 2, "Two merchant ships");
helper.assertTrue(plan.sites().stream().filter(AerialPlan24.Site::waterfall).count() == 1, "One waterfall site");
if (generator.initialDiameter() == 724 && level.getSeed() == 0)
AerialRenderer24.writeDiagnostic(Path.of("diagnostics/terrain25/world/blocks.json"), plan.sites());
report.put("geometry", JSON.toJsonTree(plan.sites())); report.put("claim_count", plan.claimsChunks().size());
report.put("census_before", StructureCensusService.aerialSites(level));
report.put("generator_codec", ChunkGenerator.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow());
var journalBefore = files(level.getServer().getWorldPath(LevelResource.ROOT).resolve("data").resolve(generator.journalDirectory()));
verifyClaims(helper, generator, plan, report);
Path world = level.getServer().getWorldPath(LevelResource.ROOT);
JsonObject previous = create ? null : JsonParser.parseString(Files.readString(world.resolve("terrain25-" + (phase.equals("reload1") ? "create" : "reload1") + ".json"))).getAsJsonObject();
if (previous != null) {
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload occurs in a separate JVM after a successful phase");
helper.assertTrue(previous.get("geometry").equals(JSON.toJsonTree(plan.sites())), "Site geometry and palette seeds survive reload");
helper.assertTrue(previous.get("generator_codec").equals(report.get("generator_codec")), "Generator options survive reload");
}
var chunks = new TreeSet<ChunkPos>(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z));
plan.sites().forEach(site -> chunks.addAll(site.claimedChunks()));
plan.sites().forEach(site -> chunks.add(ChunkPos.containing(new BlockPos(site.shoreX(), site.shoreY(), site.shoreZ()))));
// The two extracted merchants are retained in a separate, existing root chunk.
chunks.add(new ChunkPos(0, 0));
var starter = generator.starterContext();
var terrainPlan = fr.koka.sanctuary.worldgen.Terrain25Runtime.shape(starter.generator(), starter.random());
var terrainChunks = new TreeSet<ChunkPos>(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z));
for (var chamber : terrainPlan.chambers()) {
int cx = (int)Math.floor(chamber.x()) >> 4, cz = (int)Math.floor(chamber.z()) >> 4;
for(int dx=-1;dx<=1;dx++)for(int dz=-1;dz<=1;dz++)terrainChunks.add(new ChunkPos(cx+dx,cz+dz));
}
chunks.addAll(terrainChunks);
var loadOrder = new ArrayList<>(chunks);
boolean reverseChunks = Boolean.getBoolean("sanctuary.test.terrain25.reverseChunks");
if (reverseChunks) Collections.reverse(loadOrder);
report.put("reverse_chunks", reverseChunks);
var iterator = loadOrder.iterator();
long fullStarted=System.nanoTime();long[] fullNanos={0};int[] fullCalls={0};
report.put("heap_before_full",Runtime.getRuntime().totalMemory()-Runtime.getRuntime().freeMemory());
var entityChunks = new HashSet<ChunkPos>();
plan.sites().stream().filter(AerialPlan24.Site::merchant).forEach(site -> entityChunks.addAll(site.claimedChunks()));
entityChunks.add(new ChunkPos(0, 0));
// Only refuge chunks contain initial inhabitants; other cave blocks need FULL, not entity simulation.
for (var chamber : terrainPlan.chambers()) if (chamber.kind() == fr.koka.sanctuary.worldgen.Terrain25Shape.Kind.MUSHROOM) {
int cx = (int)Math.floor(chamber.x()) >> 4, cz = (int)Math.floor(chamber.z()) >> 4;
for (int dx=-1; dx<=1; dx++) for (int dz=-1; dz<=1; dz++) entityChunks.add(new ChunkPos(cx+dx,cz+dz));
}
boolean[] tickets = {false}; int[] wait = {0};
Runnable release = () -> {
if (tickets[0]) entityChunks.forEach(pos -> ChunkTickets25.remove(level, pos));
tickets[0] = false;
};
Runnable[] step = new Runnable[1];
step[0] = () -> {
try {
if (iterator.hasNext()) {
var chunk = iterator.next(); report.put("stage", "FULL " + chunk);
long chunkStart=System.nanoTime();
level.getChunk(chunk.x(), chunk.z(), ChunkStatus.FULL, true);
fullNanos[0]+=System.nanoTime()-chunkStart;fullCalls[0]++;
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run()); return;
}
if (!tickets[0]) {
entityChunks.forEach(pos -> ChunkTickets25.add(level, pos)); tickets[0] = true;
}
var occupants = occupants(level, plan);
var readiness = new TreeMap<String, Object>(); boolean entitiesReady = true;
for (var pos : entityChunks) {
boolean loaded = level.areEntitiesLoaded(pos.pack()), ticking = level.areEntitiesActuallyLoadedAndTicking(pos);
readiness.put(pos.toString(), Map.of("loaded", loaded, "ticking", ticking, "native_debug", level.getChunkSource().getChunkDebugData(pos)));
entitiesReady &= loaded && ticking;
}
report.put("entity_chunks", readiness);
report.put("occupants_observed", occupants.stream().map(villager -> Map.of("uuid", villager.getUUID().toString(),
"position", List.of(villager.getX(), villager.getY(), villager.getZ()))).toList());
report.put("visible_occupants", occupants.size()); report.put("entity_wait_ticks", wait[0]);
if ((!entitiesReady || occupants.size() < 2) && wait[0]++ < 6000) {
report.put("stage", "wait for persistent merchants");
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run()); return;
}
helper.assertTrue(entitiesReady, "All ship chunks finish native entity loading and become ticking before occupant inspection");
helper.assertTrue(occupants.size() == 2, "Both one-time merchants are present after native entity loading");
report.put("full_get_chunk_calls",fullCalls[0]);report.put("full_get_chunk_ms",fullNanos[0]/1_000_000);
report.put("full_loop_elapsed_ms",(System.nanoTime()-fullStarted)/1_000_000);
report.put("heap_after_full",Runtime.getRuntime().totalMemory()-Runtime.getRuntime().freeMemory());
report.put("memory_pool_peak",java.lang.management.ManagementFactory.getMemoryPoolMXBeans().stream()
.map(pool->Map.of("name",pool.getName(),"used",pool.getPeakUsage().getUsed(),"committed",pool.getPeakUsage().getCommitted())).toList());
report.put("observed_server_ticks",helper.getTick());
report.put("stage", "inspect native structures");
inspectTerrain(helper, generator, terrainChunks, report, create, previous);
var starts = starts(helper, level, plan);
var before = inspect(helper, level, plan, create, report);
if (create) {
verifyLocator(helper, level, plan, starts);
report.put("stage", "loot, extract and dismantle disposable witnesses");
report.put("mutations", mutate(helper, level, plan, occupants, before));
} else {
report.put("mutations", previous.get("mutations"));
verifyMutations(helper, level, plan, previous.getAsJsonObject("mutations"), occupants);
}
var tags = tags(level, starts);
if (!create) helper.assertTrue(previous.get("native_tags").equals(JSON.toJsonTree(tags)), "Native starts, references and occupant flags survive both reloads");
report.put("native_tags", tags); report.put("full_chunks", chunks.size());
report.put("blocks_after", countSites(level, plan));
var census = StructureCensusService.aerialSites(level);
helper.assertTrue(census.size() == 5 && census.stream().allMatch(site -> site.status() == StructureCensusService.AerialStatus.GENERATED),
"Five historical generated sites remain indexed after loot, mining and demolition");
report.put("census_after", census);
helper.assertTrue(equalFiles(journalBefore, files(world.resolve("data").resolve(generator.journalDirectory()))), "Aerial tests never reserve or activate an expansion");
report.put("expansion_journal_unchanged", true);
Mineral25NativeChecks.verify(helper, report);
StructureCensusService.flush(level.getServer());
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save actual blocks, loot state, starts and occupants");
report.put("fluid_scheduler", FluidTiming25.snapshot());
report.put("passed", true);
write(world.resolve("terrain25-" + phase + ".json"), report);
write(Path.of("diagnostics/terrain25/world/" + phase + ".json"), report);
release.run(); helper.succeed();
} catch (Exception error) {
release.run(); failure(report, error); throw new IllegalStateException("Terrain25 failed: " + error.getMessage(), error);
}
};
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run());
}
private static void inspectTerrain(GameTestHelper helper, SanctuaryChunkGenerator generator, Set<ChunkPos> terrainChunks,
Map<String,Object> report, boolean create, JsonObject previous) {
var level=helper.getLevel();var binding=generator.starterContext();
var plan=fr.koka.sanctuary.worldgen.Terrain25Runtime.shape(binding.generator(),binding.random());
var counts=new TreeMap<String,Integer>();
for(var cp:terrainChunks)for(int x=cp.getMinBlockX();x<=cp.getMaxBlockX();x++)for(int z=cp.getMinBlockZ();z<=cp.getMaxBlockZ();z++)
for(int y=1;y<364;y++) {
var state=level.getBlockState(new BlockPos(x,y,z));
if(!state.isAir())counts.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(),1,Integer::sum);
}
report.put("terrain_blocks",counts);report.put("terrain_sample_chunks",terrainChunks.size());
var chambers=new ArrayList<Map<String,Object>>();
for(var c:plan.chambers()) {
int walkable=0,water=0,lava=0;
for(int dx=-12;dx<=12;dx+=3)for(int dz=-12;dz<=12;dz+=3) {
var floor=new BlockPos((int)c.x()+dx,c.floorY(),(int)c.z()+dz);
if(level.getBlockState(floor).isSolid()&&level.getBlockState(floor.above()).isAir()&&level.getBlockState(floor.above(2)).isAir())walkable++;
if(level.getBlockState(floor.above()).is(Blocks.WATER))water++;
if(level.getBlockState(floor.above()).is(Blocks.LAVA))lava++;
}
chambers.add(Map.of("id",c.id(),"kind",c.kind(),"walkable_samples",walkable,"water_samples",water,"lava_samples",lava));
}
report.put("actual_chambers",chambers);
var cows=level.getEntitiesOfClass(net.minecraft.world.entity.animal.cow.MushroomCow.class,new AABB(-2048,0,-2048,2048,384,2048));
var ids=cows.stream().map(c->c.getUUID().toString()).sorted().toList();
report.put("mooshrooms",ids);
if(create) {
var changes=new TreeMap<String,Object>();
if(!cows.isEmpty()) {
var moved=cows.getFirst();
var at=new BlockPos(12,301,12);
for(int x=10;x<=14;x++)for(int z=10;z<=14;z++)level.setBlock(new BlockPos(x,300,z),Blocks.GRASS_BLOCK.defaultBlockState(),3);
moved.setNoAi(true); moved.teleportTo(at.getX()+.5,at.getY(),at.getZ()+.5);
changes.put("extracted_uuid",moved.getUUID().toString());
if(cows.size()>1){var removed=cows.get(1);changes.put("removed_uuid",removed.getUUID().toString());removed.discard();}
}
report.put("mooshroom_mutations",changes);
report.put("mooshroom_survivors",cows.stream().filter(c->!c.isRemoved()).map(c->c.getUUID().toString()).sorted().toList());
} else {
helper.assertTrue(previous.get("mooshroom_survivors").equals(JSON.toJsonTree(ids)),"Removed and extracted mooshrooms are not regenerated on reload");
report.put("mooshroom_mutations",previous.get("mooshroom_mutations"));report.put("mooshroom_survivors",previous.get("mooshroom_survivors"));
var changes=previous.getAsJsonObject("mooshroom_mutations");
if(changes.has("extracted_uuid")) {
var uuid=UUID.fromString(changes.get("extracted_uuid").getAsString());
helper.assertTrue(cows.stream().anyMatch(c->c.getUUID().equals(uuid)&&c.getY()>295),"Extracted mooshroom remains outside its original cave");
}
}
if(Boolean.getBoolean("sanctuary.test.terrain25.requireMooshrooms"))helper.assertTrue(!cows.isEmpty(),"Selected native witness contains actual mooshrooms, not merely a proposed refuge");
if(Boolean.getBoolean("sanctuary.test.terrain25.requireWaterLake"))helper.assertTrue(chambers.stream().anyMatch(c->c.get("kind")==fr.koka.sanctuary.worldgen.Terrain25Shape.Kind.LAKE && (int)c.get("water_samples")>0),"Selected native witness contains real underground lake water");
if(Boolean.getBoolean("sanctuary.test.terrain25.requireLavaLake"))helper.assertTrue(chambers.stream().anyMatch(c->c.get("kind")==fr.koka.sanctuary.worldgen.Terrain25Shape.Kind.LAVA && (int)c.get("lava_samples")>0),"Selected native witness contains real underground lake lava");
}
private static void verifyClaims(GameTestHelper helper, SanctuaryChunkGenerator generator, AerialPlan24 plan, Map<String, Object> report) {
var root = generator.source().islands().stream().filter(ExpansionIsland::origin).findFirst().orElseThrow();
var views = new LinkedHashMap<String, Object>();
for (var site : plan.sites()) {
helper.assertTrue(site.claimedChunks().stream().noneMatch(chunk -> root.ownsChunk(chunk.x(), chunk.z())), "No aerial claim replaces natural starter chunks");
var overlap = new ExpansionIsland("aerial_overlap", root.id(), "east", "temperate", 64,
site.startChunkX() * 16, site.startChunkZ() * 16, 42, "natural", "reserved", 25);
helper.assertTrue(plan.conflicts(overlap), "An unseen site already prevents overlapping expansion ownership");
double distance = Math.hypot(site.locator().getX() - site.shoreX(), site.locator().getZ() - site.shoreZ());
helper.assertTrue(distance <= 512, "Aerial site remains within 512 blocks of a real shore witness");
views.put(site.id(), Map.of("shore", List.of(site.shoreX(), site.shoreY() + 2, site.shoreZ()),
"entry", coordinates(AerialGeometry24.entry(site)), "centre", coordinates(site.locator()), "shore_distance", distance,
"exterior_view", List.of(site.minX() - 24, site.minY() + site.height() / 2, site.minZ() - 24)));
}
helper.assertTrue(!plan.conflicts(new ExpansionIsland("aerial_far", root.id(), "east", "temperate", 64, 4096, 4096, 42, "natural", "reserved", 25)),
"Unclaimed space remains available; there is no blanket exclusion ring");
report.put("look_views", views);
}
private static List<Villager> occupants(ServerLevel level, AerialPlan24 plan) {
var expected = plan.sites().stream().filter(AerialPlan24.Site::merchant).map(AerialGeometry24::occupantUuid).collect(java.util.stream.Collectors.toSet());
return level.getEntitiesOfClass(Villager.class, new AABB(-2048, 0, -2048, 2048, 384, 2048))
.stream().filter(villager -> expected.contains(villager.getUUID())).toList();
}
private static Map<String, StructureStart> starts(GameTestHelper helper, ServerLevel level, AerialPlan24 plan) {
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
var result = new TreeMap<String, StructureStart>();
for (var site : plan.sites()) {
var type = registry.get(Identifier.parse(AerialStructures24.structureId(site))).orElseThrow().value();
var start = level.getChunk(site.startChunkX(), site.startChunkZ(), ChunkStatus.FULL, true).getStartForStructure(type);
helper.assertTrue(start != null && start.isValid(), "Planned aerial site has a real persisted native start: " + site.id());
helper.assertTrue(start.getPieces().stream().filter(piece -> piece instanceof AerialPiece24 aerial && aerial.site().equals(site)).count() == 1,
"Exactly one piece retains the site's immutable descriptor");
var context = StructurePieceSerializationContext.fromLevel(level);
var tag = start.createTag(context, start.getChunkPos());
var restored = StructureStart.loadStaticStart(context, tag, level.getSeed());
helper.assertTrue(restored != null && tag.equals(restored.createTag(context, start.getChunkPos())), "Aerial start round-trips through native serialization");
if (site.merchant()) helper.assertTrue(start.getPieces().stream().filter(AerialPiece24.class::isInstance).map(AerialPiece24.class::cast)
.allMatch(AerialPiece24::occupantPlaced), "Merchant placement flag is stored after its first creation");
result.put(site.id(), start);
}
return result;
}
private static Map<String, Map<BlockPos, BlockState>> inspect(GameTestHelper helper, ServerLevel level, AerialPlan24 plan,
boolean fresh, Map<String, Object> report) {
var expected = new LinkedHashMap<String, Map<BlockPos, BlockState>>(); int chestCount = 0, waterSources = 0;
var vegetation = new TreeMap<String, Object>();
var shores = new TreeMap<String, Object>(); report.put("shore_witnesses", shores);
for (var site : plan.sites()) {
var model = render(site); expected.put(site.id(), model);
var shore = new BlockPos(site.shoreX(), site.shoreY(), site.shoreZ());
var column = new TreeMap<Integer, String>();
for (int dy = -2; dy <= 20; dy++) column.put(shore.getY() + dy, level.getBlockState(shore.above(dy)).toString());
shores.put(site.id(), Map.of("position", coordinates(shore), "column", column));
helper.assertTrue(level.getBlockState(shore.below()).isSolid(), "Saved shore witness has a real FULL terrain support: " + site.id() + " " + shore);
helper.assertTrue(level.getBlockState(shore.above()).getCollisionShape(level, shore.above()).isEmpty(),
"Shore viewpoint is not buried inside the island: " + site.id() + " " + shore.above() + " " + level.getBlockState(shore.above()));
var chestAt = AerialGeometry24.chest(site);
helper.assertTrue(level.getBlockEntity(chestAt) instanceof RandomizableContainerBlockEntity, "Real chest at " + site.id()); chestCount++;
var chest = (RandomizableContainerBlockEntity) level.getBlockEntity(chestAt);
if (fresh) {
helper.assertTrue(AerialRenderer24.loot(site).equals(chest.getLootTable()), "Aerial chest retains its unopened site-specific loot table");
for (var entry : model.entrySet()) if (!entry.getValue().isAir() && !entry.getValue().is(Blocks.WATER))
helper.assertTrue(level.getBlockState(entry.getKey()).is(entry.getValue().getBlock()), "Native FULL keeps authored material at " + site.id() + " " + entry.getKey());
for (var pos : AerialGeometry24.interiorChecks(site)) {
helper.assertTrue(level.getBlockState(pos).getCollisionShape(level, pos).isEmpty()
&& level.getBlockState(pos.above()).getCollisionShape(level, pos.above()).isEmpty(), "Usable two-block headroom at " + site.id());
helper.assertTrue(!level.getBlockState(pos.below()).getCollisionShape(level, pos.below()).isEmpty(), "Interior stands on a real floor");
}
if (site.kind() == AerialPlan24.Kind.MINERAL_ISLET) for (int dy = 1; dy <= 3; dy++)
helper.assertTrue(level.getBlockState(chestAt.above(dy)).isSolid(), "Mineral chest needs excavation through three layers");
} else helper.assertTrue(chest.getLootTable() == null && chest.isEmpty(), "Looted chest never refills on reload");
if (site.kind() == AerialPlan24.Kind.MINERAL_ISLET)
vegetation.put(site.id(), vegetation(helper, level, site));
if (site.waterfall()) {
var source = AerialGeometry24.waterfallSource(site);
helper.assertTrue(level.getFluidState(source).isSource(), "The one real source drives the native waterfall"); waterSources++;
}
// These columns are beyond the authored box and well outside initial terrain ownership.
for (var pos : List.of(new BlockPos(site.minX() - 2, site.minY(), site.minZ() - 2), new BlockPos(site.maxX() + 2, site.minY(), site.maxZ() + 2)))
for (int y = site.minY(); y <= site.maxY() + 2; y++) {
var state = level.getBlockState(pos.atY(y));
helper.assertTrue(state.isAir() || site.waterfall() && !state.getFluidState().isEmpty(), "No manufactured structure leaks outside its authored footprint");
}
}
helper.assertTrue(chestCount == 5 && waterSources == 1, "Five physical chests and exactly one planned source");
report.put("chests", chestCount); report.put("waterfall_sources", waterSources); report.put("blocks_before", countSites(level, plan));
report.put("vegetation", vegetation);
return expected;
}
private static Map<String, Object> vegetation(GameTestHelper helper, ServerLevel level, AerialPlan24.Site site) {
helper.assertTrue(site.rockHeight() >= 20 && site.rockHeight() <= 35
&& site.height() == site.rockHeight() + AerialPlan24.VEGETATION_HEADROOM,
"Tree headroom preserves the original stone height");
int surface = 0, grass = 0;
for (int x = 0; x < site.width(); x++) for (int z = 0; z < site.depth(); z++) {
int top = AerialGeometry24.terrainTop(site, x, z);
if (!AerialGeometry24.mineralBody(site, x, top, z)) continue;
surface++;
if (level.getBlockState(AerialGeometry24.world(site, x, top, z)).is(Blocks.GRASS_BLOCK)) grass++;
}
helper.assertTrue(grass * 2 > surface, "Most real intact surface columns carry grass: " + site.id());
var trees = AerialGeometry24.trees(site);
helper.assertTrue(!trees.isEmpty(), "At least one real tree on each mineral islet");
for (var tree : trees) {
var ground = AerialGeometry24.world(site, tree.x(), tree.groundY(), tree.z());
var floor = level.getBlockState(ground);
helper.assertTrue(floor.is(Blocks.GRASS_BLOCK) || floor.is(Blocks.DIRT), "Tree stands on soil, never in the air");
for (int y = 1; y <= tree.trunkHeight(); y++)
helper.assertTrue(level.getBlockState(ground.above(y)).is(tree.birch() ? Blocks.BIRCH_LOG : Blocks.OAK_LOG), "Native tree trunk stays continuous");
var crown = ground.above(tree.trunkHeight() + 1);
var leaves = level.getBlockState(crown);
helper.assertTrue(site.bounds().isInside(crown) && leaves.is(tree.birch() ? Blocks.BIRCH_LEAVES : Blocks.OAK_LEAVES)
&& !leaves.getValue(BlockStateProperties.PERSISTENT) && leaves.getValue(BlockStateProperties.DISTANCE) < 7,
"Complete natural crown survives native chunk decoration and reload");
}
return Map.of("rock_height", site.rockHeight(), "headroom", AerialPlan24.VEGETATION_HEADROOM,
"surface_columns", surface, "grass_columns", grass, "grass_percent", 100.0 * grass / surface, "trees", trees);
}
private static Map<String, Object> mutate(GameTestHelper helper, ServerLevel level, AerialPlan24 plan, List<Villager> occupants,
Map<String, Map<BlockPos, BlockState>> models) {
var emptied = new ArrayList<List<Integer>>(); var removed = new ArrayList<List<Integer>>(); var loot = new TreeMap<String, Integer>();
for (var site : plan.sites()) {
var at = AerialGeometry24.chest(site); var chest = (RandomizableContainerBlockEntity) level.getBlockEntity(at);
chest.unpackLootTable(null); int quantity = 0;
for (int slot = 0; slot < chest.getContainerSize(); slot++) quantity += chest.getItem(slot).getCount();
helper.assertTrue(quantity > 0, "Site-specific chest produces usable vanilla loot"); loot.put(site.id(), quantity);
chest.clearContent(); chest.setChanged(); emptied.add(coordinates(at));
if (site.kind() == AerialPlan24.Kind.MINERAL_ISLET) {
var ores = models.get(site.id()).entrySet().stream().filter(entry -> ore(entry.getValue())).sorted(Map.Entry.comparingByKey(
Comparator.<BlockPos>comparingInt(BlockPos::getX).thenComparingInt(BlockPos::getY).thenComparingInt(BlockPos::getZ))).limit(16).toList();
helper.assertTrue(!ores.isEmpty(), "Each mineral island contains actual finite veins");
for (var entry : ores) { level.setBlock(entry.getKey(), Blocks.AIR.defaultBlockState(), 2); removed.add(coordinates(entry.getKey())); }
}
}
var balloon = plan.sites().stream().filter(site -> site.kind() == AerialPlan24.Kind.BALLOON).findFirst().orElseThrow();
var demolition = AerialGeometry24.entry(balloon).below();
helper.assertTrue(level.getBlockState(demolition).isSolid(), "The demolition witness removes an existing floor");
level.setBlock(demolition, Blocks.AIR.defaultBlockState(), 2); removed.add(coordinates(demolition));
var extracted = new TreeMap<String, List<Double>>(); int i = 0;
for (var villager : occupants.stream().sorted(Comparator.comparing(v -> v.getUUID().toString())).toList()) {
// This fixture parks extracted merchants in an existing root chunk, outside both ships.
villager.setNoAi(true); villager.setNoGravity(true); villager.setDeltaMovement(Vec3.ZERO);
villager.snapTo(4.5 + i++ * 4, 330, 4.5); villager.setPersistenceRequired();
extracted.put(villager.getUUID().toString(), List.of(villager.getX(), villager.getY(), villager.getZ()));
}
return Map.of("emptied_chests", emptied, "removed_blocks", removed, "extracted_merchants", extracted, "loot_items_obtained", loot);
}
private static void verifyMutations(GameTestHelper helper, ServerLevel level, AerialPlan24 plan, JsonObject mutations, List<Villager> occupants) {
for (var value : mutations.getAsJsonArray("removed_blocks")) {
var pos = position(value.getAsJsonArray()); helper.assertTrue(level.getBlockState(pos).isAir(), "Mined ore and dismantled floor remain absent after reload");
}
var extracted = mutations.getAsJsonObject("extracted_merchants");
for (var villager : occupants) {
var at = extracted.getAsJsonArray(villager.getUUID().toString());
helper.assertTrue(at != null && Math.abs(villager.getX() - at.get(0).getAsDouble()) < .01
&& Math.abs(villager.getY() - at.get(1).getAsDouble()) < .01 && Math.abs(villager.getZ() - at.get(2).getAsDouble()) < .01,
"Extracted merchant stays outside its original ship without respawning");
}
for (var site : plan.sites()) if (site.merchant()) helper.assertTrue(level.getEntitiesOfClass(Villager.class, AABB.of(site.bounds())).isEmpty(),
"The visited ship does not replenish an extracted merchant");
}
private static void verifyLocator(GameTestHelper helper, ServerLevel level, AerialPlan24 plan, Map<String, StructureStart> starts) {
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
var holders = new ArrayList<Holder<Structure>>();
plan.sites().stream().map(AerialStructures24::structureId).distinct().forEach(id -> holders.add(registry.get(Identifier.parse(id)).orElseThrow()));
holders.add(registry.get(Identifier.withDefaultNamespace("stronghold")).orElseThrow());
var wanted = HolderSet.direct(holders); var origin = plan.sites().getFirst().locator();
var activeBefore = ((SanctuaryChunkGenerator) level.getChunkSource().getGenerator()).source().islands();
int before = starts.values().stream().mapToInt(StructureStart::getReferences).sum();
var found = ExpansionStructures23.findNearest(level, wanted, origin, 100, false);
helper.assertTrue(found != null, "Mixed vanilla/aerial locator finds a real accepted start");
helper.assertTrue(starts.values().stream().mapToInt(StructureStart::getReferences).sum() == before, "Ordinary locate never marks a site referenced");
var referenced = ExpansionStructures23.findNearest(level, wanted, origin, 100, true);
helper.assertTrue(referenced != null && starts.values().stream().mapToInt(StructureStart::getReferences).sum() == before + 1,
"Mixed map lookup references exactly its one winning aerial start");
helper.assertTrue(activeBefore.equals(((SanctuaryChunkGenerator) level.getChunkSource().getGenerator()).source().islands()), "Locator never activates an expansion");
}
private static Map<BlockPos, BlockState> render(AerialPlan24.Site site) {
var result = new HashMap<BlockPos, BlockState>();
AerialRenderer24.render(site, new LostCityRenderer.BlockWriter() {
public void set(int x, int y, int z, BlockState state) { result.put(new BlockPos(site.minX() + x, site.minY() + y, site.minZ() + z), state); }
public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) { set(x, y, z, Blocks.CHEST.defaultBlockState()); }
});
return result;
}
private static Map<String, Map<String, Integer>> countSites(ServerLevel level, AerialPlan24 plan) {
var all = new TreeMap<String, Map<String, Integer>>();
for (var site : plan.sites()) {
var counts = new TreeMap<String, Integer>();
for (int x = site.minX(); x <= site.maxX(); x++) for (int z = site.minZ(); z <= site.maxZ(); z++) for (int y = site.minY(); y <= site.maxY(); y++) {
var state = level.getBlockState(new BlockPos(x, y, z));
if (!state.isAir()) counts.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), 1, Integer::sum);
}
all.put(site.id(), counts);
}
return all;
}
private static boolean ore(BlockState state) { return BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath().endsWith("_ore"); }
private static Map<String, String> tags(ServerLevel level, Map<String, StructureStart> starts) {
var result = new TreeMap<String, String>(); var context = StructurePieceSerializationContext.fromLevel(level);
starts.forEach((id, start) -> result.put(id, start.createTag(context, start.getChunkPos()).toString())); return result;
}
private static List<Integer> coordinates(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
private static BlockPos position(com.google.gson.JsonArray value) { return new BlockPos(value.get(0).getAsInt(), value.get(1).getAsInt(), value.get(2).getAsInt()); }
private static Map<String, byte[]> files(Path directory) throws java.io.IOException {
var found = new TreeMap<String, byte[]>();
if (Files.exists(directory)) try (var paths = Files.walk(directory)) {
for (var path : paths.filter(Files::isRegularFile).toList()) found.put(directory.relativize(path).toString(), Files.readAllBytes(path));
}
return found;
}
private static boolean equalFiles(Map<String, byte[]> a, Map<String, byte[]> b) {
return a.keySet().equals(b.keySet()) && a.entrySet().stream().allMatch(entry -> Arrays.equals(entry.getValue(), b.get(entry.getKey())));
}
private static void write(Path path, Map<String, Object> report) throws java.io.IOException { Files.createDirectories(path.toAbsolutePath().getParent()); Files.writeString(path, JSON.toJson(report)); }
private static void failure(Map<String, Object> report, Exception error) {
report.put("passed", false); report.put("failure", error.toString());
var stack = new java.io.StringWriter(); error.printStackTrace(new java.io.PrintWriter(stack)); report.put("stacktrace", stack.toString());
SanctuaryMod.LOGGER.error("Terrain25 fixture failed at {}", report.get("stage"), error);
try { write(Path.of("diagnostics/terrain25/world/failure.json"), report); } catch (java.io.IOException failed) { error.addSuppressed(failed); }
}
}
@@ -0,0 +1,453 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
import fr.koka.sanctuary.worldgen.transit.TransitPiece21;
import fr.koka.sanctuary.worldgen.transit.TransitPlan21;
import fr.koka.sanctuary.worldgen.transit.TransitRenderer21;
import fr.koka.sanctuary.worldgen.transit.TransitStructures21;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.RegistryOps;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.StairBlock;
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.LevelResource;
import net.minecraft.world.level.storage.loot.LootTable;
import net.minecraft.world.phys.AABB;
/** Opt-in native proof confined to the new generation21 starter island in a disposable development world. */
public final class Transit21WorldGameTests {
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
@GameTest(maxTicks = 12000)
public void industrialTransitPersistsNatively(GameTestHelper helper) {
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
"The industrial-transits proof requires a Sanctuary generator");
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 21, "Use a fresh generation21 test world; do not convert old saves");
new Scenario(helper, generator, Boolean.getBoolean("sanctuary.test.expansionReload")).advance();
}
private static final class Scenario {
final GameTestHelper helper;
final ServerLevel level;
final SanctuaryChunkGenerator generator;
final TransitPlan21 plan;
final boolean reload;
final Map<String, Object> report = new LinkedHashMap<>();
final List<ChunkPos> chunks = new ArrayList<>();
final List<StructureStart> starts = new ArrayList<>();
final Map<BlockPos, BlockState> allGeometry = new HashMap<>();
JsonObject previous;
BlockPos gold;
int nextChunk;
boolean prepared;
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator, boolean reload) {
this.helper = helper; this.level = helper.getLevel(); this.generator = generator; this.reload = reload;
this.plan = generator.transitPlan21();
report.put("seed", level.getSeed()); report.put("size", generator.initialDiameter());
report.put("process_id", ProcessHandle.current().pid()); report.put("world_directory", world(level).toString());
report.put("experimental_structures", generator.experimentalStructures());
report.put("structures_enabled", level.structureManager().shouldGenerateStructures());
report.put("planning_diagnostic", plan.diagnostic());
report.put("scope", "Native starts, references, geometry, rock bearings, unopened loot and a player-sized stair walk between two surface gates. "
+ "No expansion is created and no personal save is opened. A single outer-wall gold edit in this disposable world "
+ "is saved and checked in a second JVM. Traversal remains inside the modules and certified portico approaches; underground one-block rises need real correctly oriented stairs. Only the certified natural portico approaches permit an ordinary one-block jump. No flight, ladder or external terrain shortcut is allowed. No fluid simulation is claimed.");
}
void advance() {
try {
if (!prepared) {
prepare(); prepared = true;
if (plan.isEmpty()) { finishDisabled(); return; }
}
if (nextChunk < chunks.size()) {
var pos = chunks.get(nextChunk++);
var loaded = level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
helper.assertTrue(loaded instanceof LevelChunk, "Every room footprint must reach native FULL: " + pos);
if (nextChunk % 10 == 0) SanctuaryMod.LOGGER.info("Industrial transit21: loaded {}/{} footprint chunks", nextChunk, chunks.size());
helper.runAtTickTime(helper.getTick() + 1, this::advance); return;
}
verifyPlaced();
report.put("passed", true);
if (!reload) {
helper.assertTrue(gold != null && level.getBlockState(gold).isCollisionShapeFullBlock(level, gold),
"Gameplay witness must replace a solid outer wall away from every doorway");
level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3);
report.put("gold_witness", coords(gold));
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native structures, deferred loot and the gold edit");
report.put("world_flushed_before_shutdown", true);
} else {
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Reload replaced the existing wall edit");
report.put("gold_witness", coords(gold)); report.put("gold_witness_preserved", true);
}
write(level, report, reload); helper.succeed();
} catch (Exception failure) {
report.put("passed", false); report.put("failure", failure.toString());
try { write(level, report, reload); } catch (IOException ignored) { }
throw new IllegalStateException("Industrial transit21 native " + (reload ? "reload" : "creation") + " failed", failure);
}
}
void prepare() throws IOException {
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
var encoded = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow();
var decoded = ChunkGenerator.CODEC.parse(ops, encoded).getOrThrow();
helper.assertTrue(decoded instanceof SanctuaryChunkGenerator copy && copy.rootGeneration() == 21
&& copy.experimentalStructures() == generator.experimentalStructures(), "The generation21 codec must preserve its experimental switch");
report.put("generator_codec", encoded); report.put("plan_sha256", planHash(plan)); report.put("planning_samples", plan.samples());
helper.assertTrue(plan.samples() <= TransitPlan21.MAX_SAMPLES, "Transit planning exceeded its budget");
if (reload) {
previous = JsonParser.parseString(Files.readString(proof(level))).getAsJsonObject();
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(),
"Reload requires the successful, flushed creation proof");
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must run in a separate JVM");
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed()
&& previous.get("size").getAsInt() == generator.initialDiameter()
&& previous.get("world_directory").getAsString().equals(world(level).toString()), "Reload used another world, seed or size");
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("plan_sha256").getAsString().equals(planHash(plan)),
"Saved switch or planned generation21 room identities changed on reload");
if (previous.has("gold_witness")) gold = readPos(previous.getAsJsonArray("gold_witness"));
}
boolean enabled = generator.experimentalStructures() && level.structureManager().shouldGenerateStructures();
if (!enabled) {
helper.assertTrue(plan.isEmpty(), "Experimental=false or generateStructures=false must publish no industrial transit");
return;
}
helper.assertTrue(!plan.isEmpty(), "Enabled reference island has no experimental network");
helper.assertTrue(plan.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.ACCESS).count() == 2,
"A through-route needs two independent saved surface stairs");
int spanX = plan.modules().stream().mapToInt(TransitPlan21.Module::maxX).max().orElseThrow()
- plan.modules().stream().mapToInt(TransitPlan21.Module::minX).min().orElseThrow() + 1;
int spanZ = plan.modules().stream().mapToInt(TransitPlan21.Module::maxZ).max().orElseThrow()
- plan.modules().stream().mapToInt(TransitPlan21.Module::minZ).min().orElseThrow() + 1;
report.put("span_x", spanX); report.put("span_z", spanZ);
helper.assertTrue(Math.max(spanX, spanZ) >= generator.initialDiameter() * .4,
"The native transit reference must cross at least40% of the island's nominal diameter");
report.put("modules", plan.modules().size());
report.put("halls", plan.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.HALL).count());
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(TransitStructures21.KEY);
var origins = new HashSet<Long>(); var uniqueChunks = new HashSet<ChunkPos>();
for (var network : plan.modules()) {
long origin = new ChunkPos(network.startChunkX(), network.startChunkZ()).pack();
if (!origins.add(origin)) continue;
var chunk = level.getChunk(network.startChunkX(), network.startChunkZ(), ChunkStatus.STRUCTURE_STARTS, true);
var start = chunk.getStartForStructure(structure.value());
helper.assertTrue(start != null && start.isValid(), "Accepted experimental network has no native start: " + network.kind() + " at " + network.centerX() + "," + network.centerZ());
starts.add(start);
for (var piece : start.getPieces()) {
helper.assertTrue(piece instanceof TransitPiece21, "Industrial transit loaded a foreign or historical piece type");
piece.getBoundingBox().intersectingChunks().forEach(at -> {
helper.assertTrue(Math.abs(at.x() - network.startChunkX()) <= 7 && Math.abs(at.z() - network.startChunkZ()) <= 7,
"Transit native start exceeds its local reference range");
uniqueChunks.add(at);
});
}
}
helper.assertTrue(uniqueChunks.size() <= 256, "The bounded root-only proof unexpectedly covers more than256 chunks");
chunks.addAll(uniqueChunks.stream().sorted(java.util.Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList());
}
void finishDisabled() throws IOException {
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(TransitStructures21.KEY);
var chunk = level.getChunk(0, 0, ChunkStatus.STRUCTURE_STARTS, true);
var start = chunk.getStartForStructure(structure.value());
helper.assertTrue(start == null || !start.isValid(), "Disabled experimental generation emitted a native start");
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
helper.assertTrue(located == null, "Disabled experimental structures must not be located or prepared");
report.put("empty_plan_verified", true); report.put("passed", true);
if (!reload) {
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save the disabled experiment setting");
report.put("world_flushed_before_shutdown", true);
}
write(level, report, reload); helper.succeed();
}
void verifyPlaced() {
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(TransitStructures21.KEY);
var context = StructurePieceSerializationContext.fromLevel(level);
var sites = new ArrayList<Map<String, Object>>();
var modules = new ArrayList<Map<String, Object>>();
int blocks = 0, supports = 0, chests = 0, pieces = 0;
var bearingMasks = new HashMap<TransitPlan21.Module, Integer>();
for (var start : starts) {
var origin = start.getChunkPos(); var saved = start.createTag(context, origin);
var copy = StructureStart.loadStaticStart(context, saved, level.getSeed());
helper.assertTrue(copy != null && copy.isValid() && copy.createTag(context, origin).equals(saved), "The native start changed on an NBT roundtrip");
var expectedRooms = plan.modulesForChunk(origin.x(), origin.z());
var actualRooms = start.getPieces().stream().map(TransitPiece21.class::cast).map(TransitPiece21::module).toList();
helper.assertTrue(actualRooms.size() == expectedRooms.size() && new HashSet<>(actualRooms).equals(new HashSet<>(expectedRooms)),
"Native start did not persist every planned room exactly once");
String nativeHash = hash(saved.toString());
if (reload) {
JsonObject prior = null;
for (var value : previous.getAsJsonArray("starts")) {
var site = value.getAsJsonObject(); var at = site.getAsJsonArray("start_chunk");
if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = site;
}
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(nativeHash),
"Reload altered native room parameters, supports or reference count");
}
var loot = new ArrayList<Map<String, Object>>();
for (var piece : start.getPieces()) {
var roomPiece = (TransitPiece21) piece; var room = roomPiece.module(); pieces++;
piece.getBoundingBox().intersectingChunks().forEach(at -> {
var full = level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true);
helper.assertTrue(full.getReferencesForStructure(structure.value()).contains(origin.pack()), "A saved FULL chunk lost the native structure reference");
});
var roomTag = roomPiece.createTag(context);
var roundtrip = new TransitPiece21(context, roomTag);
helper.assertTrue(roundtrip.module().equals(room) && roundtrip.createTag(context).equals(roomTag), "The native room failed its exact saved-field roundtrip");
var corrupt = roomTag.copy(); var payload = corrupt.getCompound("SanctuaryModule").orElseThrow(); payload.putInt("Schema", 99);
boolean rejected = false;
try { new TransitPiece21(context, corrupt); } catch (IllegalArgumentException expected) { rejected = true; }
helper.assertTrue(rejected, "An unknown generation21 piece schema must be refused");
var expected = rendered(room); blocks += expected.blocks().size();
expected.blocks().forEach((at, state) -> helper.assertTrue(allGeometry.putIfAbsent(at, state) == null,
"Two saved transit modules paint the same position: " + at));
modules.add(Map.of("kind", room.kind().name(), "min", List.of(room.minX(), room.minY(), room.minZ()),
"max", List.of(room.maxX(), room.maxY(), room.maxZ()), "floor_y", room.floorY(),
"surface_y", room.surfaceY(), "openings", room.openings(), "supports", room.supports().size(),
"start_chunk", List.of(room.startChunkX(), room.startChunkZ())));
for (var entry : expected.blocks().entrySet()) {
var at = entry.getKey(); var wanted = entry.getValue();
helper.assertTrue(TransitPiece21.bounds(room).isInside(at), "Renderer escaped its saved room/support bounds");
helper.assertTrue(plan.protects(at.getX(), at.getY(), at.getZ()) && generator.protectsCityDecoration(at),
"Transit construction/cleared air is missing its published decoration protection at " + at);
if (reload && at.equals(gold)) continue;
helper.assertTrue(level.getBlockState(at).is(wanted.getBlock()),
"Native clipping or later decoration changed " + room.kind() + " at " + at + ": expected " + wanted + " got " + level.getBlockState(at));
helper.assertTrue(!level.getBlockState(at).is(Blocks.SPAWNER), "Experimental architecture must not create a spawner");
}
supports += verifyBearings(helper, generator, room, bearingMasks);
for (var entry : expected.blocks().entrySet()) if (entry.getValue().is(Blocks.LANTERN)) {
var at = entry.getKey(); var state = level.getBlockState(at);
helper.assertTrue(state.canSurvive(level, at) && level.getBlockState(at.above()).isFaceSturdy(level, at.above(), Direction.DOWN),
"Transit lamp lost its complete ceiling bearing at " + at);
}
for (var chest : expected.chests()) {
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Deferred native chest block entity is missing");
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
helper.assertTrue(chest.table().equals(entity.getLootTable()) && entity.getLootTableSeed() == chest.seed(),
"Native chest loot must remain deferred with its exact table and seed");
loot.add(Map.of("position", coords(chest.position()), "table", chest.table().identifier().toString(), "seed", chest.seed())); chests++;
}
if (!reload && gold == null && room.kind() != TransitPlan21.Kind.ACCESS && room.kind() != TransitPlan21.Kind.JUNCTION)
gold = new BlockPos(room.minX(), room.floorY() + 1, room.minZ() + 1);
}
sites.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", nativeHash,
"pieces", start.getPieces().size(), "unopened_chests", loot));
}
report.put("module_coordinates", modules);
report.put("starts", sites);
int weakSpan = 0, longestSpan = 0;
for (var room : plan.modules()) {
weakSpan = bearingMasks.get(room) == 15 ? 0 : weakSpan + 1;
longestSpan = Math.max(longestSpan, weakSpan);
helper.assertTrue(weakSpan <= 2, "Native deck spans more than 34 blocks without distributed rock bearings");
}
report.put("longest_span_without_full_bearings", longestSpan * 17);
var ores = new java.util.TreeMap<String, Integer>();
allGeometry.values().forEach(state -> {
var id = net.minecraft.core.registries.BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString();
if (id.endsWith("_ore")) ores.merge(id, 1, Integer::sum);
});
report.put("finite_exposed_ores", ores);
var traversal = verifyAccess(helper, plan);
report.put("native_connections", NativeConnections20.verify(helper, allGeometry,
reload && gold != null ? Set.of(gold) : Set.of()));
report.put("module_coordinates", modules);
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
helper.assertTrue(located != null && located.getSecond().is(TransitStructures21.KEY), "Native locate cannot find a saved industrial transit");
helper.assertTrue(plan.modules().stream().filter(r -> r.kind() == TransitPlan21.Kind.ACCESS)
.anyMatch(r -> TransitPiece21.locator(r).equals(located.getFirst())), "Locate must lead to a surface entrance rather than an underground floor");
report.put("starts", sites); report.put("native_pieces", pieces); report.put("native_blocks_checked", blocks);
report.put("full_chunks_checked", chunks.size()); report.put("rock_supports_checked", supports);
report.put("unopened_deferred_chests", chests); report.put("native_routes", traversal); report.put("locate", coords(located.getFirst()));
}
}
private record Chest(BlockPos position, ResourceKey<LootTable> table, long seed) {}
private record Rendered(Map<BlockPos, BlockState> blocks, List<Chest> chests) {}
private static Rendered rendered(TransitPlan21.Module room) {
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
TransitRenderer21.render(room, new LostCityRenderer.BlockWriter() {
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(at(room, x, y, z), state); }
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
var position = at(room, x, y, z); blocks.put(position, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(position, table, RandomSource.create(seed).nextLong()));
}
});
return new Rendered(blocks, chests);
}
private static int verifyBearings(GameTestHelper helper, SanctuaryChunkGenerator generator, TransitPlan21.Module room, Map<TransitPlan21.Module, Integer> masks) {
var level = helper.getLevel(); int checked = 0, quarters = 0;
for (int z = room.minZ();; z = Math.min(z + 4, room.maxZ())) {
for (int x = room.minX();; x = Math.min(x + 4, room.maxX())) {
int anchor = room.minY() - 1;
for (var support : room.supports()) if (support.x() == x && support.z() == z) anchor = support.minY() - 1;
var bearing = new BlockPos(x, anchor, z);
boolean supported = level.getBlockState(bearing).isSolid() && level.getFluidState(bearing).isEmpty()
&& level.getBlockState(bearing.below()).isSolid() && level.getFluidState(bearing.below()).isEmpty();
if (supported) {
quarters |= 1 << ((x < room.centerX() ? 0 : 1) + (z < room.centerZ() ? 0 : 2));
for (int dy = 0; dy >= -1; dy--) {
var at = new BlockPos(x, anchor + dy, z);
helper.assertTrue(level.getBlockState(at).isSolid() && level.getFluidState(at).isEmpty(),
"Transit bearing grid lost one of its two untouched rock layers at " + at);
helper.assertTrue(generator.protectsCityDecoration(at), "A certified transit rock dependency is unprotected at " + at);
checked++;
}
}
if (x == room.maxX()) break;
}
if (z == room.maxZ()) break;
}
helper.assertTrue(room.kind() == TransitPlan21.Kind.GALLERY || room.kind() == TransitPlan21.Kind.JUNCTION || quarters == 15, "The continuous deck lost its distributed rock bearings");
masks.put(room, quarters);
return checked;
}
static Map<String, Object> verifyAccess(GameTestHelper helper, TransitPlan21 plan) {
var level = helper.getLevel();
var accesses = plan.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.ACCESS).toList();
helper.assertTrue(accesses.size() == 2, "Native transit requires exactly two surface gates");
Predicate<BlockPos> inside = pos -> plan.contains(pos.getX(), pos.getY(), pos.getZ())
|| accesses.stream().anyMatch(m -> pos.getX() >= m.centerX() - 2 && pos.getX() <= m.centerX() + 2
&& pos.getZ() >= m.minZ() - 3 && pos.getZ() < m.minZ()
&& pos.getY() >= m.surfaceY() && pos.getY() <= m.surfaceY() + 2);
var start = TransitPiece21.locator(accesses.getFirst());
Predicate<BlockPos> naturalApproach = pos -> accesses.stream().anyMatch(m ->
pos.getX() >= m.centerX() - 2 && pos.getX() <= m.centerX() + 2
&& pos.getZ() >= m.minZ() - 3 && pos.getZ() < m.minZ()
&& pos.getY() >= m.surfaceY() && pos.getY() <= m.surfaceY() + 2);
var reached = walk(helper, start, inside, naturalApproach); int targets = 0;
var arrivals = new ArrayList<Map<String, Object>>();
for (var module : plan.modules()) for (var target : TransitRenderer21.targets(module)) {
var point = at(module, target.x(), target.y(), target.z());
helper.assertTrue(reached.containsKey(Foot.at(point)), "Native transit cannot walk from its first portico to "
+ module.kind() + "/" + target.name() + " at " + point);
targets++;
}
for (var access : accesses) {
var gate = TransitPiece21.locator(access);
helper.assertTrue(reached.containsKey(Foot.at(gate)), "The second independent surface gate is not connected by real stairs");
int approaches = 0;
for (int x = access.centerX() - 2; x <= access.centerX() + 2; x++) for (int z = access.minZ() - 3; z < access.minZ(); z++) {
var high = new BlockPos(x, access.surfaceY() + 1, z);
var low = high.below(); var position = standable(level, high) ? high : low;
helper.assertTrue(standable(level, position) && reached.containsKey(Foot.at(position)),
"The certified natural approach no longer joins its portico at " + position);
helper.assertTrue(level.getBlockState(position.below()).isSolid()
&& level.getFluidState(position.below()).isEmpty(), "A surface arrival is suspended above air or water");
approaches++;
}
arrivals.add(Map.of("gate", coords(gate), "roadway", coords(at(access, 8, 2, 8)),
"rise", access.surfaceY() - access.floorY(), "steps_from_first_gate", reached.get(Foot.at(gate)),
"natural_approach_cells", approaches));
}
return Map.of("surface_accesses", arrivals, "walkable_targets", targets, "standing_cells", reached.size(),
"surface_to_surface_steps", reached.get(Foot.at(TransitPiece21.locator(accesses.getLast()))),
"traversal_policy", "0.6 by1.8 native collision volume, half-block sidewalk steps; underground rises require a real forward-facing stair; natural portico approaches alone permit a normal one-block jump; no vertical moves");
}
/** Half-block standing elevations retain real sidewalk slabs without pretending an integer cell can hover over them. */
private record Foot(int x, int halfY, int z) {
static Foot at(BlockPos pos) { return new Foot(pos.getX(), pos.getY() * 2, pos.getZ()); }
double y() { return halfY / 2.0; }
BlockPos block() { return new BlockPos(x, Math.floorDiv(halfY, 2), z); }
BlockPos bearing() { return new BlockPos(x, Math.floorDiv(halfY - 1, 2), z); }
Foot rise(int halves) { return new Foot(x, halfY + halves, z); }
Foot move(Direction direction, int halves) { return new Foot(x + direction.getStepX(), halfY + halves, z + direction.getStepZ()); }
}
private static Map<Foot, Integer> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside, Predicate<BlockPos> naturalApproach) {
var level = helper.getLevel(); var first = Foot.at(start);
helper.assertTrue(inside.test(start) && standable(level, first), "Blocked native surface portico: " + start);
var seen = new HashMap<Foot, Integer>(); var queue = new ArrayDeque<Foot>(); seen.put(first, 0); queue.add(first);
while (!queue.isEmpty()) {
var pos = queue.removeFirst();
for (var direction : HORIZONTAL) for (int dy = -2; dy <= 2; dy++) {
var next = pos.move(direction, dy);
if (!inside.test(next.block()) || seen.containsKey(next)) continue;
boolean ordinaryThreshold = naturalApproach.test(pos.block()) || naturalApproach.test(next.block());
// Native step height is enough for a half slab; a full block needs the two treads of a stair.
if (dy > 0 && (!clearBody(level, pos.rise(dy))
|| dy == 2 && !ordinaryThreshold && !ascendingStair(level, next.bearing(), direction))) continue;
if (dy < 0 && (!clearBody(level, next.rise(-dy))
|| dy == -2 && !ordinaryThreshold && !ascendingStair(level, pos.bearing(), direction.getOpposite()))) continue;
if (standable(level, next)) { seen.put(next, seen.get(pos) + 1); queue.add(next); }
}
helper.assertTrue(seen.size() <= 120_000, "Native transit traversal exceeded its bounded room volumes");
}
return seen;
}
private static boolean ascendingStair(ServerLevel level, BlockPos pos, Direction direction) {
var state = level.getBlockState(pos);
return state.getBlock() instanceof StairBlock
&& state.getValue(BlockStateProperties.HORIZONTAL_FACING) == direction
&& state.getValue(BlockStateProperties.HALF) == net.minecraft.world.level.block.state.properties.Half.BOTTOM;
}
private static boolean standable(ServerLevel level, BlockPos pos) { return standable(level, Foot.at(pos)); }
private static boolean standable(ServerLevel level, Foot pos) {
if (!clearBody(level, pos)) return false;
var below = pos.bearing(); double top = pos.y() - below.getY();
return level.getBlockState(below).getCollisionShape(level, below).toAabbs().stream()
.anyMatch(b -> Math.abs(b.maxY - top) < .0001 && b.minX <= .5 && b.maxX >= .5 && b.minZ <= .5 && b.maxZ >= .5);
}
private static boolean clearBody(ServerLevel level, Foot pos) {
var body = new AABB(pos.x() + .2, pos.y(), pos.z() + .2, pos.x() + .8, pos.y() + 1.8, pos.z() + .8);
for (int y = (int) Math.floor(pos.y()); y <= (int) Math.floor(pos.y() + 1.8 - .0001); y++) {
var at = new BlockPos(pos.x(), y, pos.z());
if (!level.getFluidState(at).isEmpty()) return false;
for (var shape : level.getBlockState(at).getCollisionShape(level, at).toAabbs())
if (shape.move(at.getX(), at.getY(), at.getZ()).intersects(body)) return false;
}
return true;
}
private static BlockPos at(TransitPlan21.Module room, int x, int y, int z) { return new BlockPos(room.minX() + x, room.minY() + y, room.minZ() + z); }
private static List<Integer> coords(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
private static BlockPos readPos(com.google.gson.JsonArray value) { return new BlockPos(value.get(0).getAsInt(), value.get(1).getAsInt(), value.get(2).getAsInt()); }
private static Path world(ServerLevel level) { return level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); }
private static Path proof(ServerLevel level) { return world(level).resolve("transit21-create-proof.json"); }
private static String planHash(TransitPlan21 plan) { return hash(plan.modules().toString()); }
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
var path = Path.of("diagnostics", "transit21-" + (reload ? "reload" : "create") + ".json"); Files.createDirectories(path.getParent());
var value = new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n";
Files.writeString(path, value);
if (!reload) Files.writeString(proof(level), value);
}
private static String hash(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
}
}
@@ -0,0 +1,126 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.worldgen.city.LostCityUnderground16;
import fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Kind;
import fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Module;
import java.util.ArrayDeque;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.StairBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
/** Native circulation proof on the already generated piece footprint. */
public final class Underground22NativeChecks {
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
private Underground22NativeChecks() {}
public static Map<String, Integer> verify(GameTestHelper helper, LostCityUnderground16.Plan plan) {
var level = helper.getLevel(); var chunks = new HashSet<Long>();
int halls = 0, ladders = 0, apertures = 0, walked = 0;
helper.assertTrue(!plan.isEmpty(), "Independent underground hall plan must be present");
for (var network : plan.networks()) {
for (var m : network.modules()) {
for (int cx = m.minX() >> 4; cx <= m.maxX() >> 4; cx++) for (int cz = m.minZ() >> 4; cz <= m.maxZ() >> 4; cz++) {
long key = ((long) cx << 32) ^ (cz & 0xffffffffL);
// Short-lived read tickets can expire during the callers asynchronous footprint load.
// Refresh only these already generated chunks before inspecting their blocks.
if (chunks.add(key)) helper.assertTrue(level.getChunk(cx, cz, ChunkStatus.FULL, true) instanceof LevelChunk,
"Underground22 piece has no loaded FULL chunk at " + cx + "," + cz);
}
for (int d = 0; d < 4; d++) if ((m.openings() & 1 << d) != 0) {
for (int side = -1; side <= 1; side++) for (int dy = 1; dy <= 3; dy++) {
var at = mouth(m, d, side, dy);
helper.assertTrue(passable(level.getBlockState(at)), "Native utility doorway blocked at " + at + " in " + m.kind());
}
apertures++;
}
}
var access = network.modules().stream().filter(m -> m.kind() == Kind.ACCESS).findFirst().orElseThrow();
BlockPos hatch = null;
for (int x = access.minX(); x <= access.maxX(); x++) for (int z = access.minZ(); z <= access.maxZ(); z++) {
var at = new BlockPos(x, access.maxY() - 1, z);
if (level.getBlockState(at).is(Blocks.DARK_OAK_TRAPDOOR)) {
helper.assertTrue(hatch == null, "The access has more than one surface hatch"); hatch = at;
}
}
helper.assertTrue(hatch != null, "Independent access has no real wooden surface hatch");
final BlockPos entrance = hatch;
var closed = level.getBlockState(entrance);
for (int y = access.floorY() + 1; y < entrance.getY(); y++) {
var at = new BlockPos(entrance.getX(), y, entrance.getZ()); var ladder = level.getBlockState(at);
helper.assertTrue(ladder.is(Blocks.LADDER), "Access ladder is interrupted at " + at);
var wall = at.relative(ladder.getValue(BlockStateProperties.HORIZONTAL_FACING).getOpposite());
helper.assertTrue(level.getBlockState(wall).isCollisionShapeFullBlock(level, wall), "Ladder has no real backing wall at " + at);
ladders++;
}
level.setBlock(entrance, closed.setValue(BlockStateProperties.OPEN, true), 2);
try {
Predicate<BlockPos> inside = pos -> network.modules().stream().anyMatch(m -> m.contains(pos.getX(), pos.getY(), pos.getZ()));
BlockPos approach = java.util.Arrays.stream(HORIZONTAL).map(entrance::relative)
.filter(inside).filter(pos -> standable(level, pos)).findFirst().orElseThrow();
var reached = walk(helper, approach, inside); walked += reached.size();
helper.assertTrue(reached.contains(entrance), "Public deck cannot reach its hatch");
for (var m : network.modules()) {
helper.assertTrue(reached.stream().anyMatch(pos -> pos.getY() == m.floorY() + 1 && pos.getX() > m.minX()
&& pos.getX() < m.maxX() && pos.getZ() > m.minZ() && pos.getZ() < m.maxZ()),
"Surface access cannot reach emitted utility module " + m.kind() + " at " + m.centerX() + "," + m.centerZ());
if (m.kind() != Kind.EXPANSION_HALL) continue;
halls++;
var plinth = new BlockPos(m.centerX(), m.minY() + 2, m.centerZ());
helper.assertTrue(level.getBlockState(plinth).is(Blocks.SMOOTH_STONE) && reached.contains(plinth.above()),
"Reserved expansion plinth must be emitted and reachable");
for (int dx : new int[]{-6, 6}) for (int dz : new int[]{-6, 6})
helper.assertTrue(reached.contains(new BlockPos(m.centerX() + dx, m.floorY() + 1, m.centerZ() + dz)),
"Expansion hall has a blocked quarter");
}
} finally { level.setBlock(entrance, closed, 2); }
}
helper.assertTrue(halls > 0, "Native networks have no usable expansion hall");
var report = new LinkedHashMap<String, Integer>(); report.put("full_chunks", chunks.size()); report.put("usable_halls", halls);
report.put("ladder_blocks", ladders); report.put("clear_openings", apertures); report.put("reached_positions", walked);
return Map.copyOf(report);
}
private static BlockPos mouth(Module m, int direction, int across, int dy) {
return new BlockPos(direction == 1 ? m.maxX() : direction == 3 ? m.minX() : m.centerX() + across,
m.floorY() + dy, direction == 0 ? m.minZ() : direction == 2 ? m.maxZ() : m.centerZ() + across);
}
/** Same finite walking/ladder rule used by the native city16/20/21 access proofs. */
private static Set<BlockPos> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside) {
var level = helper.getLevel(); var reached = new HashSet<BlockPos>(); var queue = new ArrayDeque<BlockPos>();
helper.assertTrue(inside.test(start) && standable(level, start), "Blocked utility approach"); reached.add(start); queue.add(start);
while (!queue.isEmpty()) {
var pos = queue.remove();
for (var direction : HORIZONTAL) for (int dy = -1; dy <= 1; dy++) {
var next = pos.relative(direction).offset(0, dy, 0);
if (!inside.test(next) || dy > 0 && !passable(level.getBlockState(pos.above(2)))) continue;
if (standable(level, next) && reached.add(next)) queue.add(next);
}
for (int dy : new int[]{-1, 1}) {
var next = pos.offset(0, dy, 0);
if (inside.test(next) && (level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(next).is(Blocks.LADDER)
|| level.getBlockState(pos.below()).is(Blocks.LADDER)) && standable(level, next) && reached.add(next)) queue.add(next);
}
helper.assertTrue(reached.size() < 100_000, "Utility traversal exceeded its bounded footprint");
}
return reached;
}
private static boolean standable(ServerLevel level, BlockPos pos) {
var below = level.getBlockState(pos.below()); var at = level.getBlockState(pos);
return passable(at) && passable(level.getBlockState(pos.above()))
&& (below.isSolid() || below.getBlock() instanceof StairBlock || at.is(Blocks.LADDER) || below.is(Blocks.LADDER));
}
private static boolean passable(BlockState state) {
return state.isAir() || state.is(Blocks.LADDER) || state.is(Blocks.REDSTONE_WALL_TORCH)
|| state.is(Blocks.DARK_OAK_TRAPDOOR) && state.getValue(BlockStateProperties.OPEN);
}
}
@@ -0,0 +1,301 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.Terrain25Runtime;
import fr.koka.sanctuary.worldgen.Terrain25Shape;
import fr.koka.sanctuary.worldgen.aerial.AerialPlanner24;
import fr.koka.sanctuary.worldgen.underground25.UndergroundPlanner25;
import fr.koka.sanctuary.worldgen.underground25.UndergroundStructures25;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.NoiseColumn;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
/**
* Native-registry seed search. No evaluated chunks, starts, manifests or reservations are published.
* Properties under sanctuary.test.underground25.search: seeds, diameter, and optional required families.
* Required families stop the search once all have witnesses; an exhausted search may legitimately find none.
* dryOnly skips every structure planner, accepts at most32 seeds, and optionally stopsAfterFirstDry.
*/
public final class Underground25SeedSearch {
private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create();
private static final String PREFIX = "sanctuary.test.underground25.search.";
private static final Path OUTPUT = Path.of("diagnostics/underground25/search.json");
private static final int MAX_SEEDS = 24;
private static final int MAX_DRY_SEEDS = 32;
private static final List<String> FAMILIES = List.of("sanctuary:underground_abandoned_village",
"sanctuary:miner_cabin", "minecraft:mineshaft", "minecraft:swamp_hut");
@GameTest(maxTicks = 24000)
public void conditionalNativePlansAcrossSeeds(GameTestHelper helper) throws Exception {
if (Boolean.getBoolean(PREFIX + "dryOnly")) { dryOnly(helper); return; }
ServerLevel level = helper.getLevel();
helper.assertTrue(level.structureManager().shouldGenerateStructures(), "Seed search requires general structures ON");
helper.assertTrue(level.getMinY() == 0 && level.getHeight() == 384, "The host must supply the real Sanctuary vertical extent");
var seeds = seeds(System.getProperty(PREFIX + "seeds", "0,42,2026"));
var required = families(System.getProperty(PREFIX + "required", ""));
int diameter = Integer.getInteger(PREFIX + "diameter", Integer.getInteger("sanctuary.test.diameter", 724));
helper.assertTrue(diameter == 512 || diameter == 724 || diameter == 1024, "Use a supported initial diameter");
var preset = level.registryAccess().lookupOrThrow(Registries.WORLD_PRESET)
.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary"))).value();
var prototype = (SanctuaryChunkGenerator) preset.createWorldDimensions().overworld();
helper.assertTrue(prototype.rootGeneration() == 25, "Registry preset must really be generation25");
Object liveGenerator = level.getChunkSource().getGenerator();
var live = liveGenerator instanceof SanctuaryChunkGenerator g ? g : null;
var liveIslands = live == null ? List.<ExpansionIsland>of() : List.copyOf(live.source().islands());
Object liveUnderground = live == null ? null : UndergroundStructures25.plan(live);
Object liveAerial = live == null ? null : live.aerialPlan24();
Object liveMineral = live == null ? null : live.mineralSanctuary23();
Object liveTransit = live == null ? null : live.transitPlan21();
Object liveHall = live == null ? null : live.undergroundHall22();
Object liveHeritage = live == null ? null : live.heritagePlan19();
Object liveRuins = live == null ? null : live.ruinsPlan17();
var rows = new ArrayList<Map<String, Object>>();
var witnesses = new LinkedHashMap<String, List<Long>>();
FAMILIES.forEach(id -> witnesses.put(id, new ArrayList<>()));
var report = new LinkedHashMap<String, Object>();
report.put("scope", "Native registries, real generation25 terrain and conditional admission plans only; no FULL chunks, blocks, entities, loot or visual validation");
report.put("selection", "Unmodified production admission, graph sizes, priority reservations and per-stage sample budgets; absence is a valid search result");
report.put("process_id", ProcessHandle.current().pid());
report.put("host_seed", level.getSeed()); report.put("diameter", diameter);
report.put("requested_seeds", seeds); report.put("maximum_seeds", MAX_SEEDS);
report.put("required_plan_families", required);
report.put("complete", false); report.put("worlds", rows); report.put("positive_plan_witnesses", witnesses);
report.put("evaluated_world_chunks_requested", 0);
write(OUTPUT, report);
int[] index = {0}; boolean[] failures = {false};
Runnable[] step = new Runnable[1];
step[0] = () -> {
try {
boolean foundRequired = !required.isEmpty() && required.stream().allMatch(id -> !witnesses.get(id).isEmpty());
if (index[0] == seeds.size() || foundRequired) {
report.remove("current_seed"); report.put("complete", true);
report.put("termination", foundRequired ? "required_families_found" : "requested_seeds_exhausted");
report.put("required_families_found", foundRequired);
report.put("evaluated_seeds", seeds.subList(0, index[0]));
report.put("unevaluated_seeds", seeds.subList(index[0], seeds.size()));
report.put("all_evaluations_succeeded", !failures[0]); write(OUTPUT, report);
helper.assertTrue(!failures[0], "Every requested seed must finish its plan; inspect incremental search.json for failures");
helper.succeed(); return;
}
long seed = seeds.get(index[0]++), started = System.nanoTime();
var row = new LinkedHashMap<String, Object>(); row.put("seed", seed); row.put("diameter", diameter);
rows.add(row); report.put("current_seed", seed); write(OUTPUT, report);
SanctuaryMod.LOGGER.info("Underground25 seed search {}/{}: seed {} diameter {}", index[0], seeds.size(), seed, diameter);
try {
// Copies come from the registered preset, not the already activated host biome source.
var generator = prototype.withInitialDiameter(diameter).withExperimentalStructures(true);
helper.assertTrue(generator != liveGenerator && generator.source() != prototype.source(), "Each seed has its own generator and activation snapshot");
row.put("stage", "core_secrets"); write(OUTPUT, report);
generator.prepareAerialSampling24(level, seed);
var root = generator.source().islands().stream().filter(ExpansionIsland::origin).findFirst().orElseThrow();
helper.assertTrue(root.seed() == seed && generator.randomState(root).seed() == seed, "Both descriptor and native random state use the requested seed");
row.put("core_preparation_ms", millis(started));
long aerialStarted = System.nanoTime(); row.put("stage", "aerial_reservations"); write(OUTPUT, report);
generator.setAerialPlan24(AerialPlanner24.plan(seed, diameter, new AerialPlanner24.Terrain() {
@Override public NoiseColumn column(int x, int z) { return generator.aerialBaseColumn24(x, z); }
@Override public boolean reserved(int x, int z, int minY, int maxY) {
var binding = generator.starterContext();
return Terrain25Runtime.protects(binding.generator(), binding.random(), new BlockPos(x, minY, z), 0, maxY - minY, 0)
|| generator.intersectsExperimentalStructures(x, minY, z, x, maxY, z);
}
}));
row.put("aerial_preparation_ms", millis(aerialStarted));
row.put("aerial_sites", generator.aerialPlan24().sites().size());
helper.assertTrue(generator.aerialPlan24().sites().size() == 5, "Keep all five earlier satellite reservations");
row.put("stage", "conditional_admission"); write(OUTPUT, report);
long conditionalStarted = System.nanoTime();
var plan = UndergroundPlanner25.plan(level, generator);
row.put("conditional_planning_ms", millis(conditionalStarted));
row.put("columns", plan.columns()); row.put("diagnostics", plan.diagnostics());
helper.assertTrue(plan.columns() <= UndergroundPlanner25.MAX_COLUMNS * 4, "Preserve the independent finite sampling budgets");
helper.assertTrue(UndergroundStructures25.plan(generator).starts().isEmpty(), "Diagnostic plans are never published as active starts");
row.put("planned_starts", plan.starts().stream().map(p -> Map.of("id", p.key().identifier().toString(),
"chunk", List.of(p.start().getChunkPos().x(), p.start().getChunkPos().z()), "entry", position(p.entrance()), "pieces", p.start().getPieces().size())).toList());
row.put("planned_start_bounds", plan.starts().stream().map(p -> Map.of("id", p.key().identifier().toString(),
"chunk", List.of(p.start().getChunkPos().x(), p.start().getChunkPos().z()), "bounds", bounds(p.start().getBoundingBox()))).toList());
row.put("planned_rooms", plan.rooms().stream().map(p -> Map.of("entry", position(p.getLocatorPosition()), "seed", p.seed())).toList());
var shape = Terrain25Runtime.shape(generator.delegate(root), generator.randomState(root));
row.put("dry_chambers", shape.chambers().stream().filter(c -> c.kind() == Terrain25Shape.Kind.DRY).map(c -> Map.of(
"id", c.id(), "center", List.of(c.x(), c.y(), c.z()), "radii", List.of(c.rx(), c.ry(), c.rz()),
"rock_floor_y", c.floorY(), "dry_fit", shape.dryFits().get(c.id()),
"central_candidate_chunks", UndergroundPlanner25.centralVillageChunks(c).stream().map(p -> List.of(p.x(), p.z())).toList())).toList());
var wetlands = new ArrayList<Map<String, Object>>();
for (var region : PopulationHydrologyRuntime.plansIntersecting(generator.delegate(root), generator.randomState(root), -root.terrainRadius(),
-root.terrainRadius(), root.terrainRadius(), root.terrainRadius())) {
var wetland = Terrain25Runtime.wetlandPlan(generator.delegate(root), generator.randomState(root), region.region());
wetlands.add(Map.of("region", List.of(region.region().x(), region.region().z()), "sites", wetland.sites(), "candidates", wetland.candidates(), "refusals", wetland.refusals()));
}
row.put("wetlands", wetlands);
for (String id : FAMILIES) if (plan.starts().stream().anyMatch(p -> p.key().identifier().toString().equals(id))) witnesses.get(id).add(seed);
row.put("stage", "complete"); row.put("evaluation_succeeded", true);
} catch (Exception | AssertionError failure) {
failures[0] = true; row.put("evaluation_succeeded", false); row.put("failure", failure.toString());
SanctuaryMod.LOGGER.error("Underground25 seed search failed for seed {} size {}", seed, diameter, failure);
}
boolean hostUnchanged = level.getChunkSource().getGenerator() == liveGenerator && (live == null
|| live.source().islands().equals(liveIslands) && UndergroundStructures25.plan(live) == liveUnderground
&& live.aerialPlan24() == liveAerial && live.mineralSanctuary23() == liveMineral
&& live.transitPlan21() == liveTransit && live.undergroundHall22() == liveHall
&& live.heritagePlan19() == liveHeritage && live.ruinsPlan17() == liveRuins);
row.put("host_reservations_unchanged", hostUnchanged); row.put("elapsed_ms", millis(started));
report.put("evaluated_seeds", seeds.subList(0, index[0]));
report.put("unevaluated_seeds", seeds.subList(index[0], seeds.size()));
write(Path.of("diagnostics/underground25/search/" + diameter + "-" + seed + ".json"), row); write(OUTPUT, report);
helper.assertTrue(hostUnchanged, "Search must never modify the live world's generator, descriptors or reservations");
// No generator, RandomState, native piece or sampler is retained in the report.
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run());
} catch (IOException failure) { throw new IllegalStateException("Cannot persist incremental seed-search diagnostics", failure); }
};
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run());
}
/** Terrain preselection only: build the same named native density, without any Sanctuary world attachment. */
private static void dryOnly(GameTestHelper helper) throws Exception {
var level = helper.getLevel(); var registries = level.registryAccess();
var requested = seeds(System.getProperty(PREFIX + "seeds", "0,42,2026"), MAX_DRY_SEEDS);
int diameter = Integer.getInteger(PREFIX + "diameter", 724);
helper.assertTrue(diameter == 512 || diameter == 724 || diameter == 1024, "Dry search needs one supported explicit diameter");
int players = diameter == 512 ? 5 : diameter == 724 ? 10 : 20;
var settingsKey = ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("unified_25_" + players));
var settings = registries.lookupOrThrow(Registries.NOISE_SETTINGS).getOrThrow(settingsKey);
var noises = registries.lookupOrThrow(Registries.NOISE);
var forest = registries.lookupOrThrow(Registries.BIOME).getOrThrow(fr.koka.sanctuary.worldgen.Terrain25Biomes.key("oak_forest"));
Object liveGenerator = level.getChunkSource().getGenerator();
var live = liveGenerator instanceof SanctuaryChunkGenerator g ? g : null;
var before = hostPlans(live);
boolean stopAfterFirst = Boolean.getBoolean(PREFIX + "stopAfterFirstDry");
Path output = Path.of("diagnostics/underground25/dry-search.json");
var rows = new ArrayList<Map<String,Object>>(); var witnesses = new ArrayList<Long>();
var report = new LinkedHashMap<String,Object>();
report.put("scope", "Native generation25 density and natural dry-cavern floors only; no core secrets, aerial plans, structure admission or evaluated chunks");
report.put("limitation", "A certified large natural floor is a preselection witness, not an admitted village: final water, reservations, complete Jigsaw graph and access still require the full planner");
report.put("process_id", ProcessHandle.current().pid()); report.put("host_seed", level.getSeed());
report.put("diameter", diameter); report.put("noise_settings", settingsKey.identifier().toString());
report.put("requested_seeds", requested); report.put("maximum_seeds", MAX_DRY_SEEDS);
report.put("stop_after_first_certified_large_dry", stopAfterFirst);
report.put("worlds", rows); report.put("certified_large_dry_witnesses", witnesses);
report.put("evaluated_world_chunks_requested", 0); report.put("complete", false); write(output, report);
int[] index = {0}; boolean[] failed = {false}; Runnable[] step = new Runnable[1];
step[0] = () -> {
try {
boolean stop = stopAfterFirst && !witnesses.isEmpty();
if (index[0] == requested.size() || stop) {
report.remove("current_seed"); report.put("complete", true);
report.put("termination", stop ? "certified_large_dry_found" : "requested_seeds_exhausted");
report.put("evaluated_seeds", requested.subList(0,index[0])); report.put("unevaluated_seeds", requested.subList(index[0],requested.size()));
report.put("all_evaluations_succeeded", !failed[0]); write(output,report);
helper.assertTrue(!failed[0], "All dry-only evaluations must finish; inspect incremental dry-search.json");
helper.succeed(); return;
}
long seed = requested.get(index[0]++), start = System.nanoTime();
var row = new LinkedHashMap<String,Object>(); row.put("seed", seed); row.put("diameter",diameter);
row.put("stage","native_density"); rows.add(row); report.put("current_seed",seed); write(output,report);
try {
// This is the same settings/RandomState construction as SanctuaryChunkGenerator.context.
// Density and natural-cavern fitting do not consult the biome source or loaded world chunks.
var generator = new net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator(
new net.minecraft.world.level.biome.FixedBiomeSource(forest),settings);
var random = net.minecraft.world.level.levelgen.RandomState.create(noises,seed,settings.value());
var view = Terrain25Runtime.view(generator,random); var plan = view.plan();
helper.assertTrue(random.seed() == seed, "Every native density uses the explicit searched world seed");
var chambers = plan.chambers().stream().filter(c -> c.kind() == Terrain25Shape.Kind.DRY)
.sorted(java.util.Comparator.comparingDouble((Terrain25Shape.Chamber c) -> c.rx()*c.rz()).reversed().thenComparingInt(Terrain25Shape.Chamber::id)).toList();
var largest = chambers.getFirst(); var fit = plan.dryFits().get(largest.id());
row.put("shape_seed",plan.seed()); row.put("terrain_radius",plan.radius()); row.put("largest_dry_id",largest.id());
row.put("largest_dry_certified",fit != null && fit.certified());
row.put("dry_chambers",chambers.stream().map(c -> Map.of("id",c.id(),"center",List.of(c.x(),c.y(),c.z()),
"radii",List.of(c.rx(),c.ry(),c.rz()),"rock_floor_y",c.floorY(),"player_floor_y",c.floorY()+1,
"dry_fit",plan.dryFits().get(c.id()),"central_candidate_chunks",UndergroundPlanner25.centralVillageChunks(c).stream().map(v -> List.of(v.x(),v.z())).toList())).toList());
if (fit != null && fit.certified()) witnesses.add(seed);
row.put("stage","complete"); row.put("evaluation_succeeded",true);
SanctuaryMod.LOGGER.info("Underground25 dry-only seed {} size {} largest {} floor {} fit {} in {}ms",
seed,diameter,largest.id(),largest.floorY(),fit,millis(start));
} catch (Exception | AssertionError failure) {
failed[0]=true; row.put("evaluation_succeeded",false); row.put("failure",failure.toString());
SanctuaryMod.LOGGER.error("Underground25 dry-only seed {} failed",seed,failure);
}
boolean unchanged = level.getChunkSource().getGenerator() == liveGenerator && sameHostPlans(before, hostPlans(live));
row.put("host_reservations_unchanged",unchanged); row.put("elapsed_ms",millis(start));
report.put("evaluated_seeds",requested.subList(0,index[0])); report.put("unevaluated_seeds",requested.subList(index[0],requested.size()));
write(Path.of("diagnostics/underground25/dry-search/"+diameter+"-"+seed+".json"),row); write(output,report);
helper.assertTrue(unchanged,"Dry-only search must not change host descriptors or reservations");
helper.runAtTickTime(helper.getTick()+1,() -> step[0].run());
} catch (IOException failure) { throw new IllegalStateException("Cannot write incremental dry-only diagnostics",failure); }
};
helper.runAtTickTime(helper.getTick()+1,() -> step[0].run());
}
private static List<Object> hostPlans(SanctuaryChunkGenerator live) {
if (live == null) return List.of();
// ArrayList permits a null optional plan while retaining all pre-existing plan identities.
var result = new ArrayList<Object>(); result.add(List.copyOf(live.source().islands()));
result.add(UndergroundStructures25.plan(live)); result.add(live.aerialPlan24()); result.add(live.mineralSanctuary23());
result.add(live.transitPlan21()); result.add(live.undergroundHall22()); result.add(live.heritagePlan19()); result.add(live.ruinsPlan17());
return result;
}
private static boolean sameHostPlans(List<Object> before, List<Object> after) {
if (before.size() != after.size()) return false;
if (before.isEmpty()) return true;
// Descriptor contents are copied; plan objects must retain their exact identity.
if (!before.getFirst().equals(after.getFirst())) return false;
for (int i = 1; i < before.size(); i++) if (before.get(i) != after.get(i)) return false;
return true;
}
static List<Long> seeds(String value) { return seeds(value,MAX_SEEDS); }
private static List<Long> seeds(String value,int maximum) {
String[] parts = value.split(",", -1);
if (parts.length < 1 || parts.length > maximum) throw new IllegalArgumentException("Seed search accepts 1"+maximum+" explicit signed integer seeds");
var result = new LinkedHashSet<Long>();
for (String part : parts) {
long seed = Long.parseLong(part.trim());
if (!result.add(seed)) throw new IllegalArgumentException("Duplicate seed in bounded search: " + seed);
}
return List.copyOf(result);
}
private static List<String> families(String value) {
if (value.isBlank()) return List.of();
var result = new LinkedHashSet<String>();
for (String part : value.split(",", -1)) {
String id = switch (part.trim()) {
case "village" -> "sanctuary:underground_abandoned_village";
case "miner_cabin", "cabin" -> "sanctuary:miner_cabin";
case "mineshaft", "mine" -> "minecraft:mineshaft";
case "swamp_hut", "hut" -> "minecraft:swamp_hut";
default -> part.trim();
};
if (!FAMILIES.contains(id)) throw new IllegalArgumentException("Unknown conditional family: " + id);
result.add(id);
}
return List.copyOf(result);
}
private static long millis(long start) { return (System.nanoTime() - start) / 1_000_000; }
private static List<Integer> position(BlockPos p) { return List.of(p.getX(), p.getY(), p.getZ()); }
private static List<Integer> bounds(BoundingBox b) { return List.of(b.minX(), b.minY(), b.minZ(), b.maxX(), b.maxY(), b.maxZ()); }
private static void write(Path path, Object value) throws IOException {
Files.createDirectories(path.toAbsolutePath().getParent());
Path temporary = path.resolveSibling(path.getFileName() + ".tmp");
Files.writeString(temporary, JSON.toJson(value) + "\n");
try { Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); }
catch (AtomicMoveNotSupportedException unsupported) { Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING); }
}
}
@@ -0,0 +1,428 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.Terrain25Runtime;
import fr.koka.sanctuary.worldgen.Terrain25Shape;
import fr.koka.sanctuary.worldgen.underground25.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.TicketType;
import net.minecraft.world.entity.monster.zombie.ZombieVillager;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.entity.vehicle.minecart.MinecartChest;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
import net.minecraft.world.level.block.entity.SpawnerBlockEntity;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.levelgen.structure.structures.MineshaftPieces;
import net.minecraft.world.level.storage.LevelResource;
import net.minecraft.world.level.storage.loot.BuiltInLootTables;
import net.minecraft.world.phys.AABB;
/** Conditional starts are inspected only when admitted; required-family fixtures can assert positive witnesses. */
public final class Underground25WorldGameTests {
private static final com.google.gson.Gson JSON = new GsonBuilder().setPrettyPrinting().create();
private static final BlockPos VILLAGE_EXTRACTION = new BlockPos(1608, 360, 1608);
@GameTest(maxTicks = 24000)
public void nativeUndergroundLifecycle(GameTestHelper helper) throws Exception {
ServerLevel level = helper.getLevel();
var generator = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 25, "Use a disposable generation25 world");
var plan = UndergroundStructures25.plan(generator);
var report = new LinkedHashMap<String, Object>();
String phase = System.getProperty("sanctuary.test.underground25.phase", "create");
boolean reload = phase.equals("reload");
report.put("seed", level.getSeed()); report.put("diameter", generator.initialDiameter());
report.put("phase", phase); report.put("process_id", ProcessHandle.current().pid());
report.put("diagnostics", plan.diagnostics()); report.put("columns", plan.columns());
var descriptors = plan.starts().stream().map(p -> Map.of("id", p.key().identifier().toString(),
"chunk", List.of(p.start().getChunkPos().x(), p.start().getChunkPos().z()), "entry", position(p.entrance()),
"pieces", p.start().getPieces().size())).toList();
report.put("planned_starts", descriptors);
report.put("planned_start_bounds", plan.starts().stream().map(p -> Map.of("id", p.key().identifier().toString(),
"chunk", List.of(p.start().getChunkPos().x(), p.start().getChunkPos().z()), "bounds", bounds(p.start().getBoundingBox()))).toList());
report.put("planned_rooms", plan.rooms().stream().map(p -> Map.of("entry", position(p.getLocatorPosition()), "seed", p.seed())).toList());
helper.assertTrue(plan.columns() <= UndergroundPlanner25.MAX_COLUMNS * 4, "Every stage keeps a finite, independent terrain budget");
var types = plan.starts().stream().map(p -> p.key().identifier().toString()).toList();
for (String required : System.getProperty("sanctuary.test.underground25.required", "").split(",")) if (!required.isBlank())
helper.assertTrue(required.equals("rooms") ? !plan.rooms().isEmpty() : types.contains(required), "Required positive witness " + required);
if (!level.structureManager().shouldGenerateStructures() || !generator.experimentalStructures()) {
helper.assertTrue(plan.starts().isEmpty() && plan.rooms().isEmpty(), "OFF adds no conditional starts or feature attempts");
report.put("passed", true); write(Path.of("diagnostics/underground25/off.json"), report); helper.succeed(); return;
}
var root = generator.source().islands().stream().filter(fr.koka.sanctuary.expansion.ExpansionIsland::origin).findFirst().orElseThrow();
var shape = Terrain25Runtime.shape(generator.delegate(root), generator.randomState(root));
report.put("dry_chambers", shape.chambers().stream().filter(c -> c.kind() == Terrain25Shape.Kind.DRY).map(c -> {
var chamber = new LinkedHashMap<String, Object>();
chamber.put("id", c.id()); chamber.put("center", List.of(c.x(), c.y(), c.z()));
chamber.put("radii", List.of(c.rx(), c.ry(), c.rz())); chamber.put("rock_floor_y", c.floorY());
chamber.put("village_standing_y", c.floorY() + 1);
chamber.put("dry_fit", shape.dryFits().get(c.id()));
chamber.put("floor_footprint", "Only columns with chamber.cut(x, rock_floor_y + 1, z) > 0 are certified; native graph admission is separate");
return chamber;
}).toList());
if (Boolean.getBoolean("sanctuary.test.underground25.planOnly")) {
report.put("plan_only", true); report.put("passed", true); write(Path.of("diagnostics/underground25/plan.json"), report); helper.succeed(); return;
}
Path world = level.getServer().getWorldPath(LevelResource.ROOT), previousPath = world.resolve("underground25-create.json");
JsonObject previous = reload ? JsonParser.parseString(Files.readString(previousPath)).getAsJsonObject() : null;
if (reload) {
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload uses a different JVM");
helper.assertTrue(previous.get("planned_starts").equals(JSON.toJsonTree(descriptors)), "Conditional admissions replay identically");
helper.assertTrue(previous.get("planned_rooms").equals(JSON.toJsonTree(report.get("planned_rooms"))), "Feature attempts replay identically");
}
var chunks = new TreeSet<ChunkPos>(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z));
plan.starts().forEach(p -> p.start().getPieces().forEach(piece -> piece.getBoundingBox().intersectingChunks().forEach(chunks::add)));
plan.rooms().forEach(p -> p.getBoundingBox().intersectingChunks().forEach(chunks::add));
int[] wait = {0}; boolean[] tickets = {false};
var entityChunks = new HashSet<ChunkPos>();
plan.starts().stream().filter(p -> p.key().equals(UndergroundStructures25.VILLAGE)
|| p.key().identifier().toString().equals("minecraft:swamp_hut") || p.key().identifier().toString().equals("minecraft:mineshaft"))
.forEach(p -> p.start().getPieces().forEach(piece -> piece.getBoundingBox().intersectingChunks().forEach(entityChunks::add)));
if (types.contains(UndergroundStructures25.VILLAGE.identifier().toString())) {
// Native inhabitants can walk over a piece boundary while the remaining pieces load.
plan.starts().stream().filter(p -> p.key().equals(UndergroundStructures25.VILLAGE)).forEach(p ->
p.start().getBoundingBox().inflatedBy(16).intersectingChunks().forEach(entityChunks::add));
entityChunks.add(new ChunkPos(VILLAGE_EXTRACTION.getX() >> 4, VILLAGE_EXTRACTION.getZ() >> 4));
chunks.addAll(entityChunks);
}
var iterator = chunks.iterator();
report.put("expected_full_chunks", chunks.size()); report.put("entity_ticket_chunks", entityChunks.size());
Runnable release = () -> { if (tickets[0]) entityChunks.forEach(c -> ChunkTickets25.remove(level, c)); tickets[0] = false; };
Runnable[] step = new Runnable[1];
step[0] = () -> {
try {
if (iterator.hasNext()) {
var c = iterator.next(); level.getChunk(c.x(), c.z(), ChunkStatus.FULL, true);
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run()); return;
}
if (!tickets[0]) { entityChunks.forEach(c -> ChunkTickets25.add(level, c)); tickets[0] = true; }
boolean ready = entityChunks.stream().allMatch(c -> level.areEntitiesLoaded(c.pack()) && level.areEntitiesActuallyLoadedAndTicking(c));
if (!ready && wait[0]++ < 6000) { helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run()); return; }
helper.assertTrue(ready, "Native occupants are inspected after all their chunks are ticking");
var tags = new TreeMap<String, String>();
var normalizedBounds = new ArrayList<Map<String, Object>>();
var nativeContents = new TreeMap<String, Object>();
var summaries = new ArrayList<Map<String, Object>>();
var context = StructurePieceSerializationContext.fromLevel(level);
for (var planned : plan.starts()) {
var c = planned.start().getChunkPos();
var start = level.getChunk(c.x(), c.z(), ChunkStatus.FULL, true).getStartForStructure(planned.start().getStructure());
helper.assertTrue(start != null && start.isValid(), "Admitted site has a real native start");
String startKey = planned.key().identifier() + "/" + c.x() + "/" + c.z();
var canonicalTag = roundTripNative(helper, start, context, c, level.getSeed(), startKey, normalizedBounds);
tags.put(startKey, canonicalTag.toString());
var summary = new LinkedHashMap<String, Object>(); summary.put("id", planned.key().identifier().toString()); summary.put("pieces", start.getPieces().size());
if (planned.key().identifier().toString().equals("minecraft:mineshaft")) inspectMine(helper, level, start, summary, nativeContents);
if (planned.key().equals(UndergroundStructures25.VILLAGE)) {
var mutations = inspectVillage(helper, level, start, summary, nativeContents, reload,
previous == null ? null : previous.getAsJsonObject("village_mutations"));
report.put("village_mutations", mutations);
}
if (planned.key().identifier().toString().equals("minecraft:swamp_hut")) {
var hutTag = start.getPieces().getFirst().createTag(context);
helper.assertTrue(hutTag.getBooleanOr("Witch", false) && hutTag.getBooleanOr("Cat", false), "Native hut places and saves its one-time witch and cat");
summary.put("native_witch_and_cat_placed", true);
}
if (planned.key().equals(UndergroundStructures25.MINER_CABIN)) {
var cabin = start.getPieces().stream().filter(p -> p instanceof UndergroundPiece25 u && u.kind() == UndergroundPiece25.Kind.MINER_CABIN)
.map(UndergroundPiece25.class::cast).findFirst().orElseThrow();
var approach = start.getPieces().stream().filter(p -> p instanceof UndergroundPiece25 u && u.kind() == UndergroundPiece25.Kind.ACCESS)
.map(UndergroundPiece25.class::cast).findFirst().orElseThrow();
helper.assertTrue(plan.starts().stream().filter(p -> p.key().identifier().toString().equals("minecraft:mineshaft"))
.anyMatch(m -> m.start().getPieces().stream().anyMatch(p -> approach.volumes().stream().anyMatch(v -> v.intersects(p.getBoundingBox())))),
"The cabin approach reaches a piece of an actual admitted native mineshaft");
helper.assertTrue(level.getBlockState(cabin.chestPosition()).is(Blocks.CHEST), "Attached miner cabin has a real chest");
helper.assertTrue(level.getBlockEntity(cabin.chestPosition()) instanceof RandomizableContainerBlockEntity, "Miner chest has native inventory and loot state");
var chest = (RandomizableContainerBlockEntity) level.getBlockEntity(cabin.chestPosition());
if (!reload) { chest.unpackLootTable(null); chest.clearContent(); chest.setChanged(); }
else helper.assertTrue(chest.isEmpty(), "Looted miner chest stays empty after reload");
summary.put("chest", position(cabin.chestPosition()));
}
summaries.add(summary);
}
var roomContents = new ArrayList<Map<String, Object>>();
for (var room : plan.rooms()) {
helper.assertTrue(level.getBlockState(room.getLocatorPosition()).is(Blocks.SPAWNER), "Successful native room contains its spawner");
if (!reload) helper.assertTrue(room.featureAttempted() && room.featurePlaced(), "Admitted room passes the real vanilla doorway check");
var roomReport = new LinkedHashMap<String, Object>();
roomReport.put("entry", position(room.getLocatorPosition()));
roomReport.put("spawner", inspectSpawner(helper, level, room.getLocatorPosition(),
Set.of("minecraft:skeleton", "minecraft:zombie", "minecraft:spider"), nativeContents));
var chests = new ArrayList<Map<String, Object>>();
for (var pos : positions(room.getBoundingBox())) if (level.getBlockState(pos).is(Blocks.CHEST))
chests.add(inspectChest(helper, level, pos, BuiltInLootTables.SIMPLE_DUNGEON, nativeContents));
roomReport.put("optional_chests", chests); roomContents.add(roomReport);
}
if (reload) helper.assertTrue(previous.get("native_tags").equals(JSON.toJsonTree(tags)), "No start, piece or one-time native flag changes on reload");
if (reload && previous.has("native_contents")) {
helper.assertTrue(previous.get("native_contents").equals(JSON.toJsonTree(nativeContents)),
"Native unopened loot tables/seeds, minecart UUIDs and spawner configurations survive reload");
report.put("native_contents_reload_compared", true);
} else report.put("native_contents_reload_compared", false);
report.put("native_jigsaw_bounds_normalized", normalizedBounds);
report.put("native_jigsaw_bounds_normalized_count", normalizedBounds.size());
report.put("native_tag_comparison", "All fields are strict; only native Jigsaw expanded maxY may normalize once to the exact template box. Second decode is entirely strict.");
report.put("native_contents", nativeContents); report.put("actual_monster_rooms", roomContents);
report.put("actual_starts", summaries); report.put("native_tags", tags); report.put("full_chunks", chunks.size());
report.put("native_room_spawners", plan.rooms().size()); report.put("entity_wait_ticks", wait[0]);
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save native starts, blocks, entities and emptied inventories");
report.put("passed", true); write(world.resolve("underground25-" + phase + ".json"), report);
write(Path.of("diagnostics/underground25/" + phase + ".json"), report); release.run(); helper.succeed();
} catch (Exception failure) {
release.run(); report.put("passed", false); report.put("failure", failure.toString());
try { write(Path.of("diagnostics/underground25/failure.json"), report); } catch (Exception ignored) {}
throw new IllegalStateException("Underground25 lifecycle failed", failure);
}
};
helper.runAtTickTime(helper.getTick() + 1, () -> step[0].run());
}
/** Vanilla JigsawPlacement expands some planning boxes upward; the native loader recomputes the template box. */
private static net.minecraft.nbt.CompoundTag roundTripNative(GameTestHelper helper, StructureStart start,
StructurePieceSerializationContext context, ChunkPos chunk, long seed, String startKey,
List<Map<String, Object>> normalized) {
var before = start.createTag(context, chunk);
var decoded = StructureStart.loadStaticStart(context, before, seed);
helper.assertTrue(decoded != null && decoded.isValid(), "Every native start and cavity decodes");
var after = decoded.createTag(context, chunk);
var beforeChildren = before.getListOrEmpty("Children");
var afterChildren = after.getListOrEmpty("Children");
helper.assertTrue(start.getPieces().size() == decoded.getPieces().size()
&& beforeChildren.size() == afterChildren.size() && beforeChildren.size() == start.getPieces().size(),
"No native or cavity piece is added, removed or reordered by decoding");
var beforeHeader = before.copy(); beforeHeader.remove("Children");
var afterHeader = after.copy(); afterHeader.remove("Children");
helper.assertTrue(beforeHeader.equals(afterHeader), "Native start identity, references and header round-trip exactly");
for (int i = 0; i < start.getPieces().size(); i++) {
var originalPiece = start.getPieces().get(i); var decodedPiece = decoded.getPieces().get(i);
var originalTag = beforeChildren.getCompoundOrEmpty(i); var decodedTag = afterChildren.getCompoundOrEmpty(i);
if (originalPiece instanceof PoolElementStructurePiece pool) {
helper.assertTrue(decodedPiece instanceof PoolElementStructurePiece, "A Jigsaw piece retains its native type");
var originalBox = originalPiece.getBoundingBox(); var loadedBox = decodedPiece.getBoundingBox();
var templateBox = pool.getElement().getBoundingBox(context.structureTemplateManager(), pool.getPosition(), pool.getRotation());
helper.assertTrue(loadedBox.equals(templateBox), "Reloaded Jigsaw bounds equal the real rotated native template");
helper.assertTrue(originalBox.minX() == loadedBox.minX() && originalBox.maxX() == loadedBox.maxX()
&& originalBox.minZ() == loadedBox.minZ() && originalBox.maxZ() == loadedBox.maxZ()
&& originalBox.minY() == loadedBox.minY() && loadedBox.maxY() <= originalBox.maxY(),
"Native normalization can only shrink planning maxY, preserving position, floor and all chunk coverage");
var originalFields = originalTag.copy(); originalFields.remove("BB");
var decodedFields = decodedTag.copy(); decodedFields.remove("BB");
helper.assertTrue(originalFields.equals(decodedFields),
"All Jigsaw pool paths, positions, rotation, projection, junctions and other fields round-trip exactly");
if (!originalBox.equals(loadedBox)) normalized.add(Map.of("start", startKey, "piece_index", i,
"element", pool.getElement().toString(), "original", bounds(originalBox), "native_template", bounds(loadedBox)));
} else helper.assertTrue(originalTag.equals(decodedTag),
"Cavity volumes, protection, accesses and all non-Jigsaw native fields round-trip exactly");
}
helper.assertTrue(start.getBoundingBox().equals(decoded.getBoundingBox()), "The complete admitted structure and protected cavity keep the same envelope");
var twice = StructureStart.loadStaticStart(context, after, seed);
helper.assertTrue(twice != null && after.equals(twice.createTag(context, chunk)), "Second native decode is entirely idempotent, including every bounding box");
return after;
}
private static void inspectMine(GameTestHelper helper, ServerLevel level, StructureStart start,
Map<String, Object> report, Map<String, Object> persistent) {
var context = StructurePieceSerializationContext.fromLevel(level);
var flags = new ArrayList<Map<String, Object>>();
var blocks = new TreeMap<String, Integer>();
var spawners = new ArrayList<Map<String, Object>>();
var chests = new ArrayList<Map<String, Object>>();
var railPositions = new ArrayList<List<Integer>>();
var visited = new HashSet<BlockPos>();
var coreVolumes = start.getPieces().stream().map(p -> p.getBoundingBox()).toList();
for (var piece : start.getPieces()) {
if (piece instanceof MineshaftPieces.MineShaftCorridor) {
var tag = piece.createTag(context);
boolean rails = tag.getBooleanOr("hr", false), spiders = tag.getBooleanOr("sc", false), placedSpider = tag.getBooleanOr("hps", false);
helper.assertTrue(!(rails && spiders) && (!placedSpider || spiders), "Native corridor rail/spider flags retain their vanilla invariants");
flags.add(Map.of("bounds", bounds(piece.getBoundingBox()), "has_rails", rails,
"spider_corridor", spiders, "placed_spider_spawner", placedSpider, "sections", tag.getIntOr("Num", 0)));
}
// Include the certified native support envelope. Deduplicate overlaps between pieces.
var volume = UndergroundPlacement25.writeBounds("minecraft:mineshaft", piece.getBoundingBox());
for (var at : positions(volume)) {
var pos = at.immutable(); if (!visited.add(pos)) continue;
var state = level.getBlockState(pos);
if (state.is(Blocks.RAIL) || state.is(Blocks.COBWEB) || state.is(Blocks.OAK_PLANKS)
|| state.is(Blocks.OAK_FENCE) || state.is(Blocks.OAK_LOG) || state.is(Blocks.DARK_OAK_PLANKS)
|| state.is(Blocks.DARK_OAK_FENCE) || state.is(Blocks.DARK_OAK_LOG)) {
blocks.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), 1, Integer::sum);
if (state.is(Blocks.RAIL)) {
helper.assertTrue(state.canSurvive(level, pos), "Every observed mine rail has its real vanilla support at " + pos);
railPositions.add(position(pos));
}
}
// Do not attribute a nearby native dungeon below a support column to this mine.
if ((!state.is(Blocks.SPAWNER) && !state.is(Blocks.CHEST)) || !coreVolumes.stream().anyMatch(b -> b.isInside(pos))) continue;
if (state.is(Blocks.SPAWNER)) spawners.add(inspectSpawner(helper, level, pos, Set.of("minecraft:cave_spider"), persistent));
if (state.is(Blocks.CHEST)) chests.add(inspectChest(helper, level, pos, BuiltInLootTables.ABANDONED_MINESHAFT, persistent));
}
}
var box = start.getBoundingBox();
var carts = level.getEntitiesOfClass(MinecartChest.class,
new AABB(box.minX(), box.minY(), box.minZ(), box.maxX() + 1, box.maxY() + 1, box.maxZ() + 1));
var minecarts = new ArrayList<Map<String, Object>>();
for (var cart : carts.stream().sorted(Comparator.comparing(c -> c.getUUID().toString())).toList()) {
var table = cart.getContainerLootTable();
helper.assertTrue(BuiltInLootTables.ABANDONED_MINESHAFT.equals(table), "An unopened native minecart retains its mineshaft loot table");
var state = Map.of("loot_table", table.identifier().toString(), "loot_seed", cart.getContainerLootTableSeed());
persistent.put("minecart:" + cart.getUUID(), state);
minecarts.add(Map.of("uuid", cart.getUUID().toString(), "position", List.of(cart.getX(), cart.getY(), cart.getZ()),
"loot_table", table.identifier().toString(), "loot_seed", cart.getContainerLootTableSeed()));
}
report.put("mine_observation_scope", "Unique blocks in native piece/support envelopes; rail/web/chest/cart/spawner outcomes are conditional, with no minimum quota");
report.put("native_corridor_flags", flags); report.put("observed_rail_web_wood_blocks", blocks);
report.put("supported_rail_positions", railPositions); report.put("optional_native_spawners", spawners);
report.put("optional_native_chests", chests); report.put("optional_native_minecarts", minecarts);
}
private static Map<String, Object> inspectSpawner(GameTestHelper helper, ServerLevel level, BlockPos pos,
Set<String> permittedTypes, Map<String, Object> persistent) {
helper.assertTrue(level.getBlockEntity(pos) instanceof SpawnerBlockEntity, "Spawner block has its real native BlockEntity at " + pos);
var spawner = (SpawnerBlockEntity) level.getBlockEntity(pos);
var nbt = spawner.saveWithoutMetadata(level.registryAccess());
var spawnData = nbt.getCompound("SpawnData").orElseThrow();
String type = spawnData.getCompound("entity").orElseThrow().getString("id").orElseThrow();
helper.assertTrue(permittedTypes.contains(type), "Native spawner contains an allowed actual entity type at " + pos + ": " + type);
// Delay changes with player proximity; the configured type, potentials and limits must persist.
var configuration = nbt.copy(); configuration.remove("Delay");
var limits = new TreeMap<String, Integer>();
for (String key : List.of("MinSpawnDelay", "MaxSpawnDelay", "SpawnCount", "MaxNearbyEntities", "RequiredPlayerRange", "SpawnRange"))
limits.put(key, nbt.getIntOr(key, -1));
persistent.put("spawner:" + position(pos), Map.of("entity_type", type, "limits", limits));
return Map.of("position", position(pos), "entity_type", type, "configuration_nbt", configuration.toString(), "nbt", nbt.toString());
}
private static Map<String, Object> inspectChest(GameTestHelper helper, ServerLevel level, BlockPos pos,
net.minecraft.resources.ResourceKey<net.minecraft.world.level.storage.loot.LootTable> expected,
Map<String, Object> persistent) {
helper.assertTrue(level.getBlockEntity(pos) instanceof RandomizableContainerBlockEntity, "Native chest has its inventory BlockEntity at " + pos);
var chest = (RandomizableContainerBlockEntity) level.getBlockEntity(pos);
var table = chest.getLootTable();
helper.assertTrue(expected.equals(table), "An unopened native chest retains its structure's loot table at " + pos);
var state = Map.of("loot_table", table.identifier().toString(), "loot_seed", chest.getLootTableSeed());
persistent.put("chest:" + position(pos), state);
return Map.of("position", position(pos), "loot_table", table.identifier().toString(), "loot_seed", chest.getLootTableSeed());
}
private static Object inspectVillage(GameTestHelper helper, ServerLevel level, StructureStart start,
Map<String, Object> report, Map<String, Object> persistent, boolean reload, JsonObject previousMutations) {
var cavern = start.getPieces().stream().filter(p -> p instanceof UndergroundPiece25 u && u.kind() == UndergroundPiece25.Kind.VILLAGE_CAVERN)
.map(UndergroundPiece25.class::cast).findFirst().orElseThrow();
helper.assertTrue(start.getPieces().getFirst() == cavern, "Cavity is placed before native roads and buildings");
int houses = 0, paths = 0;
var roadWitnesses = new ArrayList<BlockPos>();
var chests = new TreeSet<BlockPos>(Comparator.<BlockPos>comparingInt(BlockPos::getX).thenComparingInt(BlockPos::getY).thenComparingInt(BlockPos::getZ));
var visited = new HashSet<BlockPos>();
for (var piece : start.getPieces()) if (piece instanceof PoolElementStructurePiece pool) {
String template = pool.getElement().toString();
if (template.contains("/houses/")) houses++;
for (var at : positions(piece.getBoundingBox())) if (level.getBlockState(at).is(Blocks.CHEST)) chests.add(at.immutable());
if (!template.contains("/streets/")) continue;
var b = piece.getBoundingBox();
for (int z = b.minZ(); z <= b.maxZ(); z++) for (int x = b.minX(); x <= b.maxX(); x++) {
var pos = new BlockPos(x, cavern.floorY() - 1, z);
if (visited.add(pos) && level.getBlockState(pos).is(Blocks.DIRT_PATH)) {
helper.assertTrue(level.getBlockState(pos.below()).isSolid(), "Native underground road has a foundation"); paths++; roadWitnesses.add(pos);
}
}
}
helper.assertTrue(houses >= 6, "An extended native village, not a truncated hamlet");
helper.assertTrue(paths > 10, "Native streets actually project to the saved underground floor");
var entry = cavern.getLocatorPosition();
helper.assertTrue(level.getBlockState(entry.below()).isSolid() && level.getBlockState(entry).getCollisionShape(level, entry).isEmpty()
&& level.getBlockState(entry.above()).getCollisionShape(level, entry.above()).isEmpty(), "Village access is supported and passable");
var b = start.getBoundingBox();
var search = new AABB(b.minX() - 16, b.minY() - 8, b.minZ() - 16, b.maxX() + 17, b.maxY() + 17, b.maxZ() + 17);
var zombies = new ArrayList<>(level.getEntitiesOfClass(ZombieVillager.class, search, v -> v.isAlive() && v.isPersistenceRequired()));
if (reload) {
helper.assertTrue(previousMutations != null, "Reload keeps the recorded village edits and extracted occupant");
var extracted = level.getEntity(UUID.fromString(previousMutations.get("extracted_uuid").getAsString()));
helper.assertTrue(extracted instanceof ZombieVillager && extracted.isAlive(), "The extracted zombie-villager reloads under the same UUID");
helper.assertTrue(!search.contains(extracted.position()), "The extracted occupant stays outside its original village");
zombies.add((ZombieVillager) extracted);
}
zombies.sort(Comparator.comparing(v -> v.getUUID().toString()));
helper.assertTrue(!zombies.isEmpty(), "Native abandoned templates place persistent zombie-villagers");
var identities = new TreeMap<String, Object>();
for (var zombie : zombies) {
helper.assertTrue(zombie.getType() == EntityTypes.ZOMBIE_VILLAGER && zombie.isPersistenceRequired() && !zombie.isConverting(),
"Native persistent zombie-villagers retain their ordinary curable type, without a scripted conversion");
helper.assertTrue(!identities.containsKey(zombie.getUUID().toString()), "No duplicate live village UUID");
var data = zombie.getVillagerData();
identities.put(zombie.getUUID().toString(), Map.of("persistent", true,
"villager_type", data.type().unwrapKey().orElseThrow().identifier().toString(),
"profession", data.profession().unwrapKey().orElseThrow().identifier().toString(), "level", data.level()));
}
persistent.put("village:" + start.getChunkPos().pack(), identities);
Object mutations;
if (!reload) {
var chestPos = chests.stream().filter(pos -> level.getBlockEntity(pos) instanceof RandomizableContainerBlockEntity chest
&& chest.getLootTable() != null && chest.getLootTable().identifier().getPath().startsWith("chests/village/")).findFirst().orElse(null);
helper.assertTrue(chestPos != null, "The positive village witness contains an actual native village-loot chest");
var chest = (RandomizableContainerBlockEntity) level.getBlockEntity(chestPos);
String table = chest.getLootTable().identifier().toString(); long lootSeed = chest.getLootTableSeed();
chest.unpackLootTable(null); var loot = new TreeMap<String, Integer>();
for (int slot = 0; slot < chest.getContainerSize(); slot++) {
var stack = chest.getItem(slot);
if (!stack.isEmpty()) loot.merge(BuiltInRegistries.ITEM.getKey(stack.getItem()).toString(), stack.getCount(), Integer::sum);
}
chest.clearContent(); chest.setChanged();
var removed = roadWitnesses.stream().filter(pos -> pos.distSqr(entry.below()) > 16).findFirst().orElseThrow();
helper.assertTrue(level.setBlock(removed, Blocks.AIR.defaultBlockState(), 2), "Remove one real native road block");
helper.assertTrue(!search.contains(VILLAGE_EXTRACTION.getX() + .5, VILLAGE_EXTRACTION.getY(), VILLAGE_EXTRACTION.getZ() + .5),
"The test holding cell is outside the village");
for (int dz = -2; dz <= 2; dz++) for (int dx = -2; dx <= 2; dx++) for (int dy = -1; dy <= 3; dy++) {
var at = VILLAGE_EXTRACTION.offset(dx, dy, dz);
boolean shell = Math.abs(dx) == 2 || Math.abs(dz) == 2 || dy == -1 || dy == 3;
level.setBlock(at, (shell ? Blocks.STONE : Blocks.AIR).defaultBlockState(), 2);
}
var extracted = zombies.getFirst();
// Only this disposable test occupant is immobilized, so reload checks do not depend on AI wandering.
extracted.setNoAi(true); extracted.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO);
extracted.snapTo(VILLAGE_EXTRACTION.getX() + .5, VILLAGE_EXTRACTION.getY(), VILLAGE_EXTRACTION.getZ() + .5);
mutations = Map.of("emptied_chest", position(chestPos), "original_loot_table", table, "original_loot_seed", lootSeed,
"loot_items_obtained", loot, "removed_road_block", position(removed), "extracted_uuid", extracted.getUUID().toString(),
"extracted_position", List.of(extracted.getX(), extracted.getY(), extracted.getZ()),
"extraction_fixture", "Covered holding cell in a distant disposable-world chunk; only the extracted occupant has NoAI");
} else {
var chestPos = position(previousMutations.getAsJsonArray("emptied_chest"));
helper.assertTrue(level.getBlockEntity(chestPos) instanceof RandomizableContainerBlockEntity,
"The opened village chest remains the same native container after reload");
var chest = (RandomizableContainerBlockEntity) level.getBlockEntity(chestPos);
helper.assertTrue(chest.isEmpty() && chest.getLootTable() == null, "Looted village chest stays empty without rerolling its native loot");
var removed = position(previousMutations.getAsJsonArray("removed_road_block"));
helper.assertTrue(level.getBlockState(removed).isAir(), "A demolished native road block is not reconstructed on reload");
var extracted = level.getEntity(UUID.fromString(previousMutations.get("extracted_uuid").getAsString()));
var at = previousMutations.getAsJsonArray("extracted_position");
helper.assertTrue(Math.abs(extracted.getX() - at.get(0).getAsDouble()) < .01
&& Math.abs(extracted.getY() - at.get(1).getAsDouble()) < .01 && Math.abs(extracted.getZ() - at.get(2).getAsDouble()) < .01,
"The extracted zombie-villager retains its recorded position and is not replenished in the village");
mutations = previousMutations.deepCopy();
}
report.put("houses", houses); report.put("projected_path_blocks", paths);
report.put("zombie_villagers", identities.keySet()); report.put("persistent_native_zombie_villagers", identities.size());
report.put("curability_scope", "Native ZombieVillager type and persistence checked; no complete cure is claimed");
report.put("floor", cavern.floorY()); report.put("entry", position(entry));
return mutations;
}
private static List<Integer> position(BlockPos p) { return List.of(p.getX(), p.getY(), p.getZ()); }
private static BlockPos position(com.google.gson.JsonArray p) { return new BlockPos(p.get(0).getAsInt(), p.get(1).getAsInt(), p.get(2).getAsInt()); }
private static Iterable<BlockPos> positions(BoundingBox b) {
return BlockPos.betweenClosed(b.minX(), b.minY(), b.minZ(), b.maxX(), b.maxY(), b.maxZ());
}
private static List<Integer> bounds(net.minecraft.world.level.levelgen.structure.BoundingBox b) {
return List.of(b.minX(), b.minY(), b.minZ(), b.maxX(), b.maxY(), b.maxZ());
}
private static void write(Path path, Object value) throws java.io.IOException { Files.createDirectories(path.toAbsolutePath().getParent()); Files.writeString(path, JSON.toJson(value) + "\n"); }
}
@@ -107,7 +107,7 @@ public final class UnifiedWorldGameTests {
for (int y = 0; y < 256; y++) {
var pos = new BlockPos(x, y, z);
var state = chunk.getBlockState(pos);
String biome = chunk.getNoiseBiome(x >> 2, y >> 2, z >> 2).getRegisteredName();
String biome = caveFamily(binding, chunk.getNoiseBiome(x >> 2, y >> 2, z >> 2).getRegisteredName());
var bucket = counts.get(biome);
if (bucket != null && !state.isAir()) bucket.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), 1L, Long::sum);
// Large vanilla pillars add thick dripstone into original cave air. A painted
@@ -129,8 +129,9 @@ public final class UnifiedWorldGameTests {
Map<String, Object> atmospheres = new TreeMap<>();
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
for (String name : CAVES) {
String actualId = caveId(binding, name);
var biome = level.registryAccess().lookupOrThrow(Registries.BIOME)
.getValue(net.minecraft.resources.Identifier.parse(name));
.getValue(net.minecraft.resources.Identifier.parse(actualId));
var encoded = Biome.DIRECT_CODEC.encodeStart(ops, biome).getOrThrow().getAsJsonObject();
atmospheres.put(name, encoded.get("attributes"));
helper.assertTrue(encoded.get("attributes").toString().contains("music.overworld." + name.substring("minecraft:".length())),
@@ -164,8 +165,9 @@ public final class UnifiedWorldGameTests {
"Actual native sulfur caverns must include native rock and spikes");
verified.add("minecraft:sulfur_caves");
}
helper.assertTrue(surfaceBiomes.keySet().stream().anyMatch(name -> name.startsWith("sanctuary:population_")),
"The starter surface must retain its Population biome family above these lower cave patches");
String surfacePrefix = generator.rootGeneration() == 25 ? "sanctuary:terrain25_" : "sanctuary:population_";
helper.assertTrue(surfaceBiomes.keySet().stream().anyMatch(name -> name.startsWith(surfacePrefix)),
"The starter surface must retain its generation-specific woodland biome family above these lower cave patches");
passed = true; helper.succeed();
} finally {
report.put("encountered_native_types", encountered); report.put("verified_native_types", verified);
@@ -248,7 +250,7 @@ public final class UnifiedWorldGameTests {
&& chunk.getBlockState(free).is(Blocks.DIAMOND_BLOCK),
"Decoration outside protected volumes must remain writable");
} finally { chunk.setBlockState(free, freeBefore, 0); }
write(level, "decoration-guard", Map.of("generation", 13, "seed", level.getSeed(),
write(level, "decoration-guard", Map.of("generation", generator.rootGeneration(), "seed", level.getSeed(),
"diameter", generator.initialDiameter(), "compared_plan_points", compared,
"natural_wall_witnesses", shells, "walls_owned_by_adjacent_chunk", boundaryWitnesses, "actual_write_rejected", true, "unprotected_write_allowed", true, "passed", true));
helper.succeed();
@@ -266,7 +268,7 @@ public final class UnifiedWorldGameTests {
int x = cx * 16 + 8, z = cz * 16 + 8;
if (Math.hypot(x, z) >= capacity.radius() - 24) continue;
for (int y = 164; y >= 40; y--) {
String biome = resolver.getNoiseBiome(x >> 2, (y + 1) >> 2, z >> 2).getRegisteredName();
String biome = caveFamily(binding, resolver.getNoiseBiome(x >> 2, (y + 1) >> 2, z >> 2).getRegisteredName());
var bucket = candidates.get(biome); if (bucket == null) continue;
if (sample(binding, density, x, y, z) <= 0 || sample(binding, density, x, y + 1, z) > 0
|| sample(binding, density, x, y + 2, z) > 0 || sample(binding, density, x, y - 1, z) <= 0) continue;
@@ -286,8 +288,10 @@ public final class UnifiedWorldGameTests {
.thenComparingDouble(s -> Math.hypot(s.x(), s.z())).thenComparingInt(Site::x).thenComparingInt(Site::z)).toList();
for (Site candidate : ordered.stream().limit(32).toList()) {
var base = new BlockPos(candidate.x(), candidate.floorY() + 1, candidate.z());
if (!PopulationHydrologyRuntime.protects(binding.generator(), binding.random(), base, 8,
candidate.roofY() - candidate.floorY(), 3)) { sites.add(candidate); break; }
boolean protectedSite = Terrain25Runtime.enabled(binding.generator())
? Terrain25Runtime.protects(binding.generator(), binding.random(), base, 8, candidate.roofY() - candidate.floorY(), 3)
: PopulationHydrologyRuntime.protects(binding.generator(), binding.random(), base, 8, candidate.roofY() - candidate.floorY(), 3);
if (!protectedSite) { sites.add(candidate); break; }
}
}
return List.copyOf(sites);
@@ -296,6 +300,24 @@ public final class UnifiedWorldGameTests {
private static float sample(PopulationGenerationContext.Binding binding, DensityFunction density, int x, int y, int z) {
return binding.random().sampleBlockValueUncached(density, x, y, z);
}
private static String caveFamily(PopulationGenerationContext.Binding binding, String name) {
if (!Terrain25Runtime.enabled(binding.generator())) return name;
return switch (name) {
case "sanctuary:terrain25_lush_caves" -> "minecraft:lush_caves";
case "sanctuary:terrain25_dripstone_caves" -> "minecraft:dripstone_caves";
case "sanctuary:terrain25_sulfur_depths" -> "minecraft:sulfur_caves";
default -> name;
};
}
private static String caveId(PopulationGenerationContext.Binding binding, String name) {
if (!Terrain25Runtime.enabled(binding.generator())) return name;
return switch (name) {
case "minecraft:lush_caves" -> "sanctuary:terrain25_lush_caves";
case "minecraft:dripstone_caves" -> "sanctuary:terrain25_dripstone_caves";
case "minecraft:sulfur_caves" -> "sanctuary:terrain25_sulfur_depths";
default -> throw new IllegalArgumentException("Unknown cave family " + name);
};
}
private static float natural(PopulationGenerationContext.Binding binding, BlockPos pos) {
return sample(binding, binding.generator().generatorSettings().value().noiseRouter().finalDensity(), pos.getX(), pos.getY(), pos.getZ());
}
@@ -309,7 +331,7 @@ public final class UnifiedWorldGameTests {
}
private static Map<String, Object> report(ServerLevel level) {
var report = new LinkedHashMap<String, Object>();
report.put("generation", 13); report.put("seed", level.getSeed()); report.put("diameter", unified(level).initialDiameter());
report.put("generation", unified(level).rootGeneration()); report.put("seed", level.getSeed()); report.put("diameter", unified(level).initialDiameter());
report.put("mod_version", FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow().getMetadata().getVersion().getFriendlyString());
return report;
}
@@ -45,9 +45,31 @@ final class UnifiedWorldSettingsDiagnostics {
var historical16 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v16"))).value().createWorldDimensions();
helper.assertTrue(historical16.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 16
&& old.source().generation() == 16 && !old.experimentalStructures(), "Historical16 never gains experimental structures");
var historical17 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v17"))).value().createWorldDimensions();
helper.assertTrue(historical17.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 17
&& old.source().generation() == 17 && old.experimentalStructures(), "Historical17 keeps its original experimental generator");
var historical19 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v19"))).value().createWorldDimensions();
helper.assertTrue(historical19.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 19
&& old.source().generation() == 19 && old.experimentalStructures(), "Historical19 keeps its original renderer and world settings");
var generator = (SanctuaryChunkGenerator) standard.overworld();
helper.assertTrue(generator.rootGeneration() == 17 && generator.source().generation() == 17 && generator.experimentalStructures(), "The public preset uses generation17 with its new-world option");
var historical20 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v20")));
helper.assertTrue(historical20.value().createWorldDimensions().overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 20
&& old.source().generation() == 20 && old.experimentalStructures(), "Historical20 keeps its original architecture");
var historical21 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v21")));
helper.assertTrue(historical21.value().createWorldDimensions().overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 21
&& old.source().generation() == 21 && old.experimentalStructures(), "Historical21 keeps its original placement rules");
var historical22 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v22")));
helper.assertTrue(historical22.value().createWorldDimensions().overworld() instanceof SanctuaryChunkGenerator old
&& old.rootGeneration() == 22 && old.source().generation() == 22, "Historical22 remains separately readable");
var historical23 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v23")));
helper.assertTrue(historical23.value().createWorldDimensions().overworld() instanceof SanctuaryChunkGenerator old
&& old.rootGeneration() == 23 && old.source().generation() == 23, "Historical23 remains separately readable");
var historical24 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v24")));
helper.assertTrue(historical24.value().createWorldDimensions().overworld() instanceof SanctuaryChunkGenerator old
&& old.rootGeneration() == 24 && old.source().generation() == 24, "Historical24 remains separately readable");
helper.assertTrue(generator.rootGeneration() == 25 && generator.source().generation() == 25 && generator.experimentalStructures(), "The public preset uses generation25 with its new-world option");
helper.assertTrue(generator.initialDiameter() == 724, "The public preset defaults to Medium / 724 blocks");
verifyConstructionMasks(helper, (SanctuaryChunkGenerator) historical21.value().createWorldDimensions().overworld(), generator);
for (var tag : List.of(WorldPresetTags.NORMAL, WorldPresetTags.EXTENDED)) {
var visible = presets.getOrThrow(tag).stream().map(holder -> holder.getRegisteredName())
.filter(name -> name.startsWith("sanctuary:")).collect(Collectors.toSet());
@@ -56,11 +78,11 @@ final class UnifiedWorldSettingsDiagnostics {
}
var ops = RegistryOps.create(JsonOps.INSTANCE, access);
var activeCodec = generator.rootGeneration() == 17 ? SanctuaryChunkGenerator.RUINS17_CODEC : generator.rootGeneration() == 16 ? SanctuaryChunkGenerator.CITY16_CODEC : generator.rootGeneration() == 15 ? SanctuaryChunkGenerator.CITY15_CODEC : generator.rootGeneration() == 14 ? SanctuaryChunkGenerator.CITY_CODEC : SanctuaryChunkGenerator.CODEC;
var activeCodec = generator.rootGeneration() == 25 ? SanctuaryChunkGenerator.TERRAIN25_CODEC : generator.rootGeneration() == 24 ? SanctuaryChunkGenerator.AERIAL24_CODEC : generator.rootGeneration() == 23 ? SanctuaryChunkGenerator.SECRETS23_CODEC : generator.rootGeneration() == 22 ? SanctuaryChunkGenerator.SELECTIVE22_CODEC : generator.rootGeneration() == 21 ? SanctuaryChunkGenerator.NETWORK21_CODEC : generator.rootGeneration() == 20 ? SanctuaryChunkGenerator.ARCHITECTURE20_CODEC : generator.rootGeneration() == 19 ? SanctuaryChunkGenerator.HERITAGE19_CODEC : generator.rootGeneration() == 17 ? SanctuaryChunkGenerator.RUINS17_CODEC : generator.rootGeneration() == 16 ? SanctuaryChunkGenerator.CITY16_CODEC : generator.rootGeneration() == 15 ? SanctuaryChunkGenerator.CITY15_CODEC : generator.rootGeneration() == 14 ? SanctuaryChunkGenerator.CITY_CODEC : SanctuaryChunkGenerator.CODEC;
var serialized = activeCodec.codec().encodeStart(ops, generator).getOrThrow().getAsJsonObject();
serialized.remove("experimental_structures");
helper.assertTrue(activeCodec.codec().parse(ops, serialized).getOrThrow().experimentalStructures(),
"Generation17 omitted experimental option defaults to enabled");
"Generation25 omitted experimental option defaults to enabled");
helper.assertTrue(!((SanctuaryChunkGenerator) historical16.overworld()).withExperimentalStructures(true).experimentalStructures(),
"The new-world switch cannot activate experimental generation in a historical16 generator");
serialized.remove("initial_diameter");
@@ -75,9 +97,12 @@ final class UnifiedWorldSettingsDiagnostics {
Files.createDirectories(directory);
var storage = LevelStorageSource.createDefault(directory);
List<Map<String, Object>> results = new ArrayList<>();
for (int diameter : new int[]{512, 724, 1024}) for (boolean experimental : new boolean[]{false, true}) {
var chosen = generator.withInitialDiameter(diameter).withExperimentalStructures(experimental);
helper.assertTrue(generator.initialDiameter() == 724 && chosen.source() != generator.source(),
for (var generation : List.of(
(SanctuaryChunkGenerator) historical23.value().createWorldDimensions().overworld(),
(SanctuaryChunkGenerator) historical24.value().createWorldDimensions().overworld(), generator))
for (int diameter : new int[]{512, 724, 1024}) for (boolean experimental : new boolean[]{false, true}) {
var chosen = generation.withInitialDiameter(diameter).withExperimentalStructures(experimental);
helper.assertTrue(generation.initialDiameter() == 724 && chosen.source() != generation.source(),
"Customizing a new world must copy the generator and its mutable biome source");
var dimensions = standard.replaceOverworldGenerator(access, chosen);
for (var dimension : List.of(LevelStem.NETHER, LevelStem.END)) {
@@ -85,17 +110,19 @@ final class UnifiedWorldSettingsDiagnostics {
"Customizing the island preserves the other dimensions");
}
var original = new WorldGenSettings(new WorldOptions(helper.getLevel().getSeed(), false, true), dimensions);
String name = "diameter-" + diameter + "-experimental-" + experimental;
String name = "generation-" + generation.rootGeneration() + "-diameter-" + diameter + "-experimental-" + experimental;
try (var save = storage.createAccess(name)) {
LevelStorageSource.writeWorldGenSettings(access, storage.getLevelPath(name), original);
var restored = LevelStorageSource.readExistingSavedData(save, access, WorldGenSettings.TYPE).getOrThrow();
helper.assertTrue(restored.dimensions().overworld() instanceof SanctuaryChunkGenerator reloaded
&& reloaded.initialDiameter() == diameter && reloaded.rootGeneration() == generator.rootGeneration() && reloaded.experimentalStructures() == experimental,
&& reloaded.initialDiameter() == diameter && reloaded.rootGeneration() == generation.rootGeneration()
&& reloaded.source().generation() == generation.rootGeneration() && reloaded.experimentalStructures() == experimental,
"The selected diameter must survive real world-settings storage");
helper.assertTrue(WorldGenSettings.CODEC.encodeStart(ops, restored).getOrThrow()
.equals(WorldGenSettings.CODEC.encodeStart(ops, original).getOrThrow()),
"Seed, generation options and all dimensions survive the disk round trip");
results.add(Map.of("diameter", diameter, "experimental_structures", experimental, "disk_round_trip", true));
results.add(Map.of("generation", generation.rootGeneration(), "diameter", diameter,
"experimental_structures", experimental, "disk_round_trip", true));
}
}
Files.writeString(directory.resolve("round-trip.json"), new GsonBuilder().setPrettyPrinting().create().toJson(Map.of(
@@ -103,4 +130,54 @@ final class UnifiedWorldSettingsDiagnostics {
"public_presets", List.of("sanctuary:sanctuary"), "sizes", results,
"invalid_diameters_rejected", true, "copied_biome_source", true, "passed", true)) + "\n");
}
private static void verifyConstructionMasks(GameTestHelper helper, SanctuaryChunkGenerator historical21,
SanctuaryChunkGenerator generation22) {
var outside = new net.minecraft.core.BlockPos(200_000, 192, 200_000);
// Decoding a generation must not reserve the world before any structures are planned.
// These checks also cover the old OFF contract without initializing another island.
for (var generator : List.of(historical21, historical21.withExperimentalStructures(false),
generation22, generation22.withExperimentalStructures(false))) {
helper.assertTrue(!generator.protectsCityDecoration(outside),
"Unplanned generation" + generator.rootGeneration() + " does not protect arbitrary blocks");
helper.assertTrue(!generator.intersectsExperimentalStructures(outside.getX(), outside.getY(), outside.getZ(),
outside.getX() + 1, outside.getY() + 1, outside.getZ() + 1),
"Unplanned generation" + generator.rootGeneration() + " does not reject arbitrary structure bounds");
}
// Reuse the attached world's real plans: no extra native-noise planning or chunk loads.
if (!(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator active)
|| active.rootGeneration() != 21 && active.rootGeneration() != 22 && active.rootGeneration() != 23 && active.rootGeneration() != 24 && active.rootGeneration() != 25) return;
helper.assertTrue(!active.protectsCityDecoration(outside)
&& !active.intersectsExperimentalStructures(outside.getX(), outside.getY(), outside.getZ(),
outside.getX() + 1, outside.getY() + 1, outside.getZ() + 1),
"Prepared construction masks remain local to their real structures");
var interior = new ArrayList<net.minecraft.core.BlockPos>();
for (var module : active.transitPlan21().modules())
interior.add(new net.minecraft.core.BlockPos(module.centerX(), module.floorY(), module.centerZ()));
for (var network : active.undergroundHall22().networks()) for (var module : network.modules())
interior.add(new net.minecraft.core.BlockPos(module.centerX(), module.floorY(), module.centerZ()));
if ((active.rootGeneration() == 22 || active.rootGeneration() == 23 || active.rootGeneration() == 24 || active.rootGeneration() == 25) && helper.getLevel().structureManager().shouldGenerateStructures())
helper.assertTrue(!interior.isEmpty(), "The attached generation" + active.rootGeneration() + " world has core construction to protect");
for (var position : interior) helper.assertTrue(active.protectsCityDecoration(position)
&& active.intersectsExperimentalStructures(position.getX(), position.getY(), position.getZ(),
position.getX(), position.getY(), position.getZ()),
"Real underground construction remains protected from decoration and overlapping native structures");
if (active.rootGeneration() == 25) {
var copied = active.source().copy();
copied.setIslands(active.source().islands());
var binding = active.starterContext();
var originalResolver = active.source().createUncachedResolver(binding.random());
var copiedResolver = copied.createUncachedResolver(binding.random());
int checked = 0;
for (int x = -active.initialDiameter() / 2; x <= active.initialDiameter() / 2; x += 32)
for (int z = -active.initialDiameter() / 2; z <= active.initialDiameter() / 2; z += 32)
for (int y : new int[]{80, 128, 160, 192, 224, 288}) {
var expected = originalResolver.getNoiseBiome(x >> 2, y >> 2, z >> 2);
helper.assertTrue(copiedResolver.getNoiseBiome(x >> 2, y >> 2, z >> 2).equals(expected),
"An isolated generation25 preflight retains the exact immutable terrain-biome plan");
checked++;
}
helper.assertTrue(checked > 100, "Compare copied biomes across surface and cave layers");
}
}
}
@@ -0,0 +1,433 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import fr.koka.sanctuary.worldgen.IslandCapacity;
import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.Terrain25Biomes;
import fr.koka.sanctuary.worldgen.Terrain25Runtime;
import fr.koka.sanctuary.worldgen.Terrain25Wetlands;
import fr.koka.sanctuary.worldgen.underground25.SwampHutAdmission25;
import fr.koka.sanctuary.worldgen.underground25.UndergroundPlan25;
import fr.koka.sanctuary.worldgen.underground25.UndergroundStructures25;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.tags.BlockTags;
import net.minecraft.tags.FluidTags;
import net.minecraft.world.entity.animal.feline.Cat;
import net.minecraft.world.entity.monster.Witch;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.biome.BiomeResolver;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece;
import net.minecraft.world.phys.AABB;
/** Real regional water, native FULL biomes/blocks and conditional vanilla hut occupants; no synthetic shore. */
public final class Wetlands25WorldGameTests {
private static final com.google.gson.Gson JSON = new GsonBuilder().setPrettyPrinting().create();
private static final Comparator<Terrain25Wetlands.Column> COLUMN_ORDER = Comparator
.comparingInt(Terrain25Wetlands.Column::x).thenComparingInt(Terrain25Wetlands.Column::z)
.thenComparingInt(Terrain25Wetlands.Column::floorY).thenComparingLong(Terrain25Wetlands.Column::waterFeature);
private static final int WITNESSES_PER_KIND = 12, MAX_FULL_CHUNKS = 64, MAX_FLORA_COLUMNS_PER_STRATUM = 2048;
@GameTest(maxTicks = 24000)
public void nativeWetlandsAndConditionalHut(GameTestHelper helper) {
nativeLocalHutHeight(helper);
new Run(helper).begin();
}
private static void nativeLocalHutHeight(GameTestHelper helper) {
// The native heightmap's solid-block tags exist only after server datapacks load.
var states = new BlockState[384];
java.util.Arrays.fill(states, Blocks.AIR.defaultBlockState());
for (int y = 0; y <= 184; y++) states[y] = Blocks.STONE.defaultBlockState();
for (int y = 185; y <= 189; y++) states[y] = Blocks.WATER.defaultBlockState();
for (int y = 210; y <= 260; y++) states[y] = Blocks.STONE.defaultBlockState();
var column = new net.minecraft.world.level.NoiseColumn(0, states);
helper.assertTrue(SwampHutAdmission25.localNativeHeight(column, 0, 193) == 190,
"Native lower-hut height includes the local water surface");
column.setBlock(190, Blocks.STONE.defaultBlockState());
helper.assertTrue(SwampHutAdmission25.localNativeHeight(column, 0, 193) == 191,
"Loaded native heightmap tags recognize a higher stone bank");
helper.assertTrue(column.getBlock(210).is(Blocks.STONE),
"Selecting the local waterline leaves the true cave roof in the certification column");
}
private record Pier(BlockPos top, BlockPos foot, BlockState ground, BlockState subsoil) {}
private record Witness(Terrain25Wetlands.Column column, BlockState floor, BlockState below) {}
private static final class Run {
final GameTestHelper helper;
final ServerLevel level;
final SanctuaryChunkGenerator generator;
final Map<String, Object> report = new LinkedHashMap<>();
final List<Witness> witnesses = new ArrayList<>();
final List<Pier> piers = new ArrayList<>();
final Set<ChunkPos> chunks = new LinkedHashSet<>(), entityChunks = new LinkedHashSet<>();
final Map<Long, List<Terrain25Wetlands.Column>> strata = new HashMap<>();
final long started = System.nanoTime();
List<UndergroundPlan25.PlannedStart> huts = List.of();
BiomeResolver withoutWetlands;
Iterator<ChunkPos> iterator;
boolean tickets;
int wait;
Run(GameTestHelper helper) {
this.helper = helper; level = helper.getLevel();
generator = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
}
void begin() {
try {
helper.assertTrue(generator.rootGeneration() == 25, "Wetlands require a disposable generation25 world");
boolean structures = level.structureManager().shouldGenerateStructures();
report.put("seed", level.getSeed()); report.put("diameter", generator.initialDiameter());
report.put("generate_structures", structures); report.put("experimental_structures", generator.experimentalStructures());
report.put("process_id", ProcessHandle.current().pid());
report.put("underground_required", Boolean.getBoolean("sanctuary.test.wetlands25.undergroundRequired"));
var binding = generator.starterContext();
var capacity = PopulationHydrologyRuntime.capacity(binding.generator());
var first = capacity.regionAt(-capacity.terrainLimit(), -capacity.terrainLimit());
var last = capacity.regionAt(capacity.terrainLimit(), capacity.terrainLimit());
var columns = new TreeMap<String, Terrain25Wetlands.Column>();
var regions = new ArrayList<Map<String, Object>>();
for (int rx = first.x(); rx <= last.x(); rx++) for (int rz = first.z(); rz <= last.z(); rz++) {
var region = new IslandCapacity.Region(rx, rz);
var plan = Terrain25Runtime.wetlandPlan(binding.generator(), binding.random(), region);
helper.assertTrue(plan.candidates() <= 2 * Terrain25Wetlands.MAX_CANDIDATES
&& plan.sites().stream().filter(s -> !s.underground()).count() <= Terrain25Wetlands.MAX_SITES
&& plan.sites().stream().filter(Terrain25Wetlands.Site::underground).count() <= Terrain25Wetlands.MAX_UNDERGROUND_SITES,
"Combined surface and underground wetland searches stay within their regional budgets");
regions.add(Map.of("region", List.of(rx, rz), "sites", plan.sites(), "columns", plan.columns().size(),
"candidates", plan.candidates(), "refusals", plan.refusals()));
for (var c : plan.columns()) {
// A biome lookup consults the owning region. Cross-border metadata alone is not a world biome.
if (!capacity.regionAt(c.x(), c.z()).equals(region)) continue;
helper.assertTrue((c.underground() || Math.hypot(c.x(), c.z()) >= Terrain25Wetlands.ARRIVAL_RADIUS)
&& Math.hypot(c.x(), c.z()) < capacity.terrainLimit(), "Wetlands stay inside the island and preserve the surface arrival area");
helper.assertTrue(c.minY() == c.floorY() - (c.wet() ? 2 : 3)
&& c.maxY() <= Math.max(c.floorY(), c.waterY()) + 12
&& c.maxY() >= Math.max(c.floorY(), c.waterY()) + 4, "Each wetland owns a finite local envelope, possibly limited by its cave ceiling");
helper.assertTrue(c.underground() || c.maxY() == Math.max(c.floorY(), c.waterY()) + 12,
"An exposed wetland retains its surface envelope");
helper.assertTrue(plan.contains(c.x(), c.floorY(), c.z())
&& !c.contains(c.minY() - 1) && !c.contains(c.maxY() + 1), "The individual wetland stratum has exact vertical bounds");
if (c.wet()) helper.assertTrue(c.waterY() > c.floorY() && c.waterY() - c.floorY() <= (c.underground() ? 6 : 4),
"Wetland water follows its existing shallow surface or underground retention");
columns.put(c.x() + "," + c.z() + "," + c.floorY() + "," + c.waterY() + "," + c.waterFeature(), c);
}
}
report.put("regions", regions); report.put("effective_columns", columns.size());
var ordered = columns.values().stream().sorted(COLUMN_ORDER).toList();
for (var c : ordered) strata.computeIfAbsent(columnKey(c.x(), c.z()), ignored -> new ArrayList<>()).add(c);
long lowerColumns = ordered.stream().filter(Terrain25Wetlands.Column::underground).count();
report.put("horizontal_columns", strata.size());
report.put("surface_strata", ordered.size() - lowerColumns); report.put("underground_strata", lowerColumns);
report.put("superposed_horizontal_columns", strata.values().stream().filter(list -> list.size() > 1).count());
report.put("natural_plan_sha256", HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(JSON.toJson(ordered).getBytes(StandardCharsets.UTF_8))));
if (Boolean.getBoolean("sanctuary.test.wetlands25.required"))
helper.assertTrue(!columns.isEmpty(), "This fixture requires a positive naturally admitted wetland");
if (Boolean.getBoolean("sanctuary.test.wetlands25.undergroundRequired"))
helper.assertTrue(lowerColumns > 0, "This fixture requires a naturally admitted underground wetland");
report.put("presence", !columns.isEmpty());
report.put("presence_status", columns.isEmpty() ? "not_admitted_conditionally" : "admitted");
var baseline = generator.source().copy(); baseline.setIslands(generator.source().islands());
baseline.setTerrain25Wetlands(Terrain25Wetlands.Plan.EMPTY);
withoutWetlands = baseline.createCachingResolver(binding.random());
var underground = UndergroundStructures25.plan(generator);
huts = underground.starts().stream().filter(p -> p.key().identifier().toString().equals("minecraft:swamp_hut")).toList();
if (!structures || !generator.experimentalStructures())
helper.assertTrue(huts.isEmpty(), "OFF disables the optional hut, independently of natural wetlands");
report.put("hut_status", huts.isEmpty() ? "not_admitted_conditionally" : "admitted");
report.put("hut_diagnostics", underground.diagnostics());
for (boolean lower : new boolean[]{false, true}) for (boolean wet : new boolean[]{true, false}) {
int added = 0;
for (var c : ordered) {
if (c.underground() != lower || c.wet() != wet || (c.x() & 3) != 0 || (c.z() & 3) != 0
|| biomeY(c) > c.maxY()) continue;
if (generator.intersectsExperimentalStructures(c.x(), c.floorY() - 2, c.z(), c.x(), c.maxY(), c.z())) continue;
var column = binding.generator().getBaseColumn(c.x(), c.z(), level, binding.random());
witnesses.add(new Witness(c, column.getBlock(c.floorY()), column.getBlock(c.floorY() - 1)));
chunks.add(new ChunkPos(c.x() >> 4, c.z() >> 4));
if (++added >= WITNESSES_PER_KIND) break;
}
if (ordered.stream().anyMatch(c -> c.underground() == lower && c.wet() == wet))
helper.assertTrue(added > 0, "An admitted " + (lower ? "underground" : "surface")
+ (wet ? " water" : " bank") + " stratum has a real unobstructed FULL witness");
}
if (!columns.isEmpty()) {
helper.assertTrue(witnesses.stream().anyMatch(w -> w.column().wet()), "At least one actual wet quart is inspected");
helper.assertTrue(witnesses.stream().anyMatch(w -> !w.column().wet()), "At least one actual shore quart is inspected");
}
for (var hut : huts) {
helper.assertTrue(hut.start().getPieces().size() == 1 && hut.start().getPieces().getFirst() instanceof SwampHutPiece,
"The admitted hut uses its actual vanilla piece");
var piece = hut.start().getPieces().getFirst();
chunks.add(hut.start().getChunkPos());
piece.getBoundingBox().intersectingChunks().forEach(c -> { chunks.add(c); entityChunks.add(c); });
for (var top : SwampHutAdmission25.piers(piece)) {
var column = binding.generator().getBaseColumn(top.getX(), top.getZ(), level, binding.random());
int footY = top.getY();
while (replaceable(column.getBlock(footY)) && top.getY() - footY <= SwampHutAdmission25.MAX_PIER_DROP) footY--;
helper.assertTrue(top.getY() - footY <= SwampHutAdmission25.MAX_PIER_DROP,
"A planned native pier reaches natural terrain within eight blocks");
var foot = new BlockPos(top.getX(), footY, top.getZ());
var ground = column.getBlock(footY); var subsoil = column.getBlock(footY - 1);
helper.assertTrue(solid(ground) && solid(subsoil), "Two natural ground layers anchor each hut pier before FULL");
piers.add(new Pier(top, foot, ground, subsoil));
}
}
chunks.add(new ChunkPos(0, 0));
int voidX = (capacity.terrainLimit() + 255) & ~15;
chunks.add(new ChunkPos(voidX >> 4, 0)); report.put("void_witness_x", voidX);
helper.assertTrue(chunks.size() <= MAX_FULL_CHUNKS, "Wetland test loads only bounded native witnesses");
report.put("full_chunks_requested", chunks.stream().map(c -> List.of(c.x(), c.z())).toList());
iterator = chunks.iterator(); step();
} catch (Throwable failure) { fail(failure); }
}
void step() {
try {
if (iterator.hasNext()) {
var c = iterator.next(); level.getChunk(c.x(), c.z(), ChunkStatus.FULL, true);
later(); return;
}
if (!tickets) { entityChunks.forEach(c -> ChunkTickets25.add(level, c)); tickets = true; }
boolean ready = entityChunks.stream().allMatch(c -> level.areEntitiesLoaded(c.pack()) && level.areEntitiesActuallyLoadedAndTicking(c));
if (!ready && wait++ < 6000) { later(); return; }
report.put("entity_wait_ticks", wait);
report.put("entity_chunks", entityChunks.stream().map(c -> Map.of("chunk", List.of(c.x(), c.z()),
"loaded", level.areEntitiesLoaded(c.pack()), "ticking", level.areEntitiesActuallyLoadedAndTicking(c),
"status", level.getChunkSource().getChunkDebugData(c))).toList());
helper.assertTrue(ready, "Hut occupants are inspected after all hut chunks are actually ticking");
verifyWorld(); release(); report.put("passed", true);
report.put("milliseconds", (System.nanoTime() - started) / 1_000_000L);
String mode = !level.structureManager().shouldGenerateStructures() ? "global-off"
: !generator.experimentalStructures() ? "experimental-off" : "create";
write(Path.of("diagnostics/wetlands25/" + mode + ".json"), report); helper.succeed();
} catch (Throwable failure) { fail(failure); }
}
void verifyWorld() {
var results = new ArrayList<Map<String, Object>>(); report.put("native_witnesses", results);
int wet = 0, bank = 0, soils = 0, caveQuarts = 0;
var checkedColumns = new HashSet<Long>();
var stratumCounts = new LinkedHashMap<String, Integer>();
var soilCounts = new LinkedHashMap<String, Integer>();
var ores = new LinkedHashMap<BlockPos, Map<String, Object>>();
report.put("ore_witnesses", new ArrayList<Map<String, Object>>());
var binding = generator.starterContext();
for (var witness : witnesses) {
var c = witness.column(); int biomeY = biomeY(c);
String stratum = (c.underground() ? "underground_" : "surface_") + (c.wet() ? "water" : "bank");
stratumCounts.merge(stratum, 1, Integer::sum);
var floor = new BlockPos(c.x(), c.floorY(), c.z());
var actual = level.getBlockState(floor); var below = level.getBlockState(floor.below());
var result = new LinkedHashMap<String, Object>(); results.add(result);
result.put("column", c); result.put("floor_before_full", block(witness.floor()));
result.put("stratum", stratum);
result.put("floor_full", block(actual)); result.put("subsoil_full", block(below));
result.put("biome_y", biomeY);
var biome = level.getChunk(c.x() >> 4, c.z() >> 4).getNoiseBiome(c.x() >> 2, biomeY >> 2, c.z() >> 2);
result.put("stored_biome", biome.unwrapKey().orElseThrow().identifier().toString());
helper.assertTrue(biome.is(Terrain25Biomes.key("swamp")), "Actual FULL stores swamp at its water/shore envelope: " + floor);
helper.assertTrue(solid(actual) && solid(below) && solid(level.getBlockState(floor.below(2))),
"Actual wetland floor and two underlying layers remain supported: " + floor);
if (c.wet()) {
for (int y = c.floorY() + 1; y <= c.waterY(); y++) {
var pos = new BlockPos(c.x(), y, c.z()); var fluid = level.getFluidState(pos);
helper.assertTrue(fluid.is(FluidTags.WATER) && fluid.isSource(), "Shallow retained water survives FULL without a synthetic refill: " + pos);
}
wet++;
} else bank++;
var expected = Terrain25Runtime.shoreMaterial(c, level.getSeed(), c.floorY(), Blocks.STONE.defaultBlockState());
boolean coatedBefore = witness.floor().equals(expected);
boolean tree = actual.is(BlockTags.LOGS) || level.getBlockState(floor.above()).is(BlockTags.LOGS);
boolean floorOre = nativeOre(actual), belowOre = nativeOre(below);
result.put("expected_coating", block(expected)); result.put("tree_modification", tree);
result.put("floor_is_preserved_ore", floorOre); result.put("subsoil_is_preserved_ore", belowOre);
for (int depth = 0; depth <= 1; depth++) {
var pos = floor.below(depth); var state = depth == 0 ? actual : below;
var previous = depth == 0 ? witness.floor() : witness.below();
if (nativeOre(previous)) helper.assertTrue(state.equals(previous),
"An ore already present in the native preview survives FULL unchanged: " + pos);
if (!nativeOre(state)) continue;
helper.assertTrue(Terrain25Runtime.shoreMaterial(c, level.getSeed(), pos.getY(), state).equals(state),
"Wetland coating preserves this real native ore instead of replacing it with soil: " + pos);
ores.putIfAbsent(pos, Map.of("position", position(pos), "block", block(state), "stratum", stratum,
"unchanged_from_preview", state.equals(previous), "coating_preserves_ore", true));
}
if (coatedBefore && !tree) {
// Only actual ore is exempt: a remaining TUFF/STONE block must still fail.
if (!floorOre) helper.assertTrue(actual.equals(expected), "Native wetland coating matches its deterministic soil at " + floor);
if (!belowOre) helper.assertTrue(below.equals(witness.below()), "Native wetland subsoil survives decoration at " + floor);
if (!floorOre && !belowOre) { soils++; soilCounts.merge(stratum, 1, Integer::sum); }
}
int outside = 0, caves = 0;
boolean firstAtColumn = checkedColumns.add(columnKey(c.x(), c.z()));
int inside = 0;
for (int y = 0; firstAtColumn && y < 384; y += 4) {
int quartY = y;
boolean planned = strata.getOrDefault(columnKey(c.x(), c.z()), List.of()).stream().anyMatch(s -> s.contains(quartY));
boolean runtime = Terrain25Runtime.wetlandAt(binding.generator(), binding.random(), c.x(), y, c.z());
helper.assertTrue(runtime == planned, "World lookup preserves the union of every local wetland stratum at " + new BlockPos(c.x(), y, c.z()));
var stored = level.getChunk(c.x() >> 4, c.z() >> 4).getNoiseBiome(c.x() >> 2, y >> 2, c.z() >> 2);
if (planned) {
helper.assertTrue(stored.is(Terrain25Biomes.key("swamp")), "Every claimed stratum stores the native swamp biome at " + new BlockPos(c.x(), y, c.z()));
inside++; continue;
}
var baseline = withoutWetlands.getNoiseBiome(c.x() >> 2, y >> 2, c.z() >> 2);
helper.assertTrue(stored.is(baseline.unwrapKey().orElseThrow()), "Outside all local wetland envelopes, native cave/land biome is unchanged at " + new BlockPos(c.x(), y, c.z()));
helper.assertTrue(!stored.is(Terrain25Biomes.key("swamp")), "No vertical swamp leak outside the union of its envelopes");
if (stored.is(Terrain25Biomes.key("lush_caves")) || stored.is(Terrain25Biomes.key("dripstone_caves"))
|| stored.is(Terrain25Biomes.key("sulfur_depths")) || stored.is(Terrain25Biomes.MUSHROOM_REFUGE)) caves++;
outside++;
}
result.put("all_strata_in_horizontal_column", strata.getOrDefault(columnKey(c.x(), c.z()), List.of()));
result.put("vertical_column_checked_once", firstAtColumn); result.put("inside_envelopes_quarts_checked", inside);
result.put("outside_envelope_quarts_checked", outside); result.put("cave_quarts_preserved", caves); caveQuarts += caves;
}
if (!witnesses.isEmpty()) helper.assertTrue(soils > 0, "At least one deterministic wetland soil is present in actual FULL terrain");
for (String stratum : stratumCounts.keySet())
helper.assertTrue(soilCounts.getOrDefault(stratum, 0) > 0, "Actual wetland coating is inspected separately in " + stratum);
if (Boolean.getBoolean("sanctuary.test.wetlands25.undergroundRequired"))
helper.assertTrue(stratumCounts.getOrDefault("underground_water", 0) > 0
&& stratumCounts.getOrDefault("underground_bank", 0) > 0, "Required underground wetland has actual FULL water and bank witnesses");
report.put("strata_checked", stratumCounts); report.put("strata_exact_soils_checked", soilCounts);
report.put("vertical_columns_checked", checkedColumns.size());
report.put("water_columns_checked", wet); report.put("shore_columns_checked", bank);
report.put("exact_soil_columns_checked", soils); report.put("cave_quarts_preserved", caveQuarts);
report.put("ore_witnesses", new ArrayList<>(ores.values())); report.put("ore_blocks_present", ores.size());
report.put("ore_blocks_unchanged_from_preview", ores.values().stream().filter(o -> Boolean.TRUE.equals(o.get("unchanged_from_preview"))).count());
report.put("ore_scope", "Actual ore is counted separately from exact soil pairs. Where preview already contains ore, FULL must preserve its exact state; later native ore remains excluded from coating. No ore presence quota or whole-deposit count.");
int lowerArrivalQuarts = 0;
for (int x : new int[]{0, (Integer) report.get("void_witness_x")}) for (int y = 0; y < 384; y += 4) {
int quartY = y;
boolean lowerAtArrival = x == 0 && strata.getOrDefault(columnKey(0, 0), List.of()).stream()
.anyMatch(c -> c.underground() && c.contains(quartY));
helper.assertTrue(Terrain25Runtime.wetlandAt(binding.generator(), binding.random(), x, y, 0) == lowerAtArrival,
"Arrival stays protected at the surface; only a real underground envelope can be swamp below it, and none in the void");
helper.assertTrue(level.getChunk(x >> 4, 0).getNoiseBiome(x >> 2, y >> 2, 0).is(Terrain25Biomes.key("swamp")) == lowerAtArrival,
"Actual FULL respects the arrival surface and void, without excluding legitimate underground strata");
if (lowerAtArrival) lowerArrivalQuarts++;
}
report.put("underground_quarts_below_arrival", lowerArrivalQuarts);
verifyFlora();
verifyHuts();
report.put("scope", "Stored FULL biome quarts, real retained water and soils in separately selected upper/lower strata; all Y quarts outside the UNION of local envelopes match the baseline. OFF preserves natural planning. Hut presence is conditional. No global swamp/hut quota or lifecycle claim.");
}
void verifyFlora() {
var counts = new LinkedHashMap<String, Map<String, Integer>>(); report.put("native_flora", counts);
var examples = new ArrayList<Map<String, Object>>(); report.put("native_flora_witnesses", examples);
var scanned = new LinkedHashMap<String, Integer>(); report.put("flora_columns_scanned", scanned);
var seen = new HashSet<BlockPos>();
int lowerPlants = 0, plantsUnderRock = 0;
var ordered = strata.values().stream().flatMap(Collection::stream).sorted(COLUMN_ORDER).toList();
for (boolean lower : new boolean[]{false, true}) {
String key = lower ? "underground" : "surface"; int columns = 0, exampleCount = 0;
var plants = new LinkedHashMap<String, Integer>(); counts.put(key, plants);
for (var c : ordered) {
if (c.underground() != lower || !chunks.contains(new ChunkPos(c.x() >> 4, c.z() >> 4))) continue;
// Keep native support checks inside chunks already explicitly loaded by this test.
if ((c.x() & 15) == 0 || (c.x() & 15) == 15 || (c.z() & 15) == 0 || (c.z() & 15) == 15) continue;
if (columns++ >= MAX_FLORA_COLUMNS_PER_STRATUM) { columns--; break; }
for (int y = c.floorY() + 1; y <= c.maxY(); y++) {
var pos = new BlockPos(c.x(), y, c.z()); var state = level.getBlockState(pos);
if (!marshPlant(state) || !seen.add(pos)) continue;
helper.assertTrue(state.canSurvive(level, pos), "Native marsh plant has actual valid support: " + pos + " " + block(state));
plants.merge(block(state), 1, Integer::sum); if (lower) lowerPlants++;
Integer roof = null;
if (lower) for (int roofY = c.maxY() + 1; roofY <= Math.min(383, c.maxY() + 64); roofY++) {
var top = level.getBlockState(new BlockPos(c.x(), roofY, c.z()));
if (solid(top) && !top.is(BlockTags.LOGS) && !top.is(BlockTags.LEAVES)) { roof = roofY; break; }
}
if (roof != null) plantsUnderRock++;
if (exampleCount++ < 24) {
var example = new LinkedHashMap<String, Object>(); examples.add(example);
example.put("position", position(pos)); example.put("block", block(state)); example.put("stratum", key);
example.put("rock_roof_y", roof); example.put("native_support_valid", true);
}
}
}
scanned.put(key, columns);
}
report.put("underground_flora_blocks", lowerPlants); report.put("underground_flora_under_rock", plantsUnderRock);
if (Boolean.getBoolean("sanctuary.test.wetlands25.undergroundRequired"))
helper.assertTrue(lowerPlants > 0 && plantsUnderRock > 0,
"Required underground wetland has real supported marsh vegetation beneath an actual rock roof");
}
void verifyHuts() {
var results = new ArrayList<Map<String, Object>>(); report.put("native_huts", results);
var context = StructurePieceSerializationContext.fromLevel(level);
for (var planned : huts) {
var at = planned.start().getChunkPos();
var start = level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true).getStartForStructure(planned.start().getStructure());
helper.assertTrue(start != null && start.isValid() && start.getPieces().size() == 1
&& start.getPieces().getFirst() instanceof SwampHutPiece, "Admitted hut has its actual saved native start");
var piece = start.getPieces().getFirst(); var tag = piece.createTag(context);
helper.assertTrue(tag.getBooleanOr("Witch", false) && tag.getBooleanOr("Cat", false), "Native one-time occupant flags are saved");
var saved = start.createTag(context, at); var decoded = StructureStart.loadStaticStart(context, saved, level.getSeed());
helper.assertTrue(decoded != null && saved.equals(decoded.createTag(context, at)), "Native hut state round-trips through its real NBT");
var b = piece.getBoundingBox(); var search = new AABB(b.minX() - 16, b.minY() - 16, b.minZ() - 16, b.maxX() + 17, b.maxY() + 17, b.maxZ() + 17);
var witches = level.getEntitiesOfClass(Witch.class, search, e -> e.isAlive() && e.isPersistenceRequired());
var cats = level.getEntitiesOfClass(Cat.class, search, e -> e.isAlive() && e.isPersistenceRequired());
helper.assertTrue(!witches.isEmpty() && !cats.isEmpty(), "A real persistent witch and cat inhabit the admitted hut");
for (var pier : piers) {
helper.assertTrue(level.getBlockState(pier.foot()).equals(pier.ground())
&& level.getBlockState(pier.foot().below()).equals(pier.subsoil()), "Native hut preserves its original two-layer natural footing");
for (int y = pier.foot().getY() + 1; y <= pier.top().getY(); y++)
helper.assertTrue(level.getBlockState(new BlockPos(pier.top().getX(), y, pier.top().getZ())).is(Blocks.OAK_LOG),
"Every native pier is continuous down to its actual natural footing");
}
results.add(Map.of("start_chunk", List.of(at.x(), at.z()), "entry", position(planned.entrance()),
"witches", witches.stream().map(e -> e.getUUID().toString()).toList(), "cats", cats.stream().map(e -> e.getUUID().toString()).toList(),
"piers", piers.stream().map(p -> Map.of("top", position(p.top()), "foot", position(p.foot()), "natural_ground", block(p.ground()))).toList()));
}
}
void later() { helper.runAtTickTime(helper.getTick() + 1, this::step); }
void release() { if (tickets) entityChunks.forEach(c -> ChunkTickets25.remove(level, c)); tickets = false; }
void fail(Throwable failure) {
release(); report.put("passed", false); report.put("failure", failure.toString());
var stack = new StringWriter(); failure.printStackTrace(new PrintWriter(stack)); report.put("stacktrace", stack.toString());
try { write(Path.of("diagnostics/wetlands25/failure.json"), report); } catch (Exception ignored) {}
throw new IllegalStateException("Native wetlands25 verification failed", failure);
}
}
private static boolean solid(BlockState state) { return state.isSolid() && state.getFluidState().isEmpty(); }
private static boolean nativeOre(BlockState state) {
return state.is(BlockTags.ORES);
}
private static boolean marshPlant(BlockState state) {
return state.is(Blocks.LILY_PAD) || state.is(Blocks.SUGAR_CANE) || state.is(Blocks.SHORT_GRASS)
|| state.is(Blocks.TALL_GRASS) || state.is(Blocks.BLUE_ORCHID) || state.is(Blocks.FIREFLY_BUSH)
|| state.is(Blocks.BROWN_MUSHROOM) || state.is(Blocks.RED_MUSHROOM) || state.is(Blocks.VINE);
}
private static int biomeY(Terrain25Wetlands.Column column) { return (column.floorY() + 4) & ~3; }
private static long columnKey(int x, int z) { return ((long) x << 32) ^ (z & 0xffffffffL); }
private static boolean replaceable(BlockState state) { return state.isAir() || state.liquid() || state.is(Blocks.GLOW_LICHEN)
|| state.is(Blocks.SEAGRASS) || state.is(Blocks.TALL_SEAGRASS); }
private static String block(BlockState state) { return BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(); }
private static List<Integer> position(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
private static void write(Path path, Object value) throws java.io.IOException {
Files.createDirectories(path.toAbsolutePath().getParent()); Files.writeString(path, JSON.toJson(value) + "\n");
}
}
@@ -27,6 +27,8 @@ public final class WorldgenPerformanceGameTests {
var capacity = IslandCapacity.forGenerator(binding.generator());
var spawn = SanctuaryWorldGameTests.initialSpawn.pos();
int cx = spawn.getX() >> 4, cz = spawn.getZ() >> 4;
boolean fixed = Boolean.getBoolean("sanctuary.test.benchmark.fixedCoordinates");
if (fixed) { cx = 0; cz = 0; }
var report = new LinkedHashMap<String, Object>();
report.put("version", FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow()
.getMetadata().getVersion().getFriendlyString());
@@ -34,13 +36,25 @@ public final class WorldgenPerformanceGameTests {
var host = level.getChunkSource().getGenerator();
String profile = "players-" + capacity.players();
if (host instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator unified) {
report.put("generation", 13); report.put("initial_diameter", unified.initialDiameter());
report.put("generation", unified.rootGeneration()); report.put("initial_diameter", unified.initialDiameter());
profile = "unified-" + unified.initialDiameter();
}
report.put("spawn", List.of(spawn.getX(), spawn.getY(), spawn.getZ()));
report.put("java", System.getProperty("java.version"));
report.put("processors", Runtime.getRuntime().availableProcessors());
report.put("max_heap_bytes", Runtime.getRuntime().maxMemory());
report.put("fixed_coordinates", fixed);
report.put("chunk_origin", List.of(cx, cz));
report.put("process_id", ProcessHandle.current().pid());
report.put("heap_before_bytes", Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory());
report.put("fluid_scheduler_before", FluidTiming25.snapshot());
var alreadyLoaded = new ArrayList<List<Integer>>();
for (int x = -8; x <= 24; x++) for (int z = -8; z <= 8; z++) {
if (x > 8 && Math.abs(z) > 2) continue;
if (level.getChunkSource().getChunkNow(cx + x, cz + z) != null)
alreadyLoaded.add(List.of(cx + x, cz + z));
}
report.put("workload_full_chunks_already_loaded", alreadyLoaded);
List<Map<String, Object>> rings = new ArrayList<>();
List<Long> chunkTimes = new ArrayList<>();
int count = 0, land = 0;
@@ -86,7 +100,13 @@ public final class WorldgenPerformanceGameTests {
report.put("exploration_full_chunks", 80); report.put("exploration_steps", steps);
report.put("exploration_chunk_latency_ms", latency(chunkTimes));
report.put("regions_cached", PopulationHydrologyRuntime.cachedRegionCount(binding.random()));
report.put("heap_after_bytes", Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory());
report.put("memory_pools", java.lang.management.ManagementFactory.getMemoryPoolMXBeans().stream()
.map(pool -> Map.of("name", pool.getName(), "type", pool.getType().toString(),
"used_bytes", pool.getUsage().getUsed(), "peak_used_bytes", pool.getPeakUsage().getUsed())).toList());
report.put("fluid_scheduler_after", FluidTiming25.snapshot());
report.put("scope", "Fresh development world; 289 FULL chunks around natural spawn, then 80 eastward FULL chunks. "
+ (fixed ? "Fixed-coordinate mode uses chunk0,0 instead of spawn for equal cross-version footprints. " : "")
+ "Synchronous server requests include dependent generation up to FULL, not guaranteed ticking readiness, client GPU, network, FPS or player load. "
+ "World creation before the test is timed separately by Minecraft's startup log.");
helper.assertTrue(count == 289 && land > 100, "The benchmark must generate actual playable terrain");
@@ -0,0 +1,30 @@
package fr.koka.sanctuary.gametest.mixin;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import fr.koka.sanctuary.gametest.FluidTiming25;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.ticks.LevelTicks;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import java.util.function.BiConsumer;
@Mixin(ServerLevel.class)
public abstract class FluidTiming25Mixin {
@WrapOperation(method = "tick", at = @At(value = "INVOKE", target =
"Lnet/minecraft/world/ticks/LevelTicks;tick(JILjava/util/function/BiConsumer;)V"))
private <T> void sanctuary$measureFluidScheduler(LevelTicks<T> ticks, long time, int limit,
BiConsumer<BlockPos, T> consumer, Operation<Void> original) {
var level = (ServerLevel) (Object) this;
if (ticks != level.getFluidTicks() || !level.dimension().equals(Level.OVERWORLD)) {
original.call(ticks, time, limit, consumer); return;
}
long[] count = {0};
BiConsumer<BlockPos, T> measured = (pos, fluid) -> { count[0]++; consumer.accept(pos, fluid); };
long start = System.nanoTime();
try { original.call(ticks, time, limit, measured); }
finally { FluidTiming25.record(System.nanoTime() - start, count[0], ticks.count()); }
}
}
@@ -21,7 +21,8 @@ public abstract class SanctuaryGameTestServerMixin {
// generation test's deadline before its IO/workers can complete. This is test-only pacing.
@Inject(method = "waitUntilNextTick", at = @At("RETURN"))
private void sanctuary$paceExpansionWorkers(CallbackInfo ci) {
if (Boolean.getBoolean("sanctuary.test.ruins17") || Boolean.getBoolean("sanctuary.test.expansion") || Boolean.getBoolean("sanctuary.test.lostCity")
if (Boolean.getBoolean("sanctuary.test.benchmark.fixedCoordinates") || Integer.getInteger("sanctuary.test.generation", 25) == 25 || Boolean.getBoolean("sanctuary.test.monument25") || Boolean.getBoolean("sanctuary.test.terrain25") || Boolean.getBoolean("sanctuary.test.underground25") || Boolean.getBoolean("sanctuary.test.hydrology25") || Boolean.getBoolean("sanctuary.test.aerial24.flowAdmission") || Boolean.getBoolean("sanctuary.test.aerial24") || Boolean.getBoolean("sanctuary.test.admission23") || Boolean.getBoolean("sanctuary.test.ocean23") || Boolean.getBoolean("sanctuary.test.secrets23") || Boolean.getBoolean("sanctuary.test.native23") || Boolean.getBoolean("sanctuary.test.selective22") || (Boolean.getBoolean("sanctuary.test.heritage20") || Boolean.getBoolean("sanctuary.test.heritage21") || Boolean.getBoolean("sanctuary.test.lostCity21") || Boolean.getBoolean("sanctuary.test.transit21")) || Boolean.getBoolean("sanctuary.test.lostCity20")
|| Boolean.getBoolean("sanctuary.test.heritage19") || Boolean.getBoolean("sanctuary.test.ruins17") || Boolean.getBoolean("sanctuary.test.expansion") || Boolean.getBoolean("sanctuary.test.lostCity")
|| Boolean.getBoolean("sanctuary.test.lostCity16") || Boolean.getBoolean("sanctuary.test.lostCity15") || Boolean.getBoolean("sanctuary.test.voidReuse")) {
long until = System.nanoTime() + 20_000_000L;
long remaining;
@@ -39,18 +40,18 @@ public abstract class SanctuaryGameTestServerMixin {
@ModifyArg(method = "<clinit>", at = @At(value = "INVOKE", target =
"Lnet/minecraft/world/level/levelgen/WorldOptions;<init>(JZZ)V"), index = 1)
private static boolean sanctuary$enableStructureGeneration(boolean original) {
return true;
return Boolean.parseBoolean(System.getProperty("sanctuary.test.generateStructures", "true"));
}
@Redirect(method = "lambda$create$1", at = @At(value = "FIELD", target =
"Lnet/minecraft/world/level/levelgen/presets/WorldPresets;FLAT_ALL_DIMENSIONS:Lnet/minecraft/resources/ResourceKey;"))
private static ResourceKey<WorldPreset> sanctuary$selectActualPreset() {
if (Boolean.getBoolean("sanctuary.test.unified")) {
int generation = Integer.getInteger("sanctuary.test.generation", 17);
if (generation != 13 && generation != 14 && generation != 15 && generation != 16 && generation != 17) throw new IllegalArgumentException("Unsupported test generation");
return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(generation == 17 ? "sanctuary" : "sanctuary_v" + generation));
int generation = Integer.getInteger("sanctuary.test.generation", 25);
if (generation != 13 && generation != 14 && generation != 15 && generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22 && generation != 23 && generation != 24 && generation != 25) throw new IllegalArgumentException("Unsupported test generation");
return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(generation == 25 ? "sanctuary" : "sanctuary_v" + generation));
}
if (Boolean.getBoolean("sanctuary.test.expansion")) return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("expansion_lab"));
if (Boolean.getBoolean("sanctuary.test.terrain25") || Boolean.getBoolean("sanctuary.test.underground25") || Boolean.getBoolean("sanctuary.test.monument25") || Boolean.getBoolean("sanctuary.test.expansion")) return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("expansion_lab"));
int players = Integer.getInteger("sanctuary.test.players", 10);
String preset = switch (players) {
case 5 -> "sanctuary_5";
@@ -6,7 +6,7 @@
"environment": "*",
"license": "GPL-3.0-or-later",
"entrypoints": {
"fabric-gametest": ["${gametest_entrypoint}"],
"fabric-gametest": ["${gametest_entrypoint}", "fr.koka.sanctuary.gametest.BlockKnowledgeGameTests", "fr.koka.sanctuary.gametest.CensusGameTestsTerrain", "fr.koka.sanctuary.gametest.CensusStockGameTests", "fr.koka.sanctuary.gametest.MaterialActivityGameTests"],
"fabric-client-gametest": ["fr.koka.sanctuary.gametest.SanctuaryClientRenderTests"]
},
"mixins": ["sanctuary-gametest.mixins.json"],
@@ -2,6 +2,11 @@
"required": true,
"package": "fr.koka.sanctuary.gametest.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": ["SanctuaryGameTestServerMixin"],
"injectors": {"defaultRequire": 1}
"mixins": [
"SanctuaryGameTestServerMixin",
"FluidTiming25Mixin"
],
"injectors": {
"defaultRequire": 1
}
}
@@ -43,11 +43,17 @@ public final class SanctuaryMod implements ModInitializer {
@Override
public void onInitialize() {
fr.koka.sanctuary.knowledge.census.WorldCensusService.register();
fr.koka.sanctuary.knowledge.structures.StructureCensusService.register();
fr.koka.sanctuary.knowledge.history.MaterialActivityHistory.register();
fr.koka.sanctuary.knowledge.BlockKnowledgeService.register();
fr.koka.sanctuary.knowledge.BlockKnowledgeNetworking.register();
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("expansion_lab"), fr.koka.sanctuary.expansion.ExpansionChunkGenerator.CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("expansion_lab"), fr.koka.sanctuary.expansion.ExpansionBiomeSource.CODEC);
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("expansion_island"), fr.koka.sanctuary.expansion.ExpansionDensity.CODEC);
fr.koka.sanctuary.expansion.ExpansionRuntime.register();
fr.koka.sanctuary.expansion.ExpansionCommands.register();
fr.koka.sanctuary.worldgen.heritage.HeritageCommands19.register();
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("main_island"), MainIslandDensity.CODEC);
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v13"), SanctuaryChunkGenerator.CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v13"), SanctuaryBiomeSource.CODEC);
@@ -60,6 +66,29 @@ public final class SanctuaryMod implements ModInitializer {
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v17"), SanctuaryChunkGenerator.RUINS17_CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v17"), SanctuaryBiomeSource.RUINS17_CODEC);
fr.koka.sanctuary.worldgen.city.LostCityStructures.register();
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v19"), SanctuaryChunkGenerator.HERITAGE19_CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v19"), SanctuaryBiomeSource.HERITAGE19_CODEC);
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v20"), SanctuaryChunkGenerator.ARCHITECTURE20_CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v20"), SanctuaryBiomeSource.ARCHITECTURE20_CODEC);
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v21"), SanctuaryChunkGenerator.NETWORK21_CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v21"), SanctuaryBiomeSource.NETWORK21_CODEC);
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v22"), SanctuaryChunkGenerator.SELECTIVE22_CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v22"), SanctuaryBiomeSource.SELECTIVE22_CODEC);
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v23"), SanctuaryChunkGenerator.SECRETS23_CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v23"), SanctuaryBiomeSource.SECRETS23_CODEC);
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v24"), SanctuaryChunkGenerator.AERIAL24_CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v24"), SanctuaryBiomeSource.AERIAL24_CODEC);
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v25"), SanctuaryChunkGenerator.TERRAIN25_CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v25"), SanctuaryBiomeSource.TERRAIN25_CODEC);
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("terrain25"), fr.koka.sanctuary.worldgen.Terrain25Density.CODEC);
fr.koka.sanctuary.worldgen.underground25.UndergroundStructures25.register();
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("terrain25_groves"), fr.koka.sanctuary.worldgen.Terrain25GrovesFeature.CODEC);
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("terrain25_refuge"), fr.koka.sanctuary.worldgen.Terrain25RefugeFeature.CODEC);
fr.koka.sanctuary.worldgen.aerial.AerialStructures24.register();
fr.koka.sanctuary.worldgen.mineral.MineralSanctuaryStructures23.register();
fr.koka.sanctuary.worldgen.transit.TransitStructures21.register();
fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.register();
fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.register();
fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.register();
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("population_island"), PopulationIslandDensity.CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("population_island"), PopulationIslandBiomeSource.CODEC);
@@ -0,0 +1,366 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.knowledge.BlockKnowledge;
import fr.koka.sanctuary.knowledge.BlockKnowledgePayloads;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.CycleButton;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.MultiLineTextWidget;
import net.minecraft.client.gui.components.ObjectSelectionList;
import net.minecraft.client.gui.components.ScrollableLayout;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.CommonComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Block;
/** A personal server snapshot, with independent history facets and current carried stock. */
public final class SanctuaryBlocodexScreen extends Screen {
private static final AtomicInteger REQUEST_IDS = new AtomicInteger();
private static final int ROW_HEIGHT = 44;
private static final int LIST_TOP = 98;
private static final int TEXT = 0xFFE6E6E6;
private static final int MUTED = 0xFFAAAAAA;
private List<Row> catalogue = List.of();
private List<Row> filtered = List.of();
private final List<BlockKnowledge> incoming = new ArrayList<>();
private final Set<String> incomingIds = new HashSet<>();
private EntryList list;
private EditBox search;
private Button previous;
private Button next;
private Button refresh;
private Filter filter = Filter.ALL;
private String query = "";
private int page;
private int pageSize = 1;
private int activeRequest;
private int nextPart;
private int expectedParts;
private int expectedTotal;
private int ticks;
private int lastRequestTick = -120;
private boolean pending;
private boolean loaded;
private boolean available = true;
public SanctuaryBlocodexScreen() {
super(label("title"));
}
@Override
protected void init() {
int margin = 12;
search = addRenderableWidget(new EditBox(font, margin, 44, Math.max(80, width - 126), 20, label("search")));
search.setMaxLength(128);
search.setHint(label("search"));
search.setValue(query);
search.setResponder(value -> {
query = value;
page = 0;
filterRows();
});
refresh = addRenderableWidget(Button.builder(label("refresh"), button -> request())
.bounds(width - 106, 44, 94, 20).build());
addRenderableWidget(CycleButton.builder(Filter::label, filter).withValues(Filter.values())
.create(margin, 70, Math.max(140, width - 126), 20, label("filter"), (button, value) -> {
filter = value;
page = 0;
filterRows();
}));
addRenderableWidget(Button.builder(label("help"), button -> minecraft.gui.setScreen(new HelpScreen(this)))
.bounds(width - 106, 70, 94, 20).tooltip(Tooltip.create(label("help_text"))).build());
int listHeight = Math.max(ROW_HEIGHT + 8, height - LIST_TOP - 42);
pageSize = Math.max(1, (listHeight - 8) / ROW_HEIGHT);
list = addRenderableWidget(new EntryList(width - 24, listHeight, LIST_TOP));
list.setX(margin);
previous = addRenderableWidget(Button.builder(Component.literal("<"), button -> turnPage(-1))
.bounds(margin, height - 30, 26, 20).tooltip(Tooltip.create(label("previous"))).build());
next = addRenderableWidget(Button.builder(Component.literal(">"), button -> turnPage(1))
.bounds(margin + 32, height - 30, 26, 20).tooltip(Tooltip.create(label("next"))).build());
addRenderableWidget(Button.builder(CommonComponents.GUI_DONE, button -> onClose())
.bounds(width - 92, height - 30, 80, 20).build());
filterRows();
setInitialFocus(search);
if (!loaded && !pending) request();
}
@Override
public void tick() {
ticks++;
if (pending && ticks - lastRequestTick >= 120) fail();
if (!pending && ticks - lastRequestTick >= 40) request();
refresh.active = !pending && ticks - lastRequestTick >= 22;
}
private void request() {
if (pending || ticks - lastRequestTick < 22) return;
lastRequestTick = ticks;
if (minecraft.player == null || minecraft.getConnection() == null
|| !ClientPlayNetworking.canSend(BlockKnowledgePayloads.Request.TYPE)) {
fail();
return;
}
activeRequest = REQUEST_IDS.incrementAndGet();
nextPart = 0;
expectedParts = -1;
expectedTotal = -1;
incoming.clear();
incomingIds.clear();
pending = true;
refresh.active = false;
ClientPlayNetworking.send(new BlockKnowledgePayloads.Request(activeRequest));
}
/** Called on the client thread; nothing becomes visible until every fragment is validated. */
public void accept(BlockKnowledgePayloads.SnapshotPart part) {
if (!pending || part.requestId() != activeRequest) return;
if (!part.healthy() || part.partIndex() != nextPart
|| (expectedParts != -1 && (part.partCount() != expectedParts || part.totalEntries() != expectedTotal))) {
fail();
return;
}
expectedParts = part.partCount();
expectedTotal = part.totalEntries();
for (BlockKnowledge knowledge : part.entries()) {
if (!incomingIds.add(knowledge.blockId())) {
fail();
return;
}
incoming.add(knowledge);
}
nextPart++;
if (nextPart != expectedParts) return;
if (incoming.size() != expectedTotal) {
fail();
return;
}
catalogue = incoming.stream().map(Row::new)
.sorted(Comparator.comparing(Row::name, String.CASE_INSENSITIVE_ORDER).thenComparing(row -> row.knowledge().blockId()))
.toList();
incoming.clear();
incomingIds.clear();
loaded = true;
available = true;
pending = false;
filterRows();
}
private void fail() {
pending = false;
available = false;
loaded = false;
catalogue = List.of();
incoming.clear();
incomingIds.clear();
filterRows();
}
private void filterRows() {
String normalized = query.strip().toLowerCase(Locale.ROOT);
filtered = catalogue.stream().filter(row -> filter.matches(row.knowledge()))
.filter(row -> normalized.isEmpty() || row.name().toLowerCase(Locale.ROOT).contains(normalized)
|| row.knowledge().blockId().toLowerCase(Locale.ROOT).contains(normalized)).toList();
showPage();
}
private void showPage() {
if (list == null) return;
page = Math.max(0, Math.min(page, pageCount() - 1));
int from = page * pageSize;
String selected = list.getSelected() == null ? null : list.getSelected().row.knowledge().blockId();
List<Entry> entries = filtered.subList(Math.min(from, filtered.size()), Math.min(from + pageSize, filtered.size()))
.stream().map(Entry::new).toList();
list.replaceEntries(entries);
list.setSelected(entries.stream().filter(entry -> entry.row.knowledge().blockId().equals(selected)).findFirst().orElse(null));
list.setScrollAmount(0);
previous.active = page > 0;
next.active = page + 1 < pageCount();
}
private int pageCount() { return Math.max(1, (filtered.size() + pageSize - 1) / pageSize); }
private void turnPage(int delta) {
page += delta;
showPage();
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double horizontal, double vertical) {
if (list.isMouseOver(mouseX, mouseY) && vertical != 0) {
turnPage(vertical < 0 ? 1 : -1);
return true;
}
return super.mouseScrolled(mouseX, mouseY, horizontal, vertical);
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
graphics.centeredText(font, title, width / 2, 12, TEXT);
Component summary = !available ? label("unavailable") : !loaded ? label("loading")
: label("summary", catalogue.stream().filter(row -> row.knowledge().known()).count(), catalogue.size(), filtered.size());
graphics.centeredText(font, font.plainSubstrByWidth(summary.getString(), width - 20), width / 2, 28, MUTED);
graphics.centeredText(font, label("page", page + 1, pageCount()), width / 2, height - 24, MUTED);
if (loaded && filtered.isEmpty()) graphics.centeredText(font, label("empty"), width / 2, LIST_TOP + 18, MUTED);
if (!available) graphics.centeredText(font, label("unavailable_hint"), width / 2, LIST_TOP + 18, MUTED);
}
@Override
public boolean isPauseScreen() { return false; }
@Override
public void removed() {
pending = false;
incoming.clear();
incomingIds.clear();
}
private static Component label(String key, Object... arguments) {
return Component.translatable("sanctuary.blocodex." + key, arguments);
}
private static Component yesNo(boolean value) { return label(value ? "yes" : "no"); }
private static String compact(long value) {
if (value < 10_000) return Long.toString(value);
if (value < 1_000_000) return (value / 1_000) + "k";
if (value < 1_000_000_000) return (value / 1_000_000) + "M";
if (value < 1_000_000_000_000L) return (value / 1_000_000_000) + "G";
return (value / 1_000_000_000_000L) + "T";
}
private record Row(BlockKnowledge knowledge, String name, ItemStack icon) {
Row(BlockKnowledge knowledge) {
this(knowledge, block(knowledge).map(value -> value.getName().getString()).orElse(knowledge.blockId()),
block(knowledge).map(value -> new ItemStack(value.asItem())).orElse(ItemStack.EMPTY));
}
private static Optional<Block> block(BlockKnowledge knowledge) {
return BuiltInRegistries.BLOCK.getOptional(Identifier.parse(knowledge.blockId()));
}
}
private final class EntryList extends ObjectSelectionList<Entry> {
EntryList(int listWidth, int listHeight, int top) {
super(SanctuaryBlocodexScreen.this.minecraft, listWidth, listHeight, top, ROW_HEIGHT);
centerListVertically = false;
}
@Override
public int getRowWidth() { return getWidth() - 12; }
}
private final class Entry extends ObjectSelectionList.Entry<Entry> {
private final Row row;
Entry(Row row) { this.row = row; }
@Override
public Component getNarration() {
BlockKnowledge knowledge = row.knowledge();
return label("narration", row.name(), label(knowledge.known() ? "known" : "unknown"),
yesNo(knowledge.seen()), knowledge.mined(), yesNo(knowledge.possessed()),
knowledge.dropped(), knowledge.placed(), knowledge.carried());
}
@Override
public void extractContent(GuiGraphicsExtractor graphics, int mouseX, int mouseY, boolean hovered, float partialTick) {
BlockKnowledge knowledge = row.knowledge();
int x = getContentX();
int y = getContentY();
int contentWidth = getContentWidth();
int color = knowledge.known() ? TEXT : MUTED;
if (!row.icon().isEmpty()) graphics.item(row.icon(), x, y);
Component state = label(knowledge.known() ? "known" : "unknown");
int stateWidth = font.width(state);
graphics.text(font, font.plainSubstrByWidth(row.name(), Math.max(24, contentWidth - stateWidth - 30)), x + 22, y + 3, color);
graphics.text(font, state, x + contentWidth - stateWidth, y + 3, knowledge.known() ? 0xFF91CF9B : MUTED);
int cellWidth = contentWidth / 3;
drawCell(graphics, label("seen", yesNo(knowledge.seen())), x, y + 18, cellWidth, color);
drawCell(graphics, label("mined", compact(knowledge.mined())), x + cellWidth, y + 18, cellWidth, color);
drawCell(graphics, label("possessed", yesNo(knowledge.possessed())), x + cellWidth * 2, y + 18, cellWidth, color);
drawCell(graphics, label("dropped", compact(knowledge.dropped())), x, y + 29, cellWidth, color);
drawCell(graphics, label("placed", compact(knowledge.placed())), x + cellWidth, y + 29, cellWidth, color);
drawCell(graphics, label("carried", compact(knowledge.carried())), x + cellWidth * 2, y + 29, cellWidth, color);
if (hovered) graphics.setComponentTooltipForNextFrame(font, List.of(Component.literal(row.name()),
Component.literal(knowledge.blockId()), label("seen", yesNo(knowledge.seen())),
label("mined", knowledge.mined()), label("possessed", yesNo(knowledge.possessed())),
label("dropped", knowledge.dropped()), label("placed", knowledge.placed()),
label("carried", knowledge.carried())), mouseX, mouseY);
}
private void drawCell(GuiGraphicsExtractor graphics, Component text, int x, int y, int cellWidth, int color) {
graphics.text(font, font.plainSubstrByWidth(text.getString(), cellWidth - 3), x, y, color);
}
}
private enum Filter {
ALL, UNKNOWN, KNOWN, SEEN, MINED, POSSESSED, DROPPED, PLACED, CARRIED;
Component label() { return SanctuaryBlocodexScreen.label("filter." + name().toLowerCase(Locale.ROOT)); }
boolean matches(BlockKnowledge knowledge) {
return switch (this) {
case ALL -> true;
case UNKNOWN -> !knowledge.known();
case KNOWN -> knowledge.known();
case SEEN -> knowledge.seen();
case MINED -> knowledge.mined() > 0;
case POSSESSED -> knowledge.possessed();
case DROPPED -> knowledge.dropped() > 0;
case PLACED -> knowledge.placed() > 0;
case CARRIED -> knowledge.carried() > 0;
};
}
}
private static final class HelpScreen extends Screen {
private final Screen parent;
HelpScreen(Screen parent) {
super(label("help"));
this.parent = parent;
}
@Override
protected void init() {
LinearLayout contents = LinearLayout.vertical();
contents.addChild(new MultiLineTextWidget(label("help_text"), font).setMaxWidth(Math.min(450, width - 48)));
ScrollableLayout scroll = new ScrollableLayout(minecraft, contents, Math.max(40, height - 80));
scroll.arrangeElements();
scroll.setX((width - scroll.getWidth()) / 2);
scroll.setY(38);
scroll.visitWidgets(this::addRenderableWidget);
addRenderableWidget(Button.builder(CommonComponents.GUI_BACK, button -> onClose())
.bounds(width / 2 - 75, height - 30, 150, 20).build());
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
graphics.centeredText(font, title, width / 2, 14, TEXT);
}
@Override
public void onClose() { minecraft.gui.setScreen(parent); }
@Override
public boolean isPauseScreen() { return false; }
}
}
@@ -0,0 +1,35 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.knowledge.BlockKnowledgePayloads;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.KeyMapping;
/** Innate access: no recipe, item, operator permission or external mod. */
public final class SanctuaryKnowledgeClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
registerClient();
}
public static void registerClient() {
KeyMapping open = KeyMappingHelper.registerKeyMapping(new KeyMapping("sanctuary.blocodex.key",
InputConstants.Type.KEYBOARD, InputConstants.KEY_B,
KeyMapping.Category.register(SanctuaryMod.id("sanctuary"))));
ClientPlayNetworking.registerGlobalReceiver(BlockKnowledgePayloads.SnapshotPart.TYPE, (part, context) ->
context.client().execute(() -> {
if (context.client().gui.screen() instanceof SanctuaryBlocodexScreen screen) screen.accept(part);
}));
ClientTickEvents.END_CLIENT_TICK.register(client -> {
while (open.consumeClick()) {
if (client.player != null && client.gui.screen() == null) {
client.gui.setScreen(new SanctuaryBlocodexScreen());
}
}
});
}
}
@@ -51,11 +51,11 @@ public final class SanctuaryWorldOptionsScreen extends Screen {
contents.addChild(sizeButton);
diameterHint = contents.addChild(new MultiLineTextWidget(diameterHint(), font)
.setMaxWidth(310).setCentered(true));
if (generator.rootGeneration() == 17) {
if (generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24 || generator.rootGeneration() == 25)) {
contents.addChild(CycleButton.builder((Boolean enabled) -> enabled ? CommonComponents.OPTION_ON : CommonComponents.OPTION_OFF, experimental)
.withValues(true, false).create(0, 0, 280, 20, Component.translatable("sanctuary.world_options.experimental"),
(button, value) -> experimental = value));
contents.addChild(new MultiLineTextWidget(Component.translatable("sanctuary.world_options.experimental_hint"), font)
contents.addChild(new MultiLineTextWidget(Component.translatable(generator.rootGeneration() == 25 ? "sanctuary.world_options.terrain25_hint" : generator.rootGeneration() == 24 ? "sanctuary.world_options.aerial24_hint" : generator.rootGeneration() == 23 ? "sanctuary.world_options.secrets23_hint" : generator.rootGeneration() == 22 ? "sanctuary.world_options.heritage22_hint" : (generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24 || generator.rootGeneration() == 25)) ? "sanctuary.world_options.heritage_hint" : "sanctuary.world_options.experimental_hint"), font)
.setMaxWidth(310).setCentered(true));
}
layout.addToContents(contents);
@@ -78,6 +78,12 @@ public class ExpansionBiomeSource extends BiomeSource {
protected Holder<Biome> resolve(List<ExpansionIsland> snapshot, int x, int y, int z) {
ExpansionIsland island = islandAt(snapshot, x, z);
if (island == null || y < 0 || y >= 384) return biome("the_void");
if (ExpansionOcean23.active(island)) {
var ocean = ExpansionOcean23.column(island, x, z);
if (ocean.water() && y >= ocean.floorY() - 8) return biome(ExpansionOcean23.biome(island, ocean));
if (!ocean.water() && ocean.influence() > 0 && y >= ocean.floorY() - 8)
return biome(ExpansionOcean23.shoreBiome(island));
}
double scale = Math.clamp(island.diameter() / 3.0, 24.0, 144.0);
double localX = (x - island.centerX()) / scale, localZ = (z - island.centerZ()) / scale;
double variation = noise(island.seed(), localX, localZ);
@@ -97,7 +97,10 @@ public class ExpansionChunkGenerator extends ChunkGenerator {
return new Context(delegate, random);
});
}
protected void afterTerrain(ExpansionIsland island, ChunkAccess chunk) { ExpansionTerrain.apply(island, chunk); }
protected void afterTerrain(ExpansionIsland island, ChunkAccess chunk) {
if (ExpansionOcean23.active(island)) ExpansionOcean23.apply(island, chunk);
else ExpansionTerrain.apply(island, chunk);
}
public String journalDirectory() { return "sanctuary-expansion-v1"; }
public int rootGeneration() { return ExpansionIsland.GENERATION; }
public RandomState randomState(ExpansionIsland island) { return context(island).random(); }
@@ -122,6 +125,12 @@ public class ExpansionChunkGenerator extends ChunkGenerator {
ChunkAccess chunk, StructureTemplateManager templates, ResourceKey<Level> dimension) {
fr.koka.sanctuary.worldgen.city.LostCityStructures.generate(this, registries, state, structures, chunk, templates, dimension);
fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.generate(this, registries, state, structures, chunk, templates, dimension);
fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.generate(this, registries, state, structures, chunk, templates, dimension);
fr.koka.sanctuary.worldgen.transit.TransitStructures21.generate(this, registries, state, structures, chunk, templates, dimension);
fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.generate(this, registries, state, structures, chunk, templates, dimension);
fr.koka.sanctuary.worldgen.mineral.MineralSanctuaryStructures23.generate(this, registries, state, structures, chunk, templates, dimension);
fr.koka.sanctuary.worldgen.aerial.AerialStructures24.generate(this, registries, state, structures, chunk, templates, dimension);
fr.koka.sanctuary.worldgen.underground25.UndergroundStructures25.generate(this, registries, state, structures, chunk, templates, dimension);
ExpansionStructures.generate(this, registries, state, structures, chunk, templates, dimension);
}
@Override public void spawnOriginalMobs(WorldGenRegion region) {
@@ -131,11 +140,32 @@ public class ExpansionChunkGenerator extends ChunkGenerator {
}
@Override public int getGenDepth() { return 384; }
@Override public int getMinY() { return 0; }
@Override public int getSeaLevel() { return -64; }
@Override public int getSeaLevel() {
Integer level = NativeStructurePlacement23.seaLevel();
if (level == null) level = ExpansionOcean23.decorationSeaLevel();
return level == null ? -64 : level;
}
@Override public void applyBiomeDecoration(net.minecraft.world.level.WorldGenLevel level, ChunkAccess chunk, StructureManager structures) {
var island = islandAt(chunk.getPos().getMiddleBlockX(), chunk.getPos().getMiddleBlockZ());
if (!ExpansionOcean23.active(island)) {
super.applyBiomeDecoration(level, chunk, structures);
if (rootGeneration() == 25 && island != null && island.origin())
fr.koka.sanctuary.worldgen.underground25.UndergroundStructures25.decorate(level, chunk);
return;
}
Integer previous = ExpansionOcean23.enterDecoration(ExpansionOcean23.seaLevel(island));
try { super.applyBiomeDecoration(level, chunk, structures); }
finally { ExpansionOcean23.restoreDecoration(previous); }
}
@Override public int getSpawnHeight(LevelHeightAccessor level) { return 220; }
@Override public int getBaseHeight(int x, int z, Heightmap.Types type, LevelHeightAccessor level, RandomState ignored) {
var island = islandAt(x, z);
if (island == null) return getMinY();
if (ExpansionOcean23.active(island)) {
var ocean = ExpansionOcean23.column(island, x, z);
if (ocean.water()) return (type == Heightmap.Types.OCEAN_FLOOR || type == Heightmap.Types.OCEAN_FLOOR_WG
? ocean.floorY() : ocean.waterLevel()) + 1;
}
var context = context(island);
return context.generator().getBaseHeight(x, z, type, level, context.random());
}
@@ -143,7 +173,17 @@ public class ExpansionChunkGenerator extends ChunkGenerator {
var island = islandAt(x, z);
if (island != null) {
var context = context(island);
return context.generator().getBaseColumn(x, z, level, context.random());
var column = context.generator().getBaseColumn(x, z, level, context.random());
if (ExpansionOcean23.active(island)) {
var ocean = ExpansionOcean23.column(island, x, z);
if (ocean.water()) {
for (int y = ocean.bottomY(); y <= ocean.floorY(); y++)
column.setBlock(y, Blocks.STONE.defaultBlockState());
for (int y = ocean.floorY() + 1; y <= ocean.waterLevel(); y++)
column.setBlock(y, Blocks.WATER.defaultBlockState());
}
}
return column;
}
BlockState[] air = new BlockState[384];
java.util.Arrays.fill(air, Blocks.AIR.defaultBlockState());
@@ -48,15 +48,15 @@ public final class ExpansionCommands {
.then(createTree("create", false)).then(createTree("preview", true))
.then(Commands.literal("quick").then(Commands.argument("direction", StringArgumentType.word())
.suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.DIRECTIONS, b))
.executes(c -> quick(c, null, 0, "natural"))
.executes(c -> quick(c, null, 0, null))
.then(Commands.argument("climate", StringArgumentType.word())
.suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.CLIMATES, b))
.executes(c -> quick(c, word(c, "climate"), 0, "natural"))
.executes(c -> quick(c, word(c, "climate"), 0, null))
.then(Commands.argument("diameter", IntegerArgumentType.integer(64, 1024))
.suggests((c, b) -> SharedSuggestionProvider.suggest(List.of("64", "128", "256", "512", "1024"), b))
.executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"), "natural"))
.executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"), null))
.then(Commands.argument("relief", StringArgumentType.word())
.suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.RELIEFS, b))
.suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.reliefs(c.getSource().getLevel().getChunkSource().getGenerator() instanceof ExpansionChunkGenerator g ? g.rootGeneration() : 12), b))
.executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief")))
.then(distanceArgument().executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"),
word(c, "relief"), IntegerArgumentType.getInteger(c, "distance")))))))));
@@ -80,7 +80,7 @@ public final class ExpansionCommands {
.then(Commands.argument("climate", StringArgumentType.word()).suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.CLIMATES, b))
.then(Commands.argument("diameter", IntegerArgumentType.integer(64, 1024))
.suggests((c, b) -> SharedSuggestionProvider.suggest(List.of("64", "128", "256", "512", "1024"), b))
.then(Commands.argument("relief", StringArgumentType.word()).suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.RELIEFS, b))
.then(Commands.argument("relief", StringArgumentType.word()).suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.reliefs(c.getSource().getLevel().getChunkSource().getGenerator() instanceof ExpansionChunkGenerator g ? g.rootGeneration() : 12), b))
.executes(c -> create(c, preview, 0))
.then(distanceArgument().executes(c -> create(c, preview, IntegerArgumentType.getInteger(c, "distance"))))))))));
}
@@ -116,8 +116,10 @@ public final class ExpansionCommands {
String id = "island_" + next;
int chosenDiameter = diameter != 0 ? diameter : ExpansionIsland.spatialGeneration(session.islands().getFirst().generation())
? ExpansionRandomSize.choose(c.getSource().getLevel().getSeed(), id) : 128;
var island = session.create(id, "sanctuary", direction,
climate == null ? ExpansionIsland.directionalClimate(direction) : climate, chosenDiameter, relief, minimumDistance);
String chosenClimate = climate == null ? ExpansionIsland.directionalClimate(direction) : climate;
String chosenRelief = relief == null ? ExpansionOcean23.quickRelief(c.getSource().getLevel().getSeed(), id,
chosenDiameter, chosenClimate, session.islands().getFirst().generation()) : relief;
var island = session.create(id, "sanctuary", direction, chosenClimate, chosenDiameter, chosenRelief, minimumDistance);
describe(c, island, false);
return Command.SINGLE_SUCCESS;
});
@@ -37,7 +37,9 @@ public record ExpansionDensity(DensityFunction source, ExpansionIsland island) i
// Fractional Y elsewhere is interpolated, avoiding one-block terraces from rounding.
float density = fraction == 0.0 ? low : (float) (low
+ (original.sampleValue(sample, sx, lowerY + 1, sz) - low) * fraction);
return ExpansionRelief.sculpt(island, column, y, Math.clamp(density, -1.0F, 1.0F));
float shaped = ExpansionRelief.sculpt(island, column, y, Math.clamp(density, -1.0F, 1.0F));
return ExpansionOcean23.active(island)
? ExpansionOcean23.density(COLUMNS.get().ocean(island, x, z), y, shaped) : shaped;
}
@Override public void sampleVolume(SamplerContext sample, DensityBuffer buffer, DensityVolume volume) {
@@ -58,6 +60,7 @@ public record ExpansionDensity(DensityFunction source, ExpansionIsland island) i
private ExpansionIsland selected;
private final int[] xs = new int[SIZE], zs = new int[SIZE];
private final ExpansionRelief.Column[] columns = new ExpansionRelief.Column[SIZE];
private final ExpansionOcean23.Column[] oceans = new ExpansionOcean23.Column[SIZE];
ExpansionRelief.Column get(ExpansionIsland island, int x, int z) {
if (selected != island) {
@@ -68,9 +71,15 @@ public record ExpansionDensity(DensityFunction source, ExpansionIsland island) i
int slot = (hash ^ (hash >>> 16)) & (SIZE - 1);
if (columns[slot] == null || xs[slot] != x || zs[slot] != z) {
columns[slot] = ExpansionRelief.column(island, x, z);
oceans[slot] = ExpansionOcean23.active(island) ? ExpansionOcean23.column(island, x, z) : null;
xs[slot] = x; zs[slot] = z;
}
return columns[slot];
}
ExpansionOcean23.Column ocean(ExpansionIsland island, int x, int z) {
get(island, x, z);
int hash = x * 0x9e3779b9 ^ Integer.rotateLeft(z * 0x85ebca6b, 13);
return oceans[(hash ^ (hash >>> 16)) & (SIZE - 1)];
}
}
}
@@ -0,0 +1,74 @@
package fr.koka.sanctuary.expansion;
import fr.koka.sanctuary.worldgen.IslandCapacity;
import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.Terrain25Runtime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import net.minecraft.world.level.ChunkPos;
/** Initial river outlets reserve their future falling water before exploration; no chunks are requested. */
public final class ExpansionInitialFlows25 {
public static final int SPREAD = 7;
private static final Map<SanctuaryChunkGenerator, Set<Long>> CLAIMS = Collections.synchronizedMap(new WeakHashMap<>());
private ExpansionInitialFlows25() {}
public static Set<Long> prepare(SanctuaryChunkGenerator generator) {
if (generator.rootGeneration() != 25) return Set.of();
synchronized (CLAIMS) {
var existing = CLAIMS.get(generator);
if (existing != null) return existing;
var binding = generator.starterContext();
var capacity = PopulationHydrologyRuntime.capacity(binding.generator());
int limit = capacity.terrainLimit();
var first = capacity.regionAt(-limit, -limit); var last = capacity.regionAt(limit, limit);
var falls = new ArrayList<Terrain25Runtime.Fall>();
for (int x = first.x(); x <= last.x(); x++) for (int z = first.z(); z <= last.z(); z++) {
var region = new IslandCapacity.Region(x, z);
double nearestX = Math.max(0, Math.max(region.minX(), -region.maxX()));
double nearestZ = Math.max(0, Math.max(region.minZ(), -region.maxZ()));
if (Math.hypot(nearestX, nearestZ) < limit)
falls.addAll(Terrain25Runtime.falls(binding.generator(), binding.random(), region));
}
var result = fromFalls(falls);
CLAIMS.put(generator, result);
return result;
}
}
/** A column from Y0 to the recorded waterline occupies its chunk for expansion admission.
* Only actual channel cells receive the local seven-block fluid margin, never the full diagonal bounding box. */
public static Set<Long> fromFalls(List<Terrain25Runtime.Fall> falls) {
var chunks = new HashSet<Long>();
for (var fall : falls) {
if (fall.waterY() < 1 || fall.waterY() > 383 || fall.width() < 1 || fall.width() > 32
|| Math.abs((long) fall.endX() - fall.startX()) > 64 || Math.abs((long) fall.endZ() - fall.startZ()) > 64
|| fall.startX() == fall.endX() && fall.startZ() == fall.endZ())
throw new IllegalArgumentException("Invalid initial waterfall reservation");
for (int x = fall.minX(); x <= fall.maxX(); x++) for (int z = fall.minZ(); z <= fall.maxZ(); z++) {
if (!fall.channel(x, z)) continue;
for (int cx = (x - SPREAD) >> 4; cx <= (x + SPREAD) >> 4; cx++)
for (int cz = (z - SPREAD) >> 4; cz <= (z + SPREAD) >> 4; cz++) chunks.add(new ChunkPos(cx, cz).pack());
}
}
return Set.copyOf(chunks);
}
public static boolean conflicts(SanctuaryChunkGenerator generator, ExpansionIsland island) {
return generator.rootGeneration() == 25 && conflicts(prepare(generator), island);
}
public static boolean conflicts(Set<Long> claims, ExpansionIsland island) {
if (island.origin()) return false;
for (long key : claims) {
var chunk = ChunkPos.unpack(key);
if (island.ownsChunk(chunk.x(), chunk.z())) return true;
}
return false;
}
}
@@ -6,11 +6,13 @@ import java.util.List;
/** Immutable alpha.12 region contract. Directions place islands; climate remains an explicit choice. */
public record ExpansionIsland(String id, String parent, String direction, String climate,
int diameter, int centerX, int centerZ, long seed, String relief, String status, int generation, String reuseToken) {
int diameter, int centerX, int centerZ, long seed, String relief, String status, int generation, String reuseToken,
List<String> structurePriorities, java.util.Optional<ExpansionStructurePlan25> structurePlan25) {
public static final int GENERATION = 12;
public static final List<String> DIRECTIONS = List.of("north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest");
public static final List<String> CLIMATES = List.of("temperate", "boreal", "glacial", "cold_humid", "humid", "tropical", "warm", "arid", "dry", "cold_dry", "autumn", "cherry", "lost_city");
public static final List<String> RELIEFS = List.of("natural", "peaks", "canyon", "volcano");
public static final List<String> RELIEFS23 = List.of("natural", "peaks", "canyon", "volcano", "ocean");
public static final List<Integer> LEGACY_DIAMETERS = List.of(64, 128, 256, 512);
public static final List<Integer> DIAMETERS = List.of(64, 128, 256, 512, 1024);
public static final int DECORATION_MARGIN = 32;
@@ -27,23 +29,48 @@ public record ExpansionIsland(String id, String parent, String direction, String
Codec.STRING.fieldOf("relief").forGetter(ExpansionIsland::relief),
Codec.STRING.fieldOf("status").forGetter(ExpansionIsland::status),
Codec.INT.fieldOf("generation").forGetter(ExpansionIsland::generation),
Codec.STRING.optionalFieldOf("reuse_token", "").forGetter(ExpansionIsland::reuseToken)
Codec.STRING.optionalFieldOf("reuse_token", "").forGetter(ExpansionIsland::reuseToken),
Codec.STRING.listOf().optionalFieldOf("structure_priorities", List.of()).forGetter(ExpansionIsland::structurePriorities),
ExpansionStructurePlan25.CODEC.optionalFieldOf("structure_plan25").forGetter(ExpansionIsland::structurePlan25)
).apply(i, ExpansionIsland::new));
public ExpansionIsland(String id, String parent, String direction, String climate, int diameter,
int centerX, int centerZ, long seed, String relief, String status, int generation) {
this(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, status, generation, "");
this(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, status, generation, "", List.of());
}
public ExpansionIsland(String id, String parent, String direction, String climate, int diameter,
int centerX, int centerZ, long seed, String relief, String status, int generation, String reuseToken) {
this(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, status, generation, reuseToken, List.of());
}
public ExpansionIsland(String id, String parent, String direction, String climate, int diameter,
int centerX, int centerZ, long seed, String relief, String status, int generation, String reuseToken, List<String> structurePriorities) {
this(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, status, generation, reuseToken, structurePriorities, java.util.Optional.empty());
}
public ExpansionIsland {
if (!id.matches("[a-z][a-z0-9_]{0,31}") || !parent.matches("[a-z][a-z0-9_]{0,31}")) throw new IllegalArgumentException("Invalid island identifier");
if (!DIRECTIONS.contains(direction) || !CLIMATES.contains(climate) || !RELIEFS.contains(relief)) throw new IllegalArgumentException("Unknown island profile");
if (!DIRECTIONS.contains(direction) || !CLIMATES.contains(climate) || !reliefs(generation).contains(relief)) throw new IllegalArgumentException("Unknown island profile");
if (relief.equals("ocean") && ((generation != 23 && generation != 24 && generation != 25) || id.equals("sanctuary") || diameter != 512 && diameter != 1024))
throw new IllegalArgumentException("Ocean relief requires a generation23 expansion of diameter512 or1024");
if (!((spatialGeneration(generation) ? DIAMETERS : LEGACY_DIAMETERS).contains(diameter) || (id.equals("sanctuary") && (diameter == 724 || (generation == 13 || spatialGeneration(generation)) && diameter == 1024)))) throw new IllegalArgumentException("Unsupported nominal diameter");
if (Math.abs((long) centerX) > 100_000 || Math.abs((long) centerZ) > 100_000 || (centerX & 15) != 0 || (centerZ & 15) != 0) throw new IllegalArgumentException("Invalid island centre");
if (!List.of("reserved", "ready").contains(status) || (generation != GENERATION && !spatialGeneration(generation) && !(generation == 13 && id.equals("sanctuary")))) throw new IllegalArgumentException("Unsupported island state or generation");
if (climate.equals("lost_city") && (!spatialGeneration(generation) || id.equals("sanctuary"))) throw new IllegalArgumentException("Lost City requires a generation 14, 15, 16 or 17 expansion");
if (climate.equals("lost_city") && (!spatialGeneration(generation) || id.equals("sanctuary"))) throw new IllegalArgumentException("Lost City requires a generation 14, 15, 16, 17, 19, 20, 21 or 22 expansion");
if (reuseToken == null || !reuseToken.isEmpty() && (!spatialGeneration(generation) || id.equals("sanctuary") || !reuseToken.matches("[0-9a-f]{64}")))
throw new IllegalArgumentException("Invalid void reuse certificate token");
if (structurePriorities == null || structurePriorities.size() > 64
|| structurePriorities.stream().anyMatch(value -> value == null || !value.matches("[a-z0-9_./:-]{1,128}")))
throw new IllegalArgumentException("Invalid structure priority profile");
var priorities = new java.util.TreeSet<>(structurePriorities);
if (priorities.size() != structurePriorities.size()) throw new IllegalArgumentException("Duplicate structure priority family");
structurePriorities = List.copyOf(priorities);
if (!structurePriorities.isEmpty() && ((generation != 23 && generation != 24 && generation != 25) || id.equals("sanctuary")))
throw new IllegalArgumentException("Structure priorities are only available for generation23 expansions");
if (structurePlan25 == null || structurePlan25.isPresent() && (generation != 25 || id.equals("sanctuary") || !relief.equals("ocean")))
throw new IllegalArgumentException("Saved native structure plans require an ocean25 expansion");
if (structurePlan25.isPresent()) structurePlan25.get().validateFor(new ExpansionIsland(id, parent, direction, climate,
diameter, centerX, centerZ, seed, relief, status, generation, reuseToken, structurePriorities));
}
public static ExpansionIsland origin(int diameter, long seed) {
@@ -53,7 +80,8 @@ public record ExpansionIsland(String id, String parent, String direction, String
return new ExpansionIsland("sanctuary", "sanctuary", "north", "temperate", diameter, 0, 0, seed, "natural", "ready", generation);
}
public static boolean spatialGeneration(int generation) { return generation == 14 || generation == 15 || generation == 16 || generation == 17; }
public static boolean spatialGeneration(int generation) { return generation == 14 || generation == 15 || generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || (generation == 23 || generation == 24 || generation == 25); }
public static List<String> reliefs(int generation) { return (generation == 23 || generation == 24 || generation == 25) ? RELIEFS23 : RELIEFS; }
public boolean origin() { return id.equals("sanctuary"); }
public double referenceDiameter() { return origin() && diameter == 724 ? Math.sqrt(2) * 512 : 512; }
public double horizontalScale() { return origin() ? 1.0 : diameter / referenceDiameter(); }
@@ -71,8 +99,14 @@ public record ExpansionIsland(String id, String parent, String direction, String
return Math.abs((long) centerX - other.centerX) <= reservedRadius() + other.reservedRadius()
&& Math.abs((long) centerZ - other.centerZ) <= reservedRadius() + other.reservedRadius();
}
public ExpansionIsland ready() { return new ExpansionIsland(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, "ready", generation, reuseToken); }
public ExpansionIsland withReuseToken(String token) { return new ExpansionIsland(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, status, generation, token); }
public ExpansionIsland ready() { return new ExpansionIsland(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, "ready", generation, reuseToken, structurePriorities, structurePlan25); }
public ExpansionIsland withReuseToken(String token) { return new ExpansionIsland(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, status, generation, token, structurePriorities, structurePlan25); }
public ExpansionIsland withStructurePriorities(List<String> priorities) {
return new ExpansionIsland(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, status, generation, reuseToken, priorities, structurePlan25);
}
public ExpansionIsland withStructurePlan25(ExpansionStructurePlan25 plan) {
return new ExpansionIsland(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, status, generation, reuseToken, structurePriorities, java.util.Optional.of(plan));
}
public static String directionalClimate(String direction) {
return switch (direction) {
case "north" -> "boreal"; case "northeast" -> "cold_humid"; case "east" -> "humid";
@@ -10,6 +10,8 @@ import com.google.gson.JsonPrimitive;
import com.google.gson.Strictness;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import fr.koka.sanctuary.worldgen.aerial.AerialPlan24;
import fr.koka.sanctuary.worldgen.aerial.AerialStore24;
import java.io.IOException;
import java.io.StringReader;
import java.math.BigDecimal;
@@ -41,6 +43,8 @@ public final class ExpansionJournal {
private final int rootDiameter;
private final int rootGeneration;
private final DirectorySync directorySync;
private String aerialToken = "";
private AerialPlan24 aerialPlan24 = AerialPlan24.EMPTY;
private List<ExpansionIsland> islands;
private boolean failed;
@@ -52,7 +56,17 @@ public final class ExpansionJournal {
directorySyncFor(directory.getFileSystem().getSeparator(), ExpansionJournal::forceDirectory));
}
public ExpansionJournal(Path directory, long seed, int rootDiameter, int rootGeneration, String initialAerialToken) throws IOException {
this(directory, seed, rootDiameter, rootGeneration,
directorySyncFor(directory.getFileSystem().getSeparator(), ExpansionJournal::forceDirectory), initialAerialToken);
}
ExpansionJournal(Path directory, long seed, int rootDiameter, int rootGeneration, DirectorySync directorySync) throws IOException {
this(directory, seed, rootDiameter, rootGeneration, directorySync, "");
}
private ExpansionJournal(Path directory, long seed, int rootDiameter, int rootGeneration, DirectorySync directorySync,
String initialAerialToken) throws IOException {
if (rootGeneration != 12 && rootGeneration != 13 && !ExpansionIsland.spatialGeneration(rootGeneration)) throw new IOException("Unsupported root generation");
if (rootDiameter != 512 && rootDiameter != 724 && !((rootGeneration == 13 || ExpansionIsland.spatialGeneration(rootGeneration)) && rootDiameter == 1024))
throw new IOException("Unsupported expansion root diameter");
@@ -62,10 +76,13 @@ public final class ExpansionJournal {
this.file = this.directory.resolve(FILE_NAME);
this.seed = seed;
this.rootDiameter = rootDiameter;
if ((rootGeneration == 24 || rootGeneration == 25) && !initialAerialToken.isEmpty()) bindAerial(initialAerialToken);
else if ((rootGeneration != 24 && rootGeneration != 25) && !initialAerialToken.isEmpty()) throw new IOException("Historical journals cannot bind aerial sites");
if (Files.exists(this.directory, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isDirectory(this.directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Expansion journal path is not a directory");
this.islands = read();
} else {
if ((rootGeneration == 24 || rootGeneration == 25) && aerialToken.isEmpty()) throw new IOException("New generation24 journal requires its immutable aerial manifest");
// Creating the directory is the bootstrap marker. An interrupted bootstrap requires repair.
Files.createDirectories(this.directory.getParent());
Files.createDirectory(this.directory);
@@ -75,6 +92,8 @@ public final class ExpansionJournal {
}
public synchronized List<ExpansionIsland> islands() { return islands; }
public synchronized AerialPlan24 aerialPlan24() { return aerialPlan24; }
public synchronized String aerialToken() { return aerialToken; }
/** False after uncertain persistence or external damage; only a successful reload can recover. */
public synchronized boolean healthy() { return !failed; }
@@ -145,26 +164,33 @@ public final class ExpansionJournal {
private List<ExpansionIsland> read() throws IOException {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Existing expansion directory has no regular journal file");
if (Files.size(file) > MAX_BYTES) throw new IOException("Expansion journal exceeds its size limit");
if (Files.size(file) > (rootGeneration == 25 ? 8_388_608 : MAX_BYTES)) throw new IOException("Expansion journal exceeds its size limit");
try (JsonReader reader = new JsonReader(new StringReader(Files.readString(file, StandardCharsets.UTF_8)))) {
reader.setStrictness(Strictness.STRICT);
JsonObject root = readJson(reader, 0).getAsJsonObject();
if (reader.peek() != JsonToken.END_DOCUMENT) throw new IOException("Trailing data in expansion journal");
requireFields(root, JOURNAL_FIELDS);
if (integer(root, "schema") != 1 || number(root, "world_seed").longValueExact() != seed || integer(root, "root_diameter") != rootDiameter) {
var journalFields = new java.util.HashSet<>(JOURNAL_FIELDS);
if ((rootGeneration == 24 || rootGeneration == 25)) journalFields.add("aerial_plan");
requireFields(root, journalFields);
if (integer(root, "schema") != (rootGeneration == 25 ? 3 : rootGeneration == 24 ? 2 : 1) || number(root, "world_seed").longValueExact() != seed || integer(root, "root_diameter") != rootDiameter) {
throw new IOException("Expansion journal schema, world seed or root diameter does not match");
}
if ((rootGeneration == 24 || rootGeneration == 25)) bindAerial(string(root, "aerial_plan"));
JsonArray entries = root.getAsJsonArray("islands");
List<ExpansionIsland> decoded = new ArrayList<>();
for (JsonElement element : entries) {
JsonObject entry = element.getAsJsonObject();
var fields = new java.util.HashSet<>(ISLAND_FIELDS);
if (ExpansionIsland.spatialGeneration(rootGeneration) && entry.has("reuse_token")) fields.add("reuse_token");
if ((rootGeneration == 23 || (rootGeneration == 24 || rootGeneration == 25)) && entry.has("structure_priorities")) fields.add("structure_priorities");
if (rootGeneration == 25 && entry.has("structure_plan25")) fields.add("structure_plan25");
requireFields(entry, fields);
decoded.add(new ExpansionIsland(string(entry, "id"), string(entry, "parent"), string(entry, "direction"), string(entry, "climate"),
integer(entry, "diameter"), integer(entry, "center_x"), integer(entry, "center_z"), number(entry, "seed").longValueExact(),
string(entry, "relief"), string(entry, "status"), integer(entry, "generation"),
entry.has("reuse_token") ? string(entry, "reuse_token") : ""));
entry.has("reuse_token") ? string(entry, "reuse_token") : "",
entry.has("structure_priorities") ? priorities(entry) : List.of(),
entry.has("structure_plan25") ? java.util.Optional.of(structurePlan(entry.getAsJsonObject("structure_plan25"))) : java.util.Optional.empty()));
}
return validate(decoded);
} catch (RuntimeException e) {
@@ -189,6 +215,9 @@ public final class ExpansionJournal {
long dx = (long) island.centerX() - parent.centerX(), dz = (long) island.centerZ() - parent.centerZ();
if (!(ExpansionIsland.spatialGeneration(island.generation()) ? ExpansionSpatialPlacement14.followsDirection(island.direction(), dx, dz) : followsDirection(island.direction(), dx, dz))) throw new IOException("Expansion position does not follow its direction: " + island.id());
for (ExpansionIsland other : previous.values()) if (island.conflicts(other)) throw new IOException("Expansion reservations overlap: " + island.id() + " / " + other.id());
if ((rootGeneration == 24 || rootGeneration == 25) && aerialPlan24.conflicts(island)) throw new IOException("Expansion reservation overlaps a permanent aerial site: " + island.id());
try { island.structurePlan25().ifPresent(plan -> plan.validateFor(island)); }
catch (RuntimeException invalid) { throw new IOException("Invalid native monument reservation", invalid); }
previous.put(island.id(), island);
}
return checked;
@@ -210,7 +239,8 @@ public final class ExpansionJournal {
private JsonObject encode(List<ExpansionIsland> entries) {
JsonObject root = new JsonObject();
root.addProperty("schema", 1); root.addProperty("world_seed", seed); root.addProperty("root_diameter", rootDiameter);
root.addProperty("schema", rootGeneration == 25 ? 3 : rootGeneration == 24 ? 2 : 1); root.addProperty("world_seed", seed); root.addProperty("root_diameter", rootDiameter);
if ((rootGeneration == 24 || rootGeneration == 25)) root.addProperty("aerial_plan", aerialToken);
JsonArray array = new JsonArray();
for (ExpansionIsland island : entries) {
JsonObject entry = new JsonObject();
@@ -220,12 +250,26 @@ public final class ExpansionJournal {
entry.addProperty("seed", island.seed()); entry.addProperty("relief", island.relief()); entry.addProperty("status", island.status());
entry.addProperty("generation", island.generation());
if (!island.reuseToken().isEmpty()) entry.addProperty("reuse_token", island.reuseToken());
if (!island.structurePriorities().isEmpty()) {
var priorities = new JsonArray(); island.structurePriorities().forEach(priorities::add);
entry.add("structure_priorities", priorities);
}
island.structurePlan25().ifPresent(plan -> entry.add("structure_plan25", ExpansionStructurePlan25.CODEC
.encodeStart(com.mojang.serialization.JsonOps.INSTANCE, plan).getOrThrow()));
array.add(entry);
}
root.add("islands", array);
return root;
}
private void bindAerial(String token) throws IOException {
if (!aerialToken.isEmpty() && !aerialToken.equals(token)) throw new IOException("Aerial manifest identity changed outside this journal");
var stored = AerialStore24.load(AerialStore24.directory(directory, rootGeneration), token, seed, rootDiameter, rootGeneration);
if (!aerialToken.isEmpty() && !aerialPlan24.sites().equals(stored.plan().sites())) throw new IOException("Aerial layout changed outside this journal");
aerialToken = token;
aerialPlan24 = stored.plan();
}
private static JsonElement readJson(JsonReader reader, int depth) throws IOException {
if (depth > 4) throw new IOException("Expansion journal nesting exceeds its schema");
return switch (reader.peek()) {
@@ -258,6 +302,21 @@ public final class ExpansionJournal {
private static void requireFields(JsonObject value, Set<String> fields) throws IOException {
if (!value.keySet().equals(fields)) throw new IOException("Missing or unknown expansion journal fields");
}
private static ExpansionStructurePlan25 structurePlan(JsonObject value) throws IOException {
requireFields(value, Set.of("algorithm", "structure", "chunk_x", "chunk_z", "min_x", "min_y", "min_z", "max_x", "max_y", "max_z", "pieces", "digest"));
return ExpansionStructurePlan25.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, value).getOrThrow();
}
private static List<String> priorities(JsonObject value) throws IOException {
var array = value.get("structure_priorities");
if (!array.isJsonArray()) throw new IOException("Structure priorities must be an array");
var values = new ArrayList<String>();
for (var entry : array.getAsJsonArray()) {
if (!entry.isJsonPrimitive() || !entry.getAsJsonPrimitive().isString())
throw new IOException("Structure priority family must be a string");
values.add(entry.getAsString());
}
return List.copyOf(values);
}
private static String string(JsonObject value, String name) throws IOException {
JsonPrimitive primitive = value.getAsJsonPrimitive(name);
if (!primitive.isString()) throw new IOException("Expected string field: " + name);
@@ -0,0 +1,111 @@
package fr.koka.sanctuary.expansion;
import java.util.EnumSet;
import java.util.List;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
/** One immutable geographic field for the rock, water, biomes and native ocean structures. */
public final class ExpansionOcean23 {
private static final ThreadLocal<Integer> DECORATION_SEA_LEVEL = new ThreadLocal<>();
public static Integer decorationSeaLevel() { return DECORATION_SEA_LEVEL.get(); }
public static Integer enterDecoration(int seaLevel) {
Integer previous = DECORATION_SEA_LEVEL.get(); DECORATION_SEA_LEVEL.set(seaLevel); return previous;
}
public static void restoreDecoration(Integer previous) {
if (previous == null) DECORATION_SEA_LEVEL.remove(); else DECORATION_SEA_LEVEL.set(previous);
}
public static final List<String> BIOMES = List.of("ocean", "deep_ocean", "cold_ocean", "deep_cold_ocean",
"frozen_ocean", "deep_frozen_ocean", "warm_ocean", "lukewarm_ocean", "deep_lukewarm_ocean", "beach", "snowy_beach", "stony_shore");
public record Column(int waterLevel, int floorY, boolean water, boolean deep, int bottomY, double influence) {
public int depth() { return water ? waterLevel - floorY : 0; }
}
private ExpansionOcean23() {}
public static boolean active(ExpansionIsland island) {
return island != null && (island.generation() == 23 || island.generation() == 24 || island.generation() == 25) && !island.origin() && island.relief().equals("ocean");
}
public static int seaLevel(ExpansionIsland island) {
return (int) Math.round(ExpansionRelief.worldY(island, 220));
}
/** Quick expansions retain their size/climate draw; only unspecified relief participates. */
public static String quickRelief(long seed, String id, int diameter, String climate, int generation) {
if ((generation != 23 && generation != 24 && generation != 25) || diameter < 512 || climate.equals("lost_city")) return "natural";
return Long.remainderUnsigned(ExpansionIsland.deriveSeed(seed ^ 0x0CEA231L, id), 3) == 0 ? "ocean" : "natural";
}
public static Column column(ExpansionIsland island, int x, int z) {
int level = seaLevel(island);
if (!active(island)) return new Column(level, level, false, false, level, 0);
double radius = island.radius();
double angle = unit(island.seed() ^ 0x2310CEAL) * Math.PI * 2;
double dx = (x - island.centerX()) / radius, dz = (z - island.centerZ()) / radius;
double u = dx * Math.cos(angle) + dz * Math.sin(angle) - .09;
double v = -dx * Math.sin(angle) + dz * Math.cos(angle) + .035;
double phase = unit(island.seed() ^ 0x231BA51L) * Math.PI * 2;
double theta = Math.atan2(v, u);
double coast = 1 + .08 * Math.sin(theta * 3 + phase) + .055 * Math.sin(theta * 5 - phase)
+ .025 * Math.sin(theta * 9 + phase * 2);
double radial = Math.hypot(u / .64, v / .59) / coast;
double influence = 1 - smooth((radial - 1.08) / .14);
if (influence <= 0 || !island.contains(x, z)) return new Column(level, level, false, false, level, 0);
double depth = 48 * smooth((1 - radial) / .48)
+ 6 * smooth((1 - radial) / .3) * Math.sin(u * 17 + phase) * Math.cos(v * 13 - phase);
int floor = radial <= 1 ? level - Math.max(1, (int) Math.round(depth))
: level + (int) Math.round(4 + 24 * smooth((radial - 1) / .18));
int shell = 14 + (int) Math.round(3 * (1 + Math.sin(u * 11 + v * 7 + phase)));
boolean water = radial <= 1;
return new Column(level, floor, water, water && level - floor >= 32, floor - shell, influence);
}
public static float density(Column c, int y, float natural) {
if (c.influence() <= 0 || y < c.bottomY() - 2) return natural;
// Rock remains a bounded geological shell. The original lower caves survive below it.
float shaped = y <= c.floorY() ? Math.min(1, (y - c.bottomY() + 2) / 2f) : -1;
return (float) (natural + (shaped - natural) * c.influence());
}
public static boolean retains(Column c, int y) {
return c.water() && y <= c.floorY() && y >= c.bottomY();
}
public static String shoreBiome(ExpansionIsland island) {
return switch (island.climate()) {
case "glacial", "cold_dry" -> "snowy_beach";
case "dry" -> "stony_shore";
default -> "beach";
};
}
public static String biome(ExpansionIsland island, Column column) {
return switch (island.climate()) {
case "glacial" -> column.deep() ? "deep_frozen_ocean" : "frozen_ocean";
case "boreal", "cold_humid", "cold_dry" -> column.deep() ? "deep_cold_ocean" : "cold_ocean";
case "warm", "tropical", "arid" -> column.deep() ? "deep_lukewarm_ocean"
: column.depth() < 16 ? "warm_ocean" : "lukewarm_ocean";
default -> column.deep() ? "deep_ocean" : "ocean";
};
}
/** Exactly one chunk-local fill, before features. Static source water does not schedule an ocean of ticks. */
public static void apply(ExpansionIsland island, ChunkAccess chunk) {
if (!active(island)) return;
var pos = new BlockPos.MutableBlockPos();
for (int x = chunk.getPos().getMinBlockX(); x <= chunk.getPos().getMaxBlockX(); x++)
for (int z = chunk.getPos().getMinBlockZ(); z <= chunk.getPos().getMaxBlockZ(); z++) {
Column c = column(island, x, z);
if (!c.water()) continue;
// Surface rules run before this pass. Pin the same floor used by admission and biomes.
for (int y = c.bottomY(); y <= c.floorY(); y++) {
pos.set(x, y, z);
var rock = y >= c.floorY() - 1 ? (c.deep() ? Blocks.GRAVEL : Blocks.SAND) : Blocks.STONE;
chunk.setBlockState(pos, rock.defaultBlockState(), 0);
}
for (int y = c.floorY() + 1; y <= c.waterLevel(); y++)
chunk.setBlockState(pos.set(x, y, z), Blocks.WATER.defaultBlockState(), 0);
}
Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG,
Heightmap.Types.OCEAN_FLOOR_WG, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES));
}
private static double smooth(double x) { x = Math.clamp(x, 0, 1); return x * x * (3 - 2 * x); }
private static double unit(long v) {
v = (v ^ v >>> 30) * 0xbf58476d1ce4e5b9L;
v = (v ^ v >>> 27) * 0x94d049bb133111ebL;
return ((v ^ v >>> 31) >>> 11) * 0x1.0p-53;
}
}
@@ -21,7 +21,7 @@ public final class ExpansionPlacement {
if (direction == null || !ExpansionIsland.DIRECTIONS.contains(direction)) throw new IllegalArgumentException("invalid_direction");
if (climate == null || !ExpansionIsland.CLIMATES.contains(climate)) throw new IllegalArgumentException("invalid_climate");
if (!ExpansionIsland.DIAMETERS.contains(diameter)) throw new IllegalArgumentException("invalid_diameter");
if (relief == null || !ExpansionIsland.RELIEFS.contains(relief)) throw new IllegalArgumentException("invalid_relief");
if (relief == null || !ExpansionIsland.reliefs(islands.getFirst().generation()).contains(relief)) throw new IllegalArgumentException("invalid_relief");
validateDistance(minimumDistance);
int rootGeneration = islands.getFirst().generation();
int generation = ExpansionIsland.spatialGeneration(rootGeneration) ? rootGeneration : ExpansionIsland.GENERATION;
@@ -77,7 +77,7 @@ public final class ExpansionRelief {
double uplift;
double canyon = 0.0, crater = 0.0;
switch (island.relief()) {
case "natural" -> {
case "natural", "ocean" -> {
double ridge = Math.pow(1.0 - Math.abs(broad), 4.0);
uplift = Math.clamp(12.0 * broad + 5.0 * detail + 10.0 * Math.max(0.0, ridge - 0.5), -20.0, 20.0);
}
@@ -1,6 +1,9 @@
package fr.koka.sanctuary.expansion;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.aerial.AerialPlanner24;
import fr.koka.sanctuary.worldgen.aerial.AerialStore24;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.ArrayList;
@@ -37,7 +40,26 @@ public final class ExpansionRuntime {
if (server.getWorldData().overworldData().isInitialized()
&& !java.nio.file.Files.isDirectory(directory, java.nio.file.LinkOption.NOFOLLOW_LINKS))
throw new IOException("Initialized laboratory world is missing its expansion journal directory");
var journal = new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter(), generator.rootGeneration());
String initialAerialToken = "";
if ((generator.rootGeneration() == 24 || generator.rootGeneration() == 25) && !java.nio.file.Files.exists(directory, java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
if (!(generator instanceof SanctuaryChunkGenerator sanctuary)) throw new IOException("Aerial world requires the Sanctuary generator");
initialAerialToken = AerialStore24.create(AerialStore24.directory(directory, generator.rootGeneration()), level.getSeed(), generator.initialDiameter(), generator.rootGeneration(),
() -> AerialPlanner24.forWorld(level, sanctuary)).token();
}
var journal = (generator.rootGeneration() == 24 || generator.rootGeneration() == 25)
? new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter(), generator.rootGeneration(), initialAerialToken)
: new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter(), generator.rootGeneration());
if ((generator.rootGeneration() == 24 || generator.rootGeneration() == 25)) {
if (!(generator instanceof SanctuaryChunkGenerator sanctuary)) throw new IOException("Aerial world requires the Sanctuary generator");
sanctuary.setAerialPlan24(journal.aerialPlan24());
}
for (var island : journal.islands()) if (island.structurePlan25().isPresent())
island.structurePlan25().get().restore(net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext.fromLevel(level), island);
if (generator.rootGeneration() == 25 && generator instanceof SanctuaryChunkGenerator sanctuary) {
// Reload has no aerial planning callback: attach the same immutable root sampler before checking future water.
sanctuary.prepareAerialSampling24(level);
ExpansionInitialFlows25.prepare(sanctuary);
}
ExpansionVoidReuse.open(level, generator, journal.islands());
generator.initialize(level, journal.islands());
var session = new Session(level, generator, journal);
@@ -87,7 +109,7 @@ public final class ExpansionRuntime {
public ExpansionIsland candidate(String id, String parentId, String direction, String climate, int diameter, String relief,
int minimumDistance) {
return ExpansionPlacement.candidates(level.getSeed(), islands(), id, parentId, direction, climate, diameter, relief, minimumDistance)
.stream().findFirst().orElseThrow(() -> new IllegalArgumentException("no_free_space"));
.stream().filter(value -> !aerialConflicts(value)).findFirst().orElseThrow(() -> new IllegalArgumentException("no_free_space"));
}
public ExpansionIsland create(String id, String parent, String direction, String climate, int diameter, String relief) throws IOException {
return create(id, parent, direction, climate, diameter, relief, 0);
@@ -107,10 +129,21 @@ public final class ExpansionRuntime {
return value;
}
if (islands().size() >= 64) throw new IllegalArgumentException("island_limit");
var candidates = ExpansionPlacement.candidates(level.getSeed(), islands(), id, parent, direction, climate, diameter, relief, minimumDistance);
for (var value : candidates) {
// Capture one immutable decision before any candidate is probed or certified. A resumed
// reservation uses its recorded profile even if the world's census has since changed.
var priorities = generator.rootGeneration() == 23 || (generator.rootGeneration() == 24 || generator.rootGeneration() == 25)
? fr.koka.sanctuary.knowledge.structures.StructureCensusService.priorities(level) : List.<String>of();
var candidates = ExpansionPlacement.candidates(level.getSeed(), islands(), id, parent, direction, climate, diameter, relief, minimumDistance)
.stream().filter(value -> !aerialConflicts(value)).map(value -> value.withStructurePriorities(priorities)).toList();
boolean nativeRefused = false;
for (var candidate : candidates) {
ExpansionIsland value = candidate;
ExpansionAdmission.checkDeadline(deadline);
try {
if (value.generation() == 25) {
value = ExpansionStructures23.prepareExpansion25(level, value, deadline);
if (!value.relief().equals("ocean")) ExpansionStructures23.verifyExpansion(level, value, deadline);
} else if (value.generation() == 23 || value.generation() == 24) ExpansionStructures23.verifyExpansion(level, value, deadline);
var inspection = ExpansionIsland.spatialGeneration(value.generation()) ? ExpansionVoidReuse.inspect(level, value, deadline) : null;
if (inspection == null) ExpansionAdmission.verify(level, value, deadline);
if (probe(value) == null) throw new IllegalArgumentException("no_ground");
@@ -127,12 +160,19 @@ public final class ExpansionRuntime {
return admitted;
} catch (ExpansionAdmission.OccupiedException occupied) {
// IO failures, timeouts and unsuitable terrain stop this request without a new draw.
} catch (ExpansionStructures23.NoSuitableStructureException unsuitable) {
nativeRefused = true; // Try another location with the same immutable priorities.
} finally {
generator.discardUnpublishedContext(value);
if (!value.equals(candidate)) generator.discardUnpublishedContext(candidate);
}
}
ExpansionAdmission.checkDeadline(deadline);
throw new IllegalArgumentException("no_free_space");
throw new IllegalArgumentException(nativeRefused ? "native_structure_unavailable" : "no_free_space");
}
private boolean aerialConflicts(ExpansionIsland island) {
return (generator.rootGeneration() == 24 || generator.rootGeneration() == 25) && generator instanceof SanctuaryChunkGenerator sanctuary
&& (sanctuary.aerialPlan24().conflicts(island) || ExpansionInitialFlows25.conflicts(sanctuary, island));
}
public void resume(String id) {
requireHealthyJournal();
@@ -0,0 +1,48 @@
package fr.koka.sanctuary.expansion;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/** Exact built-in 26.3-pre-2 catalogue. Other dimensions remain native and ancient cities await Cavernes. */
public final class ExpansionStructureCatalog23 {
public enum Availability { OVERWORLD, CAVERNS_ONLY, NETHER_ONLY, END_ONLY }
public enum Habitat { SURFACE, UNDERGROUND, OCEAN, SHORE }
public record Entry(String id, String family, Availability availability, Habitat habitat) {}
private static final List<Entry> ENTRIES = create();
private static final Map<String, Entry> BY_ID = ENTRIES.stream().collect(Collectors.toUnmodifiableMap(Entry::id, Function.identity()));
private ExpansionStructureCatalog23() {}
public static List<Entry> entries() { return ENTRIES; }
public static Entry entry(String id) { return BY_ID.get(id); }
public static String family(String id) { Entry entry = entry(id); return entry == null ? "unknown" : entry.family(); }
private static List<Entry> create() {
var entries = new ArrayList<Entry>();
add(entries, "village", Habitat.SURFACE, "village_plains", "village_desert", "village_savanna", "village_snowy", "village_taiga");
for (String biome : List.of("bamboo_jungle", "birch_forest", "cherry_grove", "dappled_forest", "flower_forest", "forest", "meadow",
"old_growth_birch_forest", "old_growth_pine_taiga", "old_growth_spruce_taiga", "pale_garden", "savanna", "snowy_taiga",
"sparse_jungle", "swamp", "taiga", "windswept_forest", "wooded_badlands"))
add(entries, "camp", Habitat.SURFACE, "abandoned_camp_" + biome);
add(entries, "ruined_portal", Habitat.SURFACE, "ruined_portal", "ruined_portal_desert", "ruined_portal_jungle", "ruined_portal_mountain", "ruined_portal_swamp");
add(entries, "ruined_portal", Habitat.OCEAN, "ruined_portal_ocean");
add(entries, "mineshaft", Habitat.UNDERGROUND, "mineshaft", "mineshaft_mesa");
add(entries, "ocean_ruin", Habitat.OCEAN, "ocean_ruin_cold", "ocean_ruin_warm");
add(entries, "shipwreck", Habitat.OCEAN, "shipwreck");
add(entries, "shipwreck", Habitat.SHORE, "shipwreck_beached");
add(entries, "buried_treasure", Habitat.SHORE, "buried_treasure");
for (String name : List.of("desert_pyramid", "igloo", "jungle_pyramid", "mansion", "pillager_outpost", "swamp_hut", "trail_ruins"))
add(entries, name, Habitat.SURFACE, name);
add(entries, "monument", Habitat.OCEAN, "monument");
add(entries, "stronghold", Habitat.UNDERGROUND, "stronghold");
add(entries, "trial_chambers", Habitat.UNDERGROUND, "trial_chambers");
entries.add(new Entry("minecraft:ancient_city", "ancient_city", Availability.CAVERNS_ONLY, Habitat.UNDERGROUND));
for (String name : List.of("fortress", "bastion_remnant", "nether_fossil", "ruined_portal_nether"))
entries.add(new Entry("minecraft:" + name, name.equals("ruined_portal_nether") ? "ruined_portal" : name, Availability.NETHER_ONLY, Habitat.UNDERGROUND));
entries.add(new Entry("minecraft:end_city", "end_city", Availability.END_ONLY, Habitat.SURFACE));
return List.copyOf(entries);
}
private static void add(List<Entry> entries, String family, Habitat habitat, String... names) {
for (String name : names) entries.add(new Entry("minecraft:" + name, family, Availability.OVERWORLD, habitat));
}
}
@@ -0,0 +1,118 @@
package fr.koka.sanctuary.expansion;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.Base64;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.NbtAccounter;
import net.minecraft.nbt.NbtIo;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
/** Immutable native monument selected before an ocean25 reservation. No block, entity or chunk is stored here. */
public record ExpansionStructurePlan25(String algorithm, String structure, int chunkX, int chunkZ,
int minX, int minY, int minZ, int maxX, int maxY, int maxZ, String pieces, String digest) {
public static final String ALGORITHM = "ocean_monument25_v1";
public static final int MAX_NBT_BYTES = 65_536;
public static final Codec<ExpansionStructurePlan25> CODEC = RecordCodecBuilder.create(i -> i.group(
Codec.STRING.fieldOf("algorithm").forGetter(ExpansionStructurePlan25::algorithm),
Codec.STRING.fieldOf("structure").forGetter(ExpansionStructurePlan25::structure),
Codec.INT.fieldOf("chunk_x").forGetter(ExpansionStructurePlan25::chunkX),
Codec.INT.fieldOf("chunk_z").forGetter(ExpansionStructurePlan25::chunkZ),
Codec.INT.fieldOf("min_x").forGetter(ExpansionStructurePlan25::minX),
Codec.INT.fieldOf("min_y").forGetter(ExpansionStructurePlan25::minY),
Codec.INT.fieldOf("min_z").forGetter(ExpansionStructurePlan25::minZ),
Codec.INT.fieldOf("max_x").forGetter(ExpansionStructurePlan25::maxX),
Codec.INT.fieldOf("max_y").forGetter(ExpansionStructurePlan25::maxY),
Codec.INT.fieldOf("max_z").forGetter(ExpansionStructurePlan25::maxZ),
Codec.STRING.fieldOf("pieces").forGetter(ExpansionStructurePlan25::pieces),
Codec.STRING.fieldOf("digest").forGetter(ExpansionStructurePlan25::digest)
).apply(i, ExpansionStructurePlan25::new));
public ExpansionStructurePlan25 {
if (!ALGORITHM.equals(algorithm) || !"minecraft:monument".equals(structure))
throw new IllegalArgumentException("Unsupported saved native structure algorithm");
if (Math.abs((long) chunkX) > 6_300 || Math.abs((long) chunkZ) > 6_300
|| minX > maxX || minY > maxY || minZ > maxZ || minY < 3 || maxY > 379
|| (long) maxX - minX > 256 || (long) maxZ - minZ > 256
|| minX < (long) chunkX * 16 - 128 || maxX > (long) chunkX * 16 + 143
|| minZ < (long) chunkZ * 16 - 128 || maxZ > (long) chunkZ * 16 + 143)
throw new IllegalArgumentException("Invalid saved native structure bounds");
if (pieces == null || pieces.isEmpty() || pieces.length() > (MAX_NBT_BYTES + 2) / 3 * 4
|| digest == null || !digest.matches("[0-9a-f]{64}") || !ExpansionVoidCertificate.digest(pieces).equals(digest))
throw new IllegalArgumentException("Invalid saved native structure checksum");
CompoundTag tag = decode(pieces);
if (!structure.equals(tag.getStringOr("id", "")) || tag.getIntOr("ChunkX", Integer.MIN_VALUE) != chunkX
|| tag.getIntOr("ChunkZ", Integer.MIN_VALUE) != chunkZ || tag.getIntOr("references", -1) != 0
|| tag.getListOrEmpty("Children").isEmpty())
throw new IllegalArgumentException("Saved native structure identity differs from its pieces");
}
public static ExpansionStructurePlan25 capture(StructurePieceSerializationContext context, StructureStart start) {
if (!start.isValid() || start.getReferences() != 0) throw new IllegalArgumentException("Expected an unreferenced native start");
return fromTag(start.getBoundingBox(), start.createTag(context, start.getChunkPos()));
}
/** Also used by serialization tests without a Minecraft world. */
static ExpansionStructurePlan25 fromTag(BoundingBox bounds, CompoundTag tag) {
try {
var bytes = new ByteArrayOutputStream();
NbtIo.write(tag, new DataOutputStream(bytes));
if (bytes.size() > MAX_NBT_BYTES) throw new IllegalArgumentException("Native monument exceeds its saved-piece budget");
String encoded = Base64.getEncoder().encodeToString(bytes.toByteArray());
return new ExpansionStructurePlan25(ALGORITHM, tag.getStringOr("id", ""), tag.getIntOr("ChunkX", 0), tag.getIntOr("ChunkZ", 0),
bounds.minX(), bounds.minY(), bounds.minZ(), bounds.maxX(), bounds.maxY(), bounds.maxZ(), encoded, ExpansionVoidCertificate.digest(encoded));
} catch (IOException error) { throw new IllegalArgumentException("Cannot encode native monument", error); }
}
public ChunkPos startChunk() { return new ChunkPos(chunkX, chunkZ); }
public BoundingBox bounds() { return new BoundingBox(minX, minY, minZ, maxX, maxY, maxZ); }
/** Includes native foundation/clearing work around the visible body. */
public BoundingBox protection() { return new BoundingBox(minX - 16, Math.max(0, minY - 16), minZ - 16, maxX + 16, maxY + 8, maxZ + 16); }
public boolean conflicts(BoundingBox other) { return protection().intersects(other); }
public boolean protects(net.minecraft.core.BlockPos pos) {
return pos.getX() >= minX - 16 && pos.getX() <= maxX + 16 && pos.getZ() >= minZ - 16 && pos.getZ() <= maxZ + 16
&& pos.getY() >= Math.max(0, minY - 16) && pos.getY() <= maxY + 8;
}
public void validateFor(ExpansionIsland island) {
if (island.generation() != 25 || island.origin() || !island.relief().equals("ocean")
|| !island.ownsChunk(chunkX, chunkZ)) throw new IllegalArgumentException("Native monument belongs to an ocean25 expansion");
for (int x : new int[]{minX, maxX}) for (int z : new int[]{minZ, maxZ})
if (!island.contains(x, z)) throw new IllegalArgumentException("Saved monument extends outside its island");
CompoundTag metadata = tag().getCompoundOrEmpty("sanctuary_native");
if (metadata.getIntOr("generation", 0) != 23 || metadata.getLongOr("island_seed", 0) != island.seed()
|| metadata.getIntOr("sea_level", -64) != ExpansionOcean23.seaLevel(island))
throw new IllegalArgumentException("Saved monument seed or sea level differs from its island");
}
public StructureStart restore(StructurePieceSerializationContext context, ExpansionIsland island) {
validateFor(island);
CompoundTag saved = tag();
StructureStart result = StructureStart.loadStaticStart(context, saved, island.seed());
if (result == null || !result.isValid() || !bounds().equals(result.getBoundingBox())
|| !startChunk().equals(result.getChunkPos()) || !saved.equals(result.createTag(context, result.getChunkPos())))
throw new IllegalStateException("Reserved native monument no longer decodes exactly; refusing a new draw");
return result;
}
public CompoundTag tag() { return decode(pieces); }
private static CompoundTag decode(String encoded) {
try {
byte[] bytes = Base64.getDecoder().decode(encoded);
if (bytes.length > MAX_NBT_BYTES) throw new IllegalArgumentException("Native monument exceeds its byte budget");
var input = new DataInputStream(new ByteArrayInputStream(bytes));
var tag = NbtIo.read(input, NbtAccounter.create(1_048_576));
if (input.available() != 0) throw new IllegalArgumentException("Trailing bytes in saved native monument");
return tag;
} catch (IOException error) { throw new IllegalArgumentException("Cannot decode saved native monument", error); }
}
}
@@ -44,6 +44,10 @@ public final class ExpansionStructures {
public static void generate(ExpansionChunkGenerator generator, RegistryAccess registries,
ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk,
StructureTemplateManager templates, ResourceKey<Level> level) {
if ((generator.rootGeneration() == 23 || generator.rootGeneration() == 24 || generator.rootGeneration() == 25)) {
ExpansionStructures23.generate(generator, registries, state, manager, chunk, templates, level);
return;
}
if (!manager.shouldGenerateStructures()) return;
int blockX = chunk.getPos().getMiddleBlockX(), blockZ = chunk.getPos().getMiddleBlockZ();
ExpansionIsland island = generator.islandAt(blockX, blockZ);
@@ -0,0 +1,690 @@
package fr.koka.sanctuary.expansion;
import com.google.gson.JsonObject;
import com.mojang.datafixers.util.Pair;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.expansion.ExpansionStructureCatalog23.Availability;
import fr.koka.sanctuary.expansion.ExpansionStructureCatalog23.Entry;
import fr.koka.sanctuary.expansion.ExpansionStructureCatalog23.Habitat;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryOps;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.NoiseColumn;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.chunk.PalettedContainerFactory;
import net.minecraft.world.level.chunk.ProtoChunk;
import net.minecraft.world.level.chunk.UpgradeData;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure;
import net.minecraft.world.level.levelgen.structure.structures.StrongholdStructure;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
/** Native structures on generation23 islands: deterministic candidate cells, native pieces and native starts.
* Nothing is placed or persisted by the preflight. A refused start is never published as a reference.
*/
public final class ExpansionStructures23 {
public static final int CELL_CHUNKS = 8;
private static final int MAX_NATIVE_ATTEMPTS_PER_SITE = 12;
private static final int MAX_COLUMN_SAMPLES = 4096;
private static final boolean DIAGNOSTICS = Boolean.getBoolean("sanctuary.expansion.structureDiagnostics")
|| Boolean.getBoolean("sanctuary.test.expansion");
private ExpansionStructures23() {}
public static List<Entry> catalogue() { return ExpansionStructureCatalog23.entries(); }
public static String family(String id) { return ExpansionStructureCatalog23.family(id); }
public static boolean isPlacing() { return NativeStructurePlacement23.seaLevel() != null; }
private static boolean nativeGeneration(int generation) { return generation == 23 || generation == 24 || generation == 25; }
public record Candidate(ChunkPos chunk, long roll) {}
public record Accepted(Holder<Structure> structure, StructureStart start) {}
public static final class NoSuitableStructureException extends IllegalArgumentException {
public NoSuitableStructureException() { super("No suitable native structure fits this expansion; choose another seed, climate, relief or larger size"); }
}
private static final class SampleBudgetExceeded extends RuntimeException {}
/** Includes one deterministic central candidate even for a diameter64 island. Never draws from mutable server history. */
public static List<Candidate> candidates(ExpansionIsland island) {
var result = new LinkedHashMap<Long, Candidate>();
int rootX = island.centerX() >> 4, rootZ = island.centerZ() >> 4;
var central = new ChunkPos(rootX, rootZ);
result.put(central.pack(), new Candidate(central, mix(island.seed() ^ 0xA2300C37L)));
island.structurePlan25().ifPresent(saved -> result.putIfAbsent(saved.startChunk().pack(),
new Candidate(saved.startChunk(), mix(island.seed() ^ saved.startChunk().pack() ^ 0xA25C37L))));
int cells = (island.terrainRadius() + 127) / 128;
for (int cz = -cells; cz <= cells; cz++) for (int cx = -cells; cx <= cells; cx++) {
long roll = mix(island.seed() ^ 0xA23CA7A10L ^ cx * 0x9e3779b97f4a7c15L ^ cz * 0xc2b2ae3d27d4eb4fL);
if (Long.remainderUnsigned(roll, 100) >= 68) continue;
var pos = new ChunkPos(rootX + cx * CELL_CHUNKS + (int) Long.remainderUnsigned(roll >>> 8, CELL_CHUNKS),
rootZ + cz * CELL_CHUNKS + (int) Long.remainderUnsigned(roll >>> 17, CELL_CHUNKS));
if (island.contains(pos.getMiddleBlockX(), pos.getMiddleBlockZ())) result.putIfAbsent(pos.pack(), new Candidate(pos, roll));
}
return List.copyOf(result.values());
}
public static void generate(ExpansionChunkGenerator generator, RegistryAccess registries,
ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk,
StructureTemplateManager templates, ResourceKey<Level> dimension) {
if (!Level.OVERWORLD.equals(dimension) || !manager.shouldGenerateStructures()) return;
ExpansionIsland island = generator.islandAt(chunk.getPos().getMiddleBlockX(), chunk.getPos().getMiddleBlockZ());
if (island == null || !nativeGeneration(island.generation())) return;
if (island.origin() && island.generation() == 25 && generator instanceof SanctuaryChunkGenerator sanctuary
&& fr.koka.sanctuary.worldgen.underground25.UndergroundStructures25.plan(sanctuary).starts().stream()
.anyMatch(site -> site.start().getChunkPos().equals(chunk.getPos()))) return;
Candidate candidate = candidates(island).stream().filter(c -> c.chunk().equals(chunk.getPos())).findFirst().orElse(null);
if (candidate == null) return;
for (var old : chunk.getAllStarts().values()) {
if (old.isValid() && ((Object) old) instanceof NativeStructureMetadata23 metadata
&& metadata.sanctuary$native23() != null) return;
}
if (island.structurePlan25().isPresent() && island.structurePlan25().get().startChunk().equals(chunk.getPos())) {
var saved = island.structurePlan25().get();
var serialization = new net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext(
net.minecraft.server.packs.resources.ResourceManager.Empty.INSTANCE, registries, templates);
StructureStart start = saved.restore(serialization, island);
manager.setStartForStructure(start.getStructure(), start, chunk);
return;
}
var view = new NativeView(generator, island, generator.source(), registries);
Accepted accepted = plan(generator, view, island, candidate, registries, templates, dimension, chunk, Long.MAX_VALUE);
if (accepted != null) {
manager.setStartForStructure(accepted.structure().value(), accepted.start(), chunk);
diagnostic(island, candidate, "accepted", accepted.structure().unwrapKey().orElseThrow().identifier().toString());
}
}
/** Bounded, read-only native preflight used before reservation of a generation23 expansion.
* The same descriptor, cell order, templates and support predicate are replayed by createStructures.
* No active source snapshot is mutated and no real world chunk is requested.
*/
public static void verifyExpansion(ServerLevel level, ExpansionIsland island, long deadlineNanos) {
if (!nativeGeneration(island.generation()) || island.origin() || !level.structureManager().shouldGenerateStructures()) return;
if (island.generation() == 25 && island.relief().equals("ocean")) {
prepareExpansion25(level, island, deadlineNanos);
return;
}
if (!(level.getChunkSource().getGenerator() instanceof ExpansionChunkGenerator host))
throw new IllegalArgumentException("Native structure preflight requires a Sanctuary world");
ExpansionBiomeSource isolated = isolatedSource(host, island, level.registryAccess());
NativeView view = new NativeView(host, island, isolated, level.registryAccess());
var factory = PalettedContainerFactory.create(level.registryAccess());
for (Candidate candidate : candidates(island)) {
checkDeadline(deadlineNanos);
var chunk = new ProtoChunk(candidate.chunk(), UpgradeData.EMPTY, level, factory, null);
Accepted accepted = plan(host, view, island, candidate, level.registryAccess(), level.getStructureTemplateManager(), level.dimension(), chunk, deadlineNanos);
if (accepted != null) return;
}
throw new NoSuitableStructureException();
}
/** The selected native start becomes part of the journal/certificate identity before any terrain inspection. */
public static ExpansionIsland prepareExpansion25(ServerLevel level, ExpansionIsland island, long deadlineNanos) {
if (island.generation() != 25 || island.origin() || !island.relief().equals("ocean")
|| !level.structureManager().shouldGenerateStructures()) return island;
var context = net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext.fromLevel(level);
if (island.structurePlan25().isPresent()) {
island.structurePlan25().get().restore(context, island);
return island;
}
Accepted accepted = preview(level, island, "minecraft:monument", deadlineNanos);
if (accepted == null) throw new NoSuitableStructureException();
var result = island.withStructurePlan25(ExpansionStructurePlan25.capture(context, accepted.start()));
// A native monument recreates its rooms from seed when loading. Verify that path before accepting it.
result.structurePlan25().orElseThrow().restore(context, result);
return result;
}
/** Public for native diagnostics: only accepted starts are returned; no registry or chunk mutation. */
public static Accepted plan(ExpansionChunkGenerator host, ExpansionChunkGenerator view, ExpansionIsland island,
Candidate candidate, RegistryAccess registries, StructureTemplateManager templates, ResourceKey<Level> dimension,
ChunkAccess chunk, long deadlineNanos) {
try { return planFiltered(host, view, island, candidate, registries, templates, dimension, chunk, deadlineNanos, entry -> generalSelection(island, entry)); }
catch (SampleBudgetExceeded refused) { diagnostic(island, candidate, "terrain-budget-refused", ""); return null; }
}
/** Pure type-specific diagnostic, using the production admission rules and no live chunk generation. */
public static Accepted preview(ServerLevel level, ExpansionIsland island, String structureId, long deadlineNanos) {
return preview(level, island, structureId, deadlineNanos, false);
}
/** Finds a site that the full production selection actually chooses, useful for full lifecycle diagnostics. */
public static Accepted previewSelected(ServerLevel level, ExpansionIsland island, String structureId, long deadlineNanos) {
return preview(level, island, structureId, deadlineNanos, true);
}
/** One explicit candidate for root25 conditional planners; no chunk request or publication. */
public static Accepted previewAt(ServerLevel level, ExpansionIsland island, String structureId, Candidate candidate, long deadlineNanos) {
var host = (ExpansionChunkGenerator) level.getChunkSource().getGenerator();
return previewAt(level, host, island, structureId, candidate, deadlineNanos);
}
/** Same admission with an explicit, already prepared host, including isolated native diagnostics. */
public static Accepted previewAt(ServerLevel level, ExpansionChunkGenerator host, ExpansionIsland island,
String structureId, Candidate candidate, long deadlineNanos) {
var view = new NativeView(host, island, isolatedSource(host, island, level.registryAccess()), level.registryAccess());
var chunk = new ProtoChunk(candidate.chunk(), UpgradeData.EMPTY, level, PalettedContainerFactory.create(level.registryAccess()), null);
try {
return planFiltered(host, view, island, candidate, level.registryAccess(), level.getStructureTemplateManager(),
level.dimension(), chunk, deadlineNanos, entry -> entry.id().equals(structureId));
} catch (SampleBudgetExceeded refused) { return null; }
}
/** Only the initial25 conditional mine planner uses piece-wise hydrology; historical/general admission stays unchanged. */
public static Accepted previewMine25(ServerLevel level, SanctuaryChunkGenerator host, ExpansionIsland island,
Candidate candidate, long deadlineNanos) {
if (!island.origin() || island.generation() != 25 || host.rootGeneration() != 25)
throw new IllegalArgumentException("The conditional mine preview requires the initial25 island");
var view = new NativeView(host, island, isolatedSource(host, island, level.registryAccess()), level.registryAccess(), Integer.MAX_VALUE, true);
var chunk = new ProtoChunk(candidate.chunk(), UpgradeData.EMPTY, level, PalettedContainerFactory.create(level.registryAccess()), null);
try {
return planFiltered(host, view, island, candidate, level.registryAccess(), level.getStructureTemplateManager(),
level.dimension(), chunk, deadlineNanos, entry -> entry.id().equals("minecraft:mineshaft"));
} catch (SampleBudgetExceeded refused) { return null; }
}
/** Lower25 marshes use their actual local shore, while all certification columns remain complete. */
public static Accepted previewAtWetland25(ServerLevel level, SanctuaryChunkGenerator host, ExpansionIsland island,
Candidate candidate, fr.koka.sanctuary.worldgen.Terrain25Wetlands.Column shore, long deadlineNanos) {
if (!island.origin() || island.generation() != 25 || host.rootGeneration() != 25 || !shore.underground())
throw new IllegalArgumentException("A lower wetland preview requires an initial25 underground marsh");
int x = candidate.chunk().getMiddleBlockX(), z = candidate.chunk().getMiddleBlockZ();
if (shore.x() != x || shore.z() != z || !fr.koka.sanctuary.worldgen.Terrain25Runtime.wetlandAt(
host.delegate(island), host.randomState(island), x, shore.floorY() + 1, z)) return null;
int ceiling = shore.waterY() + 4;
var view = new NativeView(host, island, isolatedSource(host, island, level.registryAccess()), level.registryAccess(), ceiling);
var chunk = new ProtoChunk(candidate.chunk(), UpgradeData.EMPTY, level, PalettedContainerFactory.create(level.registryAccess()), null);
var column = view.getBaseColumn(x, z, chunk, view.randomState(island));
if (new Ground(column, chunk.getMinY(), ceiling).top != shore.floorY()) {
diagnostic(island, candidate, "hut25:local-shore-height-mismatch", "minecraft:swamp_hut"); return null;
}
try {
return planFiltered(host, view, island, candidate, level.registryAccess(), level.getStructureTemplateManager(),
level.dimension(), chunk, deadlineNanos, entry -> entry.id().equals("minecraft:swamp_hut"));
} catch (SampleBudgetExceeded refused) { return null; }
}
private static Accepted preview(ServerLevel level, ExpansionIsland island, String structureId, long deadlineNanos, boolean selectedOnly) {
var host = (ExpansionChunkGenerator) level.getChunkSource().getGenerator();
ExpansionBiomeSource source = isolatedSource(host, island, level.registryAccess());
var view = new NativeView(host, island, source, level.registryAccess());
var factory = PalettedContainerFactory.create(level.registryAccess());
for (Candidate candidate : candidates(island)) {
checkDeadline(deadlineNanos);
var chunk = new ProtoChunk(candidate.chunk(), UpgradeData.EMPTY, level, factory, null);
try {
Accepted accepted = planFiltered(host, view, island, candidate, level.registryAccess(), level.getStructureTemplateManager(),
level.dimension(), chunk, deadlineNanos, entry -> selectedOnly ? generalSelection(island, entry) : entry.id().equals(structureId));
if (accepted != null && accepted.structure().unwrapKey().orElseThrow().identifier().toString().equals(structureId)) return accepted;
} catch (SampleBudgetExceeded refused) { /* An unsuitable candidate does not suppress the following sites. */ }
}
return null;
}
/** The initial25 village belongs to the conditional underground planner; expansion and explicit preview catalogues stay complete. */
private static boolean generalSelection(ExpansionIsland island, Entry entry) {
return island.generation() != 25 || !island.origin() || !entry.family().equals("village");
}
private static Accepted planFiltered(ExpansionChunkGenerator host, ExpansionChunkGenerator view, ExpansionIsland island,
Candidate candidate, RegistryAccess registries, StructureTemplateManager templates, ResourceKey<Level> dimension,
ChunkAccess chunk, long deadlineNanos, Predicate<Entry> included) {
RandomState random = view.randomState(island);
GroundSampler ground = new GroundSampler(view, chunk, random, deadlineNanos);
int x = candidate.chunk().getMiddleBlockX(), z = candidate.chunk().getMiddleBlockZ();
Ground centre = ground.at(x, z);
if (centre.top < 32) return null;
var sampler = random.createClimateSampler(SamplerContext.builder().enableCaches().build());
var resolver = view.getBiomeSource().createResolver(sampler);
int undergroundY = Math.clamp(centre.top - 60, 50, 210);
Holder<Biome> surfaceBiome = host.structureBiome(resolver.getNoiseBiome(x >> 2, centre.top >> 2, z >> 2));
Holder<Biome> undergroundBiome = host.structureBiome(resolver.getNoiseBiome(x >> 2, undergroundY >> 2, z >> 2));
int seaLevel = ExpansionOcean23.active(island) ? ExpansionOcean23.seaLevel(island) : -64;
Holder<Biome> waterBiome = host.structureBiome(resolver.getNoiseBiome(x >> 2, Math.max(0, seaLevel - 8) >> 2, z >> 2));
var registry = registries.lookupOrThrow(Registries.STRUCTURE);
var eligible = new ArrayList<Choice>();
for (Entry entry : catalogue()) {
if (entry.availability() != Availability.OVERWORLD || !sizeEligible(entry, island) || !included.test(entry)) continue;
Holder<Structure> holder = registry.get(Identifier.parse(entry.id())).orElse(null);
if (holder == null) continue;
Holder<Biome> biome = entry.habitat() == Habitat.UNDERGROUND ? undergroundBiome
: entry.habitat() == Habitat.OCEAN ? waterBiome : surfaceBiome;
if (!holder.value().biomes().contains(biome)) continue;
if (entry.habitat() == Habitat.OCEAN && !ExpansionOcean23.column(island, x, z).water()) continue;
long salt = mix(candidate.roll() ^ entry.id().hashCode() * 0x9e3779b97f4a7c15L);
double unit = ((salt >>> 11) + 1.0) * 0x1.0p-53;
int weight = weight(entry.family()) * (island.structurePriorities().contains(entry.family()) ? 4 : 1);
eligible.add(new Choice(entry, holder, -Math.log(unit) / weight));
}
eligible.sort(Comparator.comparingDouble(Choice::score).thenComparing(choice -> choice.entry().id()));
int attempts = 0;
for (Choice choice : eligible) {
if (++attempts > MAX_NATIVE_ATTEMPTS_PER_SITE) break;
checkDeadline(deadlineNanos);
Structure original = choice.holder().value();
Entry entry = choice.entry();
int variants = entry.habitat() == Habitat.UNDERGROUND || entry.family().equals("ruined_portal") ? 3 : 1;
for (int vertical = 0; vertical < variants; vertical++) {
checkDeadline(deadlineNanos);
int targetY = undergroundY - vertical * 20;
Structure generated = original;
if (entry.family().equals("trial_chambers")) generated = withStartHeight(original, registries, targetY);
Predicate<Holder<Biome>> valid = b -> original.biomes().contains(host.structureBiome(b));
StructureStart start;
if (original instanceof StrongholdStructure) {
// Vanilla stronghold piece weights are static mutable state.
synchronized (StrongholdStructure.class) {
start = generated.generate(choice.holder(), dimension, registries, view, view.getBiomeSource(), sampler,
random, templates, island.seed(), candidate.chunk(), 0, chunk, valid);
}
} else {
start = generated.generate(choice.holder(), dimension, registries, view, view.getBiomeSource(), sampler,
random, templates, island.seed(), candidate.chunk(), 0, chunk, valid);
}
if (!start.isValid()) { diagnostic(island, candidate, "native-refused", entry.id()); continue; }
// Only buried portal variants need the alternative underground elevations.
if (vertical > 0 && entry.family().equals("ruined_portal") && !undergroundPortal(start)) break;
start = adjustNativeStart(original, start, entry, island, targetY, ground);
String rejection = supportRejection(host, island, entry, start, ground);
if (rejection != null) { diagnostic(island, candidate, rejection, entry.id()); continue; }
((NativeStructureMetadata23) (Object) start).sanctuary$native23(new NativeStructureMetadata23.Data(island.seed(), seaLevel));
return new Accepted(choice.holder(), start);
}
}
return null;
}
/** Keep all pools, processors, aliases, loot, village logic and original registered identity. */
public static Structure withStartHeight(Structure original, RegistryAccess registries, int y) {
if (!(original instanceof JigsawStructure)) throw new IllegalArgumentException("Expected native jigsaw structure");
var ops = RegistryOps.create(JsonOps.INSTANCE, registries);
JsonObject json = Structure.DIRECT_CODEC.encodeStart(ops, original).getOrThrow().getAsJsonObject().deepCopy();
JsonObject height = new JsonObject(); height.addProperty("absolute", y);
json.add("start_height", height);
return Structure.DIRECT_CODEC.parse(ops, json).getOrThrow();
}
private static StructureStart adjustNativeStart(Structure original, StructureStart start, Entry entry, ExpansionIsland island,
int targetY, GroundSampler ground) {
var pieces = start.getPieces();
if (entry.family().equals("stronghold") || entry.family().equals("mineshaft") || undergroundPortal(start)) {
BoundingBox box = start.getBoundingBox();
int dy = targetY - (box.minY() + box.maxY()) / 2;
for (StructurePiece piece : pieces) NativeStructurePlacement23.move(piece, dy);
} else if (entry.family().equals("igloo")) {
// All shaft pieces share the surface entrance's projection. Save final template positions,
// rather than the vanilla temporary Y90 restored after each postProcess invocation.
var top = (net.minecraft.world.level.levelgen.structure.TemplateStructurePiece) pieces.getLast();
var entrance = top.templatePosition().offset(net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate
.calculateRelativePosition(top.placeSettings(), new BlockPos(3, 0, 0)));
int dy = ground.at(entrance.getX(), entrance.getZ()).top - 90;
for (StructurePiece piece : pieces) NativeStructurePlacement23.move(piece, dy);
} else if (entry.family().equals("desert_pyramid") || entry.family().equals("jungle_pyramid") || entry.family().equals("swamp_hut")) {
// Finalize vanilla's heightPosition before saving starts. Average the full footprint,
// avoiding the native dependence on whichever decorating chunk happens to arrive first.
for (StructurePiece piece : pieces) {
var box = piece.getBoundingBox(); int sum = 0, count = 0, lowest = Integer.MAX_VALUE;
for (int z = box.minZ(); z <= box.maxZ(); z++) for (int x = box.minX(); x <= box.maxX(); x++) {
int height = entry.family().equals("swamp_hut") && ground.localSurfaceCeiling != Integer.MAX_VALUE
? fr.koka.sanctuary.worldgen.underground25.SwampHutAdmission25.localNativeHeight(
ground.at(x, z).column, ground.level.getMinY(), ground.localSurfaceCeiling)
: ground.at(x, z).top + 1;
sum += height; count++; lowest = Math.min(lowest, height);
}
int height = entry.family().equals("desert_pyramid") ? lowest : sum / count;
int burial = entry.family().equals("desert_pyramid")
? (int) Long.remainderUnsigned(mix(island.seed() ^ start.getChunkPos().pack() ^ 0x231D35E47L), 3) : 0;
NativeStructurePlacement23.move(piece, height - burial - box.minY());
((fr.koka.sanctuary.mixin.ScatteredHeight23Accessor) piece).sanctuary$heightPosition(height);
}
} else if (entry.family().equals("monument")) {
int floor = Integer.MIN_VALUE;
BoundingBox box = start.getBoundingBox();
for (int z = box.minZ(); z <= box.maxZ(); z += 4) for (int x = box.minX(); x <= box.maxX(); x += 4)
floor = Math.max(floor, ExpansionOcean23.column(island, x, z).floorY());
int dy = floor + 1 - box.minY();
for (StructurePiece piece : pieces) NativeStructurePlacement23.move(piece, dy);
} else if (entry.family().equals("shipwreck") || entry.family().equals("ocean_ruin") || entry.family().equals("buried_treasure")) {
// Vanilla adjusts these templates during decoration; use the same terrain floor now for valid starts/references.
for (StructurePiece piece : pieces) {
var box = piece.getBoundingBox();
int x = box.getCenter().getX(), z = box.getCenter().getZ();
int floor = entry.habitat() == Habitat.OCEAN ? ExpansionOcean23.column(island, x, z).floorY() : ground.at(x, z).top;
if (piece instanceof net.minecraft.world.level.levelgen.structure.structures.ShipwreckPieces.ShipwreckPiece ship) {
// Shipwreck.generate may already change templatePosition without refreshing its bounding box.
// Set and certify the final height now instead of translating that stale Y90 box twice.
int min = Integer.MAX_VALUE, sum = 0, count = 0;
var size = ship.template().getSize();
var at = ship.templatePosition();
for (int dz = 0; dz < size.getZ(); dz++) for (int dx = 0; dx < size.getX(); dx++) {
int height = entry.habitat() == Habitat.OCEAN
? ExpansionOcean23.column(island, at.getX() + dx, at.getZ() + dz).floorY() + 1
: ground.at(at.getX() + dx, at.getZ() + dz).top + 1;
min = Math.min(min, height); sum += height; count++;
}
int height = count == 0 ? floor + 1 : entry.habitat() == Habitat.SHORE
? ship.calculateBeachedPosition(min, net.minecraft.util.RandomSource.create(island.seed() ^ start.getChunkPos().pack())) : sum / count;
ship.adjustPositionHeight(height);
var actual = ship.template().getBoundingBox(ship.placeSettings(), ship.templatePosition());
box.move(0, actual.minY() - box.minY(), 0);
} else {
NativeStructurePlacement23.move(piece, floor + 1 - box.minY());
}
}
}
// Cloned generators and moved bounding boxes must never escape as a saved registry identity or cached pre-move box.
return new StructureStart(original, start.getChunkPos(), start.getReferences(), new PiecesContainer(pieces));
}
private static String supportRejection(ExpansionChunkGenerator host, ExpansionIsland island, Entry entry,
StructureStart start, GroundSampler ground) {
BoundingBox total = start.getBoundingBox();
if (island.origin() && island.generation() == 25 && host instanceof SanctuaryChunkGenerator sanctuary
&& fr.koka.sanctuary.worldgen.underground25.UndergroundStructures25.plan(sanctuary).intersects(
new BoundingBox(total.minX() - 8, total.minY() - 16, total.minZ() - 8, total.maxX() + 8, total.maxY() + 8, total.maxZ() + 8)))
return "reserved-underground25";
if (island.structurePlan25().isPresent() && island.structurePlan25().get().conflicts(
new BoundingBox(total.minX() - 8, total.minY() - 16, total.minZ() - 8, total.maxX() + 8, total.maxY() + 8, total.maxZ() + 8)))
return "reserved-monument25";
if (island.origin() && entry.habitat() != Habitat.UNDERGROUND && !undergroundPortal(start)) {
int nearestX = Math.clamp(island.centerX(), total.minX(), total.maxX());
int nearestZ = Math.clamp(island.centerZ(), total.minZ(), total.maxZ());
if (Math.hypot((double) nearestX - island.centerX(), (double) nearestZ - island.centerZ()) < 56) return "natural-arrival-buffer";
}
if (total.minY() < ground.level.getMinY() + 3 || total.maxY() > ground.level.getMaxY() - 4) return "outside-height";
if (!inside(island, total.minX(), total.minZ(), 3) || !inside(island, total.maxX(), total.minZ(), 3)
|| !inside(island, total.minX(), total.maxZ(), 3) || !inside(island, total.maxX(), total.maxZ(), 3)) return "outside-island";
// Vanilla reference discovery scans eight chunks around the decorating chunk.
if (total.minX() < start.getChunkPos().getMinBlockX() - 128 || total.maxX() > start.getChunkPos().getMaxBlockX() + 128
|| total.minZ() < start.getChunkPos().getMinBlockZ() - 128 || total.maxZ() > start.getChunkPos().getMaxBlockZ() + 128) return "outside-native-reference-range";
if (host instanceof SanctuaryChunkGenerator city) {
if (city.lostCityPlan(island).intersects(total.minX(), total.minZ(), total.maxX(), total.maxZ())) return "occupied-city";
if (ground.localCity == null) {
var local = new fr.koka.sanctuary.worldgen.city.LostCityProtection16.Builder();
local.add(city.lostCityPlan(island), city.undergroundPlan16(island));
for (var support : city.citySupports16(island)) local.addViaductSupport(support);
var reservation = city.cityReservation16(island);
if (reservation != null) local.addReservation(reservation);
ground.localCity = local.build();
}
if (ground.localCity.intersects(total.minX() - 2, total.minY() - 12, total.minZ() - 2, total.maxX() + 2, total.maxY() + 4, total.maxZ() + 2)
|| city.intersectsCityConstruction15(total.minX() - 2, total.minY() - 12, total.minZ() - 2, total.maxX() + 2, total.maxY() + 4, total.maxZ() + 2)
|| city.intersectsCityConstruction16(total.minX() - 2, total.minY() - 12, total.minZ() - 2, total.maxX() + 2, total.maxY() + 4, total.maxZ() + 2)
|| city.intersectsExperimentalStructures(total.minX() - 2, total.minY() - 12, total.minZ() - 2, total.maxX() + 2, total.maxY() + 4, total.maxZ() + 2)) return "reserved-secret";
if (island.origin()) {
var binding = city.starterContext();
if (island.generation() == 25 && entry.family().equals("swamp_hut")) {
String shore = fr.koka.sanctuary.worldgen.underground25.SwampHutAdmission25.rejection(city, island, start,
(x, z) -> ground.at(x, z).column, ground.localSurfaceCeiling != Integer.MAX_VALUE);
if (shore != null) return shore;
} else if (island.generation() == 25 && entry.family().equals("mineshaft") && ground.preciseMineHydrology25) {
if (ground.mineHydrology25 == null) ground.mineHydrology25 = new fr.koka.sanctuary.worldgen.underground25.MineHydrologyAdmission25(binding);
var check = ground.mineHydrology25.check(start);
if (DIAGNOSTICS) SanctuaryMod.LOGGER.info("Native25 mine hydrology seed {} chunk {} pieces {} probes {} refusal {} at {}",
island.seed(), start.getChunkPos(), start.getPieces().size(), check.probes(), check.rejection(), check.at());
if (check.rejection() != null) return check.rejection();
} else if (fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime.protects(binding.generator(), binding.random(),
total.getCenter().atY(total.minY()), Math.max(total.getXSpan(), total.getZSpan()) / 2 + 3, total.getYSpan() + 16, 26)) return "protected-hydrology";
}
}
boolean underground = entry.habitat() == Habitat.UNDERGROUND || undergroundPortal(start);
boolean aquatic = entry.habitat() == Habitat.OCEAN;
int siteMin = Integer.MAX_VALUE, siteMax = Integer.MIN_VALUE;
for (StructurePiece piece : start.getPieces()) {
BoundingBox box = piece.getBoundingBox();
int low = Integer.MAX_VALUE, high = Integer.MIN_VALUE, supported = 0, sampled = 0;
boolean buriedPiece = underground || !aquatic && box.maxY() < ground.at(box.getCenter().getX(), box.getCenter().getZ()).top - 6;
int step = buriedPiece ? 8 : 4;
for (int z : samples(box.minZ(), box.maxZ(), step)) for (int x : samples(box.minX(), box.maxX(), step)) {
if (!inside(island, x, z, 3)) return "footprint-outside-island";
sampled++;
if (aquatic) {
var ocean = ExpansionOcean23.column(island, x, z);
if (!ocean.water() || ocean.depth() < (entry.family().equals("monument") ? 26 : 5)) return "insufficient-ocean";
if (entry.family().equals("monument") && (box.maxY() >= ocean.waterLevel() - 1 || box.minY() - ocean.floorY() > 12)) return "monument-depth-or-foundation";
if (!ground.at(x, z).solid(ocean.floorY() - 3)) return "ocean-floor-not-supported";
continue;
}
Ground column = ground.at(x, z);
if (buriedPiece) {
boolean floor = column.solidNear(box.minY() - 1, 6);
boolean roof = column.solidNear(box.maxY() + 1, 6);
if (column.top >= box.maxY() + 5 && floor && roof) supported++;
continue;
}
if (column.top < 40 || column.thickness < (entry.family().endsWith("pyramid") ? 18 : 8)) return "unsupported-foundation";
if (entry.family().equals("trail_ruins") && !column.solidNear(box.minY() - 1, 4)) return "unsupported-buried-ruin";
int maxBurial = entry.family().equals("trail_ruins") || entry.family().endsWith("pyramid") ? 32 : 12;
int foundationY = entry.family().equals("mansion") ? total.minY() : box.minY();
if (foundationY > column.top + 10 || foundationY < column.top - maxBurial) return "surface-height-mismatch";
low = Math.min(low, column.top); high = Math.max(high, column.top);
if (high - low > (entry.family().endsWith("pyramid") ? 5 : 9)) return "surface-slope";
}
if (buriedPiece && supported < Math.ceil(sampled * (entry.family().equals("mineshaft") ? 0.65 : 0.85))) return "cavity-not-enclosed";
if (!buriedPiece && !aquatic) {
siteMin = Math.min(siteMin, low); siteMax = Math.max(siteMax, high);
if (siteMax - siteMin > 20) return "site-slope";
}
}
return null;
}
/** A real-start search for all vanilla keys/tags. No ring-grid fallback and no unactivated island probe. */
public static Pair<BlockPos, Holder<Structure>> findNearest(ServerLevel level, HolderSet<Structure> wanted,
BlockPos origin, int radius, boolean skipReferenced) {
if (!(level.getChunkSource().getGenerator() instanceof ExpansionChunkGenerator generator)
|| !nativeGeneration(generator.rootGeneration()) || !level.structureManager().shouldGenerateStructures()) return null;
boolean nativeRequested = wanted.stream().anyMatch(h -> {
var e = ExpansionStructureCatalog23.entry(h.unwrapKey().orElseThrow().identifier().toString());
return e != null && e.availability() == Availability.OVERWORLD;
});
boolean sanctuaryRequested = wanted.stream().anyMatch(h -> h.unwrapKey().orElseThrow().identifier().getNamespace().equals("sanctuary"));
var candidates = new ArrayList<Candidate>();
for (ExpansionIsland island : generator.source().islands()) {
if (!nativeRequested || !nativeGeneration(island.generation()) || !island.status().equals("ready")) continue;
for (Candidate candidate : candidates(island)) {
if (Math.abs((long) candidate.chunk().x() - (origin.getX() >> 4)) <= (long) radius * CELL_CHUNKS
&& Math.abs((long) candidate.chunk().z() - (origin.getZ() >> 4)) <= (long) radius * CELL_CHUNKS) candidates.add(candidate);
}
}
if (sanctuaryRequested && generator instanceof SanctuaryChunkGenerator sanctuary) addSanctuaryCandidates(sanctuary, candidates);
if (generator.rootGeneration() == 25 && nativeRequested && generator instanceof SanctuaryChunkGenerator sanctuary)
for (var site : fr.koka.sanctuary.worldgen.underground25.UndergroundStructures25.plan(sanctuary).starts())
addPosition(candidates, site.start().getChunkPos().x(), site.start().getChunkPos().z());
candidates.removeIf(c -> Math.abs((long) c.chunk().x() - (origin.getX() >> 4)) > (long) radius * CELL_CHUNKS
|| Math.abs((long) c.chunk().z() - (origin.getZ() >> 4)) > (long) radius * CELL_CHUNKS);
candidates = new ArrayList<>(candidates.stream().collect(java.util.stream.Collectors.toMap(
c -> c.chunk().pack(), c -> c, (first, second) -> first)).values());
candidates.sort(Comparator.comparingDouble((Candidate c) -> horizontalDistanceSquared(origin, c.chunk().getMiddleBlockX(), c.chunk().getMiddleBlockZ()))
.thenComparingInt(c -> c.chunk().x()).thenComparingInt(c -> c.chunk().z()));
Holder<Structure> bestHolder = null; StructureStart best = null; ChunkAccess bestChunk = null;
BlockPos bestPos = null; double bestDistance = Double.POSITIVE_INFINITY;
for (Candidate candidate : candidates) {
// Every admitted start is within the native 128-block reference range of its start chunk.
double minimumDistance = Math.max(0, Math.sqrt(horizontalDistanceSquared(origin, candidate.chunk().getMiddleBlockX(), candidate.chunk().getMiddleBlockZ())) - 192);
if (!sanctuaryRequested && generator.rootGeneration() != 25 && minimumDistance * minimumDistance > bestDistance) break;
var chunk = level.getChunk(candidate.chunk().x(), candidate.chunk().z(), ChunkStatus.STRUCTURE_STARTS, true);
for (Holder<Structure> holder : wanted) {
Entry entry = ExpansionStructureCatalog23.entry(holder.unwrapKey().orElseThrow().identifier().toString());
if (entry != null && entry.availability() != Availability.OVERWORLD) continue;
StructureStart start = level.structureManager().getStartForStructure(holder.value(), chunk);
if (start == null || !start.isValid() || skipReferenced && !start.canBeReferenced()) continue;
BlockPos position = locator(start, origin);
double distance = horizontalDistanceSquared(origin, position.getX(), position.getZ());
if (distance < bestDistance) { best = start; bestChunk = chunk; bestPos = position; bestHolder = holder; bestDistance = distance; }
}
}
if (best == null) return null;
// Mutate only the final winner, including when the request is a tag containing several structures.
if (skipReferenced) {
level.structureManager().addReference(best);
// Vanilla updates the start and its in-memory presence cache, not the
// chunk's save flag. A distant READY chunk may already be clean.
bestChunk.markUnsaved();
}
return Pair.of(bestPos, bestHolder);
}
private static void addSanctuaryCandidates(SanctuaryChunkGenerator generator, List<Candidate> positions) {
for (ExpansionIsland island : generator.source().islands()) {
if (!nativeGeneration(island.generation()) || !island.status().equals("ready")) continue;
for (var district : generator.lostCityPlan(island).districts()) addPosition(positions, district.startChunkX(), district.startChunkZ());
}
generator.heritagePlan19().sites().forEach(s -> addPosition(positions, s.startChunkX(), s.startChunkZ()));
generator.heritagePlan19().segments().forEach(s -> addPosition(positions, s.startChunkX(), s.startChunkZ()));
generator.ruinsPlan17().networks().forEach(s -> addPosition(positions, s.startChunkX(), s.startChunkZ()));
generator.transitPlan21().modules().forEach(s -> addPosition(positions, s.startChunkX(), s.startChunkZ()));
generator.undergroundHall22().networks().forEach(s -> addPosition(positions, s.startChunkX(), s.startChunkZ()));
var mineral = generator.mineralSanctuary23();
if (!mineral.isEmpty()) addPosition(positions, mineral.chamber().startChunkX(), mineral.chamber().startChunkZ());
if (generator.rootGeneration() == 24 || generator.rootGeneration() == 25) generator.aerialPlan24().sites()
.forEach(site -> addPosition(positions, site.startChunkX(), site.startChunkZ()));
}
private static void addPosition(List<Candidate> positions, int x, int z) { positions.add(new Candidate(new ChunkPos(x, z), 0)); }
private static BlockPos locator(StructureStart start, BlockPos origin) {
var entrances = new ArrayList<StructurePiece>();
for (StructurePiece piece : start.getPieces()) {
if (piece instanceof fr.koka.sanctuary.worldgen.city.LostCityUndergroundPiece20 room
&& room.module().kind() == fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Kind.ACCESS) entrances.add(piece);
if (piece instanceof fr.koka.sanctuary.worldgen.transit.TransitPiece21 tunnel
&& tunnel.module().kind() == fr.koka.sanctuary.worldgen.transit.TransitPlan21.Kind.ACCESS) entrances.add(piece);
if (piece instanceof fr.koka.sanctuary.worldgen.ruins.RuinsPiece20 room
&& room.room().kind() == fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.Kind.ACCESS) entrances.add(piece);
}
return entrances.stream().map(StructurePiece::getLocatorPosition).min(Comparator.comparingDouble(origin::distSqr))
.orElseGet(() -> start.getPieces().getFirst().getLocatorPosition());
}
private static boolean undergroundPortal(StructureStart start) {
if (!(start.getPieces().getFirst() instanceof net.minecraft.world.level.levelgen.structure.structures.RuinedPortalPiece portal)) return false;
var placement = ((fr.koka.sanctuary.mixin.RuinedPortal23Accessor) portal).sanctuary$verticalPlacement();
return placement == net.minecraft.world.level.levelgen.structure.structures.RuinedPortalPiece.VerticalPlacement.UNDERGROUND
|| placement == net.minecraft.world.level.levelgen.structure.structures.RuinedPortalPiece.VerticalPlacement.IN_MOUNTAIN;
}
private static boolean sizeEligible(Entry entry, ExpansionIsland island) {
return switch (entry.family()) {
case "mansion", "trial_chambers", "stronghold" -> island.diameter() >= 256;
case "village", "monument" -> island.diameter() >= 256;
default -> true;
};
}
private static int weight(String family) {
return switch (family) { case "camp" -> 6; case "ruined_portal", "mineshaft", "ocean_ruin" -> 3;
case "village", "shipwreck", "trail_ruins", "buried_treasure" -> 2; default -> 1; };
}
private static int[] samples(int low, int high, int step) {
var values = new java.util.TreeSet<Integer>(); values.add(low); values.add(high); values.add((low + high) / 2);
for (int value = low; value <= high; value += step) values.add(value);
return values.stream().mapToInt(Integer::intValue).toArray();
}
private static boolean inside(ExpansionIsland island, int x, int z, int margin) {
return Math.hypot((double) x - island.centerX(), (double) z - island.centerZ()) < island.terrainRadius() - margin;
}
private static double horizontalDistanceSquared(BlockPos origin, int x, int z) { double dx = (double) x - origin.getX(), dz = (double) z - origin.getZ(); return dx * dx + dz * dz; }
private static long mix(long value) { value = (value ^ value >>> 30) * 0xbf58476d1ce4e5b9L; value = (value ^ value >>> 27) * 0x94d049bb133111ebL; return value ^ value >>> 31; }
private static void checkDeadline(long deadline) { if (System.nanoTime() > deadline) throw new IllegalArgumentException("search_timeout"); }
private static void diagnostic(ExpansionIsland island, Candidate candidate, String reason, String id) {
if (DIAGNOSTICS) SanctuaryMod.LOGGER.info("Native23 {} seed {} chunk {} {} {}", island.id(), island.seed(), candidate.chunk(), reason, id);
}
private record Choice(Entry entry, Holder<Structure> holder, double score) {}
private static final class Ground {
final NoiseColumn column; final int top; final int thickness;
Ground(NoiseColumn column, int min, int max) {
this.column = column; int y = max;
while (y >= min && !column.getBlock(y).isSolid()) y--;
top = y;
while (y >= min && top - y < 24 && solid(y)) y--;
thickness = top - y;
}
boolean solid(int y) { var block = column.getBlock(y); return block.isSolid() && block.getFluidState().isEmpty(); }
boolean solidNear(int y, int tolerance) { for (int dy = -tolerance; dy <= tolerance; dy++) if (solid(y + dy)) return true; return false; }
}
private static final class GroundSampler {
final ExpansionChunkGenerator generator; final LevelHeightAccessor level; final RandomState random; final long deadline;
final int localSurfaceCeiling;
final boolean preciseMineHydrology25;
fr.koka.sanctuary.worldgen.underground25.MineHydrologyAdmission25 mineHydrology25;
final Map<Long, Ground> columns = new HashMap<>();
fr.koka.sanctuary.worldgen.city.LostCityProtection16 localCity;
GroundSampler(ExpansionChunkGenerator generator, LevelHeightAccessor level, RandomState random, long deadline) {
this.generator = generator; this.level = level; this.random = random; this.deadline = deadline;
localSurfaceCeiling = generator instanceof NativeView view ? view.localSurfaceCeiling : Integer.MAX_VALUE;
preciseMineHydrology25 = generator instanceof NativeView view && view.preciseMineHydrology25;
}
Ground at(int x, int z) {
long key = ((long) x << 32) ^ (z & 0xffffffffL);
Ground old = columns.get(key); if (old != null) return old;
checkDeadline(deadline);
if (columns.size() >= MAX_COLUMN_SAMPLES) throw new SampleBudgetExceeded();
Ground result = new Ground(generator.getBaseColumn(x, z, level, random), level.getMinY(), Math.min(level.getMaxY(), localSurfaceCeiling));
columns.put(key, result); return result;
}
}
private static ExpansionBiomeSource isolatedSource(ExpansionChunkGenerator host, ExpansionIsland island, RegistryAccess registries) {
ExpansionBiomeSource isolated;
if (host.source() instanceof fr.koka.sanctuary.worldgen.SanctuaryBiomeSource source) {
var copy = source.copy();
if (host instanceof SanctuaryChunkGenerator sanctuary) copy.setCityPlans(Map.of(island.id(), sanctuary.lostCityPlan(island)));
isolated = copy;
} else {
List<Holder<Biome>> biomes = registries.lookupOrThrow(Registries.BIOME).listElements().map(h -> (Holder<Biome>) h).toList();
isolated = new ExpansionBiomeSource(biomes);
}
isolated.setIslands(List.of(island));
return isolated;
}
/** Isolated biome snapshot during preflight, per-island sea level during native generation. */
private static final class NativeView extends ExpansionChunkGenerator {
private final ExpansionChunkGenerator host; private final ExpansionIsland island;
private final int localSurfaceCeiling;
private final boolean preciseMineHydrology25;
NativeView(ExpansionChunkGenerator host, ExpansionIsland island, ExpansionBiomeSource source, RegistryAccess registries) {
this(host, island, source, registries, Integer.MAX_VALUE);
}
NativeView(ExpansionChunkGenerator host, ExpansionIsland island, ExpansionBiomeSource source, RegistryAccess registries, int localSurfaceCeiling) {
this(host, island, source, registries, localSurfaceCeiling, false);
}
NativeView(ExpansionChunkGenerator host, ExpansionIsland island, ExpansionBiomeSource source, RegistryAccess registries,
int localSurfaceCeiling, boolean preciseMineHydrology25) {
super(source, host.initialDiameter(), true); this.host = host; this.island = island; this.registries = registries;
this.localSurfaceCeiling = localSurfaceCeiling; this.preciseMineHydrology25 = preciseMineHydrology25;
}
@Override public int rootGeneration() { return island.generation(); }
@Override public Holder<Biome> structureBiome(Holder<Biome> biome) { return host.structureBiome(biome); }
@Override public int getSeaLevel() { return ExpansionOcean23.active(island) ? ExpansionOcean23.seaLevel(island) : 192; }
@Override public RandomState randomState(ExpansionIsland candidate) { return island.origin() ? host.randomState(candidate) : super.randomState(candidate); }
@Override public int getBaseHeight(int x, int z, Heightmap.Types type, LevelHeightAccessor level, RandomState random) {
if (localSurfaceCeiling != Integer.MAX_VALUE) {
var column = getBaseColumn(x, z, level, random);
for (int y = Math.min(level.getMaxY(), localSurfaceCeiling); y >= level.getMinY(); y--)
if (type.isOpaque().test(column.getBlock(y))) return y + 1;
return level.getMinY();
}
return island.origin() ? host.getBaseHeight(x, z, type, level, random) : super.getBaseHeight(x, z, type, level, random);
}
@Override public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor level, RandomState random) {
return island.origin() ? host.getBaseColumn(x, z, level, random) : super.getBaseColumn(x, z, level, random);
}
}
}
@@ -86,6 +86,12 @@ public final class ExpansionVoidReuse {
if (!(level.getChunkSource().getGenerator() instanceof ExpansionChunkGenerator generator)
|| !ExpansionIsland.spatialGeneration(generator.rootGeneration()) || island.generation() != generator.rootGeneration() || island.origin())
throw new IOException("Void reuse is restricted to a matching generation-14/15 expansion");
if ((generator.rootGeneration() == 24 || generator.rootGeneration() == 25) && generator instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator sanctuary
&& sanctuary.aerialPlan24().conflicts(island))
throw new ExpansionAdmission.OccupiedException("aerial_reserved: expansion overlaps a permanent initial site");
if (generator.rootGeneration() == 25 && generator instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator sanctuary
&& ExpansionInitialFlows25.conflicts(sanctuary, island))
throw new ExpansionAdmission.OccupiedException("initial_waterfall_reserved: expansion overlaps a future initial river outlet");
List<ChunkPos> positions = positions(island);
var entityStorage = entityRegion(level);
var poiStorage = ((ExpansionVoidPoiStorageAccess) level.getPoiManager()).sanctuary$simpleRegionStorage();
@@ -173,6 +179,12 @@ public final class ExpansionVoidReuse {
public static void open(ServerLevel level, ExpansionChunkGenerator generator, List<ExpansionIsland> islands) throws IOException {
requireServerThread(level); close(level);
if (!ExpansionIsland.spatialGeneration(generator.rootGeneration())) return;
if ((generator.rootGeneration() == 24 || generator.rootGeneration() == 25) && generator instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator sanctuary)
for (var island : islands) if (sanctuary.aerialPlan24().conflicts(island))
throw new IOException("Saved expansion overlaps a permanent aerial site: " + island.id());
if (generator.rootGeneration() == 25 && generator instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator sanctuary)
for (var island : islands) if (ExpansionInitialFlows25.conflicts(sanctuary, island))
throw new IOException("Saved expansion overlaps an initial river outlet: " + island.id());
var state = new State();
for (var island : islands) if (!island.reuseToken().isEmpty()) add(state, loadManifest(level, island));
// Validate every transaction before writing even an EMPTY placeholder or publishing any island.
@@ -0,0 +1,8 @@
package fr.koka.sanctuary.expansion;
/** Optional metadata on native starts, absent on all historical and ordinary Minecraft starts. */
public interface NativeStructureMetadata23 {
record Data(long islandSeed, int seaLevel) {}
Data sanctuary$native23();
void sanctuary$native23(Data data);
}
@@ -0,0 +1,20 @@
package fr.koka.sanctuary.expansion;
import fr.koka.sanctuary.mixin.MonumentChildren23Accessor;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces;
/** Scoped native placement parameters; never change the sea level of another dimension or start. */
public final class NativeStructurePlacement23 {
private static final ThreadLocal<Integer> SEA_LEVEL = new ThreadLocal<>();
private NativeStructurePlacement23() {}
public static Integer seaLevel() { return SEA_LEVEL.get(); }
public static Integer enter(int seaLevel) { Integer previous = SEA_LEVEL.get(); SEA_LEVEL.set(seaLevel); return previous; }
public static void restore(Integer previous) { if (previous == null) SEA_LEVEL.remove(); else SEA_LEVEL.set(previous); }
public static void move(StructurePiece piece, int dy) {
if (piece instanceof OceanMonumentPieces.MonumentBuilding monument) {
for (StructurePiece child : ((MonumentChildren23Accessor) monument).sanctuary$children()) child.move(0, dy, 0);
}
piece.move(0, dy, 0);
}
}
@@ -0,0 +1,17 @@
package fr.koka.sanctuary.knowledge;
/** A server-side snapshot; mined and dropped are read from vanilla statistics. */
public record BlockKnowledge(String blockId, boolean seen, boolean possessed,
long mined, long dropped, long placed, int carried) {
public BlockKnowledge {
BlockKnowledgeMemory.requireBlockId(blockId);
if (mined < 0 || dropped < 0 || placed < 0 || carried < 0) {
throw new IllegalArgumentException("Block knowledge counters cannot be negative");
}
}
/** Unknown is the absence of all evidence, rather than another persisted flag. */
public boolean known() {
return seen || possessed || mined > 0 || dropped > 0 || placed > 0 || carried > 0;
}
}
@@ -0,0 +1,22 @@
package fr.koka.sanctuary.knowledge;
import net.fabricmc.fabric.api.event.Event;
import net.fabricmc.fabric.api.event.EventFactory;
import net.minecraft.server.level.ServerPlayer;
/** Server-thread notifications for future consumers; not a durable reward ledger. */
public final class BlockKnowledgeEvents {
public enum Activity { SEEN, POSSESSED, MINED, DROPPED, PLACED, INVENTORY }
@FunctionalInterface
public interface Changed {
void onChanged(ServerPlayer player, BlockKnowledge knowledge, Activity activity);
}
public static final Event<Changed> CHANGED = EventFactory.createArrayBacked(Changed.class,
listeners -> (player, knowledge, activity) -> {
for (Changed listener : listeners) listener.onChanged(player, knowledge, activity);
});
private BlockKnowledgeEvents() {}
}
@@ -0,0 +1,25 @@
package fr.koka.sanctuary.knowledge;
import java.util.function.Predicate;
/** Independent evidence predicates, composable with {@link Predicate#and} and {@link Predicate#or}. */
public enum BlockKnowledgeFacet implements Predicate<BlockKnowledge> {
KNOWN(BlockKnowledge::known),
SEEN(BlockKnowledge::seen),
MINED(knowledge -> knowledge.mined() > 0),
POSSESSED(BlockKnowledge::possessed),
DROPPED(knowledge -> knowledge.dropped() > 0),
PLACED(knowledge -> knowledge.placed() > 0),
CARRIED(knowledge -> knowledge.carried() > 0);
private final Predicate<BlockKnowledge> evidence;
BlockKnowledgeFacet(Predicate<BlockKnowledge> evidence) {
this.evidence = evidence;
}
@Override
public boolean test(BlockKnowledge knowledge) {
return evidence.test(knowledge);
}
}
@@ -0,0 +1,68 @@
package fr.koka.sanctuary.knowledge;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
/** Only Sanctuary's evidence is stored here; vanilla statistics keep their ownership. */
public final class BlockKnowledgeMemory {
private static final Pattern BLOCK_ID = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+");
private static final Entry UNKNOWN = new Entry(false, false, 0);
private final Map<String, Entry> entries = new HashMap<>();
public BlockKnowledgeMemory() { }
/** Restores stable identifiers without requiring the corresponding mod to be installed. */
public static BlockKnowledgeMemory restored(Map<String, Entry> saved) {
Objects.requireNonNull(saved, "saved");
BlockKnowledgeMemory memory = new BlockKnowledgeMemory();
saved.forEach((id, entry) -> {
requireBlockId(id);
memory.entries.put(id, Objects.requireNonNull(entry, "entry"));
});
return memory;
}
public synchronized boolean recordSeen(String blockId) {
Entry previous = entry(blockId);
if (previous.seen()) return false;
entries.put(blockId, new Entry(true, previous.possessed(), previous.placed()));
return true;
}
public synchronized boolean recordPossessed(String blockId) {
Entry previous = entry(blockId);
if (previous.possessed()) return false;
entries.put(blockId, new Entry(previous.seen(), true, previous.placed()));
return true;
}
public synchronized void recordPlaced(String blockId) {
Entry previous = entry(blockId);
long placed = previous.placed() == Long.MAX_VALUE ? Long.MAX_VALUE : previous.placed() + 1;
entries.put(blockId, new Entry(previous.seen(), previous.possessed(), placed));
}
public synchronized Entry entry(String blockId) {
requireBlockId(blockId);
return entries.getOrDefault(blockId, UNKNOWN);
}
/** A stable immutable snapshot, safe to expose to other mechanics. */
public synchronized Map<String, Entry> entries() {
return Map.copyOf(entries);
}
static void requireBlockId(String blockId) {
if (blockId == null || !BLOCK_ID.matcher(blockId).matches()) {
throw new IllegalArgumentException("Invalid namespaced block identifier: " + blockId);
}
}
public record Entry(boolean seen, boolean possessed, long placed) {
public Entry {
if (placed < 0) throw new IllegalArgumentException("Placed count cannot be negative");
}
}
}
@@ -0,0 +1,63 @@
package fr.koka.sanctuary.knowledge;
import fr.koka.sanctuary.SanctuaryMod;
import java.util.List;
import java.util.Map;
import java.util.WeakHashMap;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.server.level.ServerPlayer;
/** The client can only ask for its own server-maintained state. */
public final class BlockKnowledgeNetworking {
private static final Map<ServerPlayer, Long> LAST_REQUEST = new WeakHashMap<>();
private static final long REQUEST_INTERVAL_NANOS = 1_000_000_000L;
private BlockKnowledgeNetworking() { }
public static void register() {
PayloadTypeRegistry.serverboundPlay().register(BlockKnowledgePayloads.Request.TYPE, BlockKnowledgePayloads.Request.CODEC);
PayloadTypeRegistry.clientboundPlay().register(BlockKnowledgePayloads.SnapshotPart.TYPE, BlockKnowledgePayloads.SnapshotPart.CODEC);
ServerPlayNetworking.registerGlobalReceiver(BlockKnowledgePayloads.Request.TYPE, (request, context) ->
context.server().execute(() -> respond(context.player(), request.requestId())));
}
private static void respond(ServerPlayer player, int requestId) {
if (!ServerPlayNetworking.canSend(player, BlockKnowledgePayloads.SnapshotPart.TYPE)) return;
long now = System.nanoTime();
synchronized (LAST_REQUEST) {
Long previous = LAST_REQUEST.get(player);
if (previous != null && now - previous < REQUEST_INTERVAL_NANOS) return;
LAST_REQUEST.put(player, now);
}
try {
if (!BlockKnowledgeService.healthy(player)) {
unavailable(player, requestId);
return;
}
List<BlockKnowledge> snapshot = List.copyOf(BlockKnowledgeService.snapshot(player));
if (snapshot.size() > BlockKnowledgePayloads.MAX_ENTRIES) {
unavailable(player, requestId);
return;
}
// Validate before sending any fragment: a snapshot is all-or-nothing at the client.
snapshot.forEach(BlockKnowledgePayloads::validateEntry);
int parts = Math.max(1, (snapshot.size() + BlockKnowledgePayloads.FRAGMENT_SIZE - 1)
/ BlockKnowledgePayloads.FRAGMENT_SIZE);
for (int part = 0; part < parts; part++) {
int start = part * BlockKnowledgePayloads.FRAGMENT_SIZE;
int end = Math.min(start + BlockKnowledgePayloads.FRAGMENT_SIZE, snapshot.size());
ServerPlayNetworking.send(player, new BlockKnowledgePayloads.SnapshotPart(requestId, part, parts,
snapshot.size(), true, snapshot.subList(start, end)));
}
} catch (RuntimeException failure) {
SanctuaryMod.LOGGER.error("Unable to send Blocodex snapshot", failure);
unavailable(player, requestId);
}
}
private static void unavailable(ServerPlayer player, int requestId) {
ServerPlayNetworking.send(player, new BlockKnowledgePayloads.SnapshotPart(requestId, 0, 1, 0, false, List.of()));
}
}
@@ -0,0 +1,91 @@
package fr.koka.sanctuary.knowledge;
import fr.koka.sanctuary.SanctuaryMod;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Read-only requests and bounded fragments of a single server snapshot. */
public final class BlockKnowledgePayloads {
public static final int FRAGMENT_SIZE = 128;
public static final int MAX_ENTRIES = 32_768;
public static final int MAX_ID_LENGTH = 256;
private BlockKnowledgePayloads() { }
public record Request(int requestId) implements CustomPacketPayload {
public static final Type<Request> TYPE = new Type<>(SanctuaryMod.id("blocodex_request"));
public static final StreamCodec<RegistryFriendlyByteBuf, Request> CODEC =
CustomPacketPayload.codec((request, buffer) -> buffer.writeVarInt(request.requestId()),
buffer -> new Request(buffer.readVarInt()));
@Override
public Type<Request> type() { return TYPE; }
}
public record SnapshotPart(int requestId, int partIndex, int partCount, int totalEntries,
boolean healthy, List<BlockKnowledge> entries) implements CustomPacketPayload {
public static final Type<SnapshotPart> TYPE = new Type<>(SanctuaryMod.id("blocodex_snapshot"));
public static final StreamCodec<RegistryFriendlyByteBuf, SnapshotPart> CODEC =
CustomPacketPayload.codec(SnapshotPart::write, SnapshotPart::read);
public SnapshotPart {
entries = List.copyOf(entries);
if (totalEntries < 0 || totalEntries > MAX_ENTRIES
|| partCount != Math.max(1, (totalEntries + FRAGMENT_SIZE - 1) / FRAGMENT_SIZE)
|| partIndex < 0 || partIndex >= partCount
|| entries.size() != Math.min(FRAGMENT_SIZE, totalEntries - partIndex * FRAGMENT_SIZE)
|| (!healthy && totalEntries != 0)) {
throw new IllegalArgumentException("Invalid Blocodex snapshot fragment");
}
for (BlockKnowledge entry : entries) validateEntry(entry);
}
@Override
public Type<SnapshotPart> type() { return TYPE; }
private void write(RegistryFriendlyByteBuf buffer) {
buffer.writeVarInt(requestId);
buffer.writeVarInt(partIndex);
buffer.writeVarInt(partCount);
buffer.writeVarInt(totalEntries);
buffer.writeBoolean(healthy);
buffer.writeVarInt(entries.size());
for (BlockKnowledge entry : entries) {
buffer.writeUtf(entry.blockId(), MAX_ID_LENGTH);
buffer.writeBoolean(entry.seen());
buffer.writeBoolean(entry.possessed());
buffer.writeVarLong(entry.mined());
buffer.writeVarLong(entry.dropped());
buffer.writeVarLong(entry.placed());
buffer.writeVarInt(entry.carried());
}
}
private static SnapshotPart read(RegistryFriendlyByteBuf buffer) {
int request = buffer.readVarInt();
int index = buffer.readVarInt();
int parts = buffer.readVarInt();
int total = buffer.readVarInt();
boolean healthy = buffer.readBoolean();
int size = buffer.readVarInt();
if (size < 0 || size > FRAGMENT_SIZE) throw new IllegalArgumentException("Oversized Blocodex fragment");
List<BlockKnowledge> entries = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
entries.add(new BlockKnowledge(buffer.readUtf(MAX_ID_LENGTH), buffer.readBoolean(), buffer.readBoolean(),
buffer.readVarLong(), buffer.readVarLong(), buffer.readVarLong(), buffer.readVarInt()));
}
return new SnapshotPart(request, index, parts, total, healthy, entries);
}
}
public static void validateEntry(BlockKnowledge entry) {
if (entry.blockId().length() > MAX_ID_LENGTH || Identifier.tryParse(entry.blockId()) == null
|| entry.mined() < 0 || entry.dropped() < 0 || entry.placed() < 0 || entry.carried() < 0) {
throw new IllegalArgumentException("Invalid Blocodex entry");
}
}
}
@@ -0,0 +1,267 @@
package fr.koka.sanctuary.knowledge;
import fr.koka.sanctuary.SanctuaryMod;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Predicate;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.stats.Stat;
import net.minecraft.stats.Stats;
import net.minecraft.tags.TagKey;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.ClipContext;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.storage.LevelResource;
import net.minecraft.world.phys.HitResult;
import net.minecraft.world.phys.Vec3;
/**
* Personal, world-local knowledge. All mutations and reads run on the server thread.
* Vanilla owns mined/dropped counters; Sanctuary owns seen/possessed/placed history.
*/
public final class BlockKnowledgeService {
public static final int OBSERVATION_INTERVAL = 5;
public static final int SAVE_INTERVAL = 600;
public static final double OBSERVATION_DISTANCE = 32;
public static final TagKey<Block> NATURAL_STRUCTURE_MATERIALS = TagKey.create(Registries.BLOCK,
SanctuaryMod.id("structure_palette/natural"));
private static final Map<MinecraftServer, Map<UUID, Session>> SESSIONS = new IdentityHashMap<>();
private static List<Block> catalog;
private BlockKnowledgeService() {}
public static void register() {
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> observeInventory(handler.player));
// Fabric also reaches DISCONNECT through Netty's channelInactive callback.
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> server.execute(() -> {
Map<UUID, Session> sessions = SESSIONS.get(server);
if (sessions == null) return;
Session session = sessions.remove(handler.player.getUUID());
if (session != null) save(session);
}));
ServerTickEvents.END_SERVER_TICK.register(server -> {
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
observeInventory(player);
if (Math.floorMod(server.getTickCount() + player.getId(), OBSERVATION_INTERVAL) == 0) observeLook(player);
}
if (server.getTickCount() % SAVE_INTERVAL == 0) flush(server);
});
ServerLifecycleEvents.BEFORE_SAVE.register((server, flush, force) -> flush(server));
ServerLifecycleEvents.SERVER_STOPPING.register(BlockKnowledgeService::flush);
ServerLifecycleEvents.SERVER_STOPPED.register(SESSIONS::remove);
}
/** A failed history is explicitly unavailable, never an empty progression record. */
public static boolean healthy(ServerPlayer player) { return !session(player).failed; }
public static List<BlockKnowledge> snapshot(ServerPlayer player) {
observeInventory(player);
Session session = requireHealthy(player);
List<BlockKnowledge> result = new ArrayList<>(catalog().size());
for (Block block : catalog()) result.add(value(player, block, session));
return List.copyOf(result);
}
public static BlockKnowledge knowledge(ServerPlayer player, Block block) {
observeInventory(player);
return value(player, block, requireHealthy(player));
}
/**
* Candidate materials for structures: combine knowledge facets with a data-pack material tag.
* Retains current quantities but does not reserve/consume them or authorize world placement.
*/
public static List<BlockKnowledge> palette(ServerPlayer player, Predicate<BlockKnowledge> condition, TagKey<Block> materials) {
Objects.requireNonNull(condition, "condition");
Objects.requireNonNull(materials, "materials");
return snapshot(player).stream().filter(condition)
.filter(entry -> BuiltInRegistries.BLOCK.getValue(Identifier.parse(entry.blockId())).defaultBlockState().is(materials))
.toList();
}
public static void recordSeen(ServerPlayer player, Block block) {
if (block.defaultBlockState().isAir() || player.isSpectator()) return;
Session session = session(player);
if (session.failed || !session.memory.recordSeen(id(block))) return;
session.dirty = true;
changed(player, block, session, BlockKnowledgeEvents.Activity.SEEN);
}
/** Called after successful BlockItem placement, before its item is consumed. */
public static void recordPlaced(ServerPlayer player, Block block) {
if (block.defaultBlockState().isAir() || player.isSpectator()) return;
observeInventory(player);
Session session = session(player);
if (session.failed) return;
session.memory.recordPlaced(id(block));
session.dirty = true;
changed(player, block, session, BlockKnowledgeEvents.Activity.PLACED);
}
/** Observes only personal slots and the carried cursor, never an open chest's contents. */
public static void observeInventory(ServerPlayer player) {
Session session = session(player);
if (session.failed) return;
Map<Block, Integer> current = inventory(player);
Map<Block, Integer> previous = session.carried;
session.carried = current;
for (Block block : current.keySet()) {
if (session.memory.recordPossessed(id(block))) {
session.dirty = true;
changed(player, block, session, BlockKnowledgeEvents.Activity.POSSESSED);
}
}
if (!current.equals(previous)) {
var changedBlocks = new HashSet<>(previous.keySet());
changedBlocks.addAll(current.keySet());
for (Block block : changedBlocks) {
if (!current.getOrDefault(block, 0).equals(previous.getOrDefault(block, 0))) {
changed(player, block, session, BlockKnowledgeEvents.Activity.INVENTORY);
}
}
}
}
/** Called after vanilla updates a stat; does not award or mirror its counter. */
public static void statChanged(ServerPlayer player, Stat<?> stat) {
Block block;
BlockKnowledgeEvents.Activity activity;
if (stat.getType() == Stats.BLOCK_MINED && stat.getValue() instanceof Block mined) {
block = mined;
activity = BlockKnowledgeEvents.Activity.MINED;
} else if ((stat.getType() == Stats.ITEM_DROPPED || stat.getType() == Stats.ITEM_PICKED_UP)
&& stat.getValue() instanceof BlockItem item) {
block = item.getBlock();
activity = stat.getType() == Stats.ITEM_DROPPED ? BlockKnowledgeEvents.Activity.DROPPED : BlockKnowledgeEvents.Activity.POSSESSED;
} else return;
Session session = session(player);
if (session.failed || block.defaultBlockState().isAir()) return;
observeInventory(player);
if (stat.getType() != Stats.BLOCK_MINED && player.getStats().getValue(stat) > 0
&& session.memory.recordPossessed(id(block))) {
session.dirty = true;
changed(player, block, session, BlockKnowledgeEvents.Activity.POSSESSED);
}
if (activity != BlockKnowledgeEvents.Activity.POSSESSED) changed(player, block, session, activity);
}
/** A single occluded gaze ray, with no requests for missing chunks. */
public static void observeLook(ServerPlayer player) {
if (player.isSpectator() || !player.isAlive() || !healthy(player)) return;
Vec3 start = player.getEyePosition();
Vec3 end = start.add(player.getLookAngle().scale(OBSERVATION_DISTANCE));
if (!finite(start) || !finite(end)) return;
// clip's shape queries may inspect neighbours. Reject absent neighbouring chunks too.
int minX = ((int) Math.floor(Math.min(start.x, end.x) - 1)) >> 4;
int maxX = ((int) Math.floor(Math.max(start.x, end.x) + 1)) >> 4;
int minZ = ((int) Math.floor(Math.min(start.z, end.z) - 1)) >> 4;
int maxZ = ((int) Math.floor(Math.max(start.z, end.z) + 1)) >> 4;
for (int x = minX; x <= maxX; x++) for (int z = minZ; z <= maxZ; z++) {
if (!player.level().getChunkSource().hasChunk(x, z)) return;
}
var hit = player.level().clip(new ClipContext(start, end, ClipContext.Block.OUTLINE, ClipContext.Fluid.ANY, player));
if (hit.getType() == HitResult.Type.BLOCK) recordSeen(player, player.level().getBlockState(hit.getBlockPos()).getBlock());
}
public static void flush(MinecraftServer server) {
Map<UUID, Session> sessions = SESSIONS.get(server);
if (sessions != null) sessions.values().forEach(BlockKnowledgeService::save);
}
private static BlockKnowledge value(ServerPlayer player, Block block, Session session) {
var history = session.memory.entry(id(block));
var stats = player.getStats();
long mined = Math.max(0, stats.getValue(Stats.BLOCK_MINED, block));
long dropped = 0;
boolean possessed = history.possessed();
// Shared block items (e.g. wall/standing torch) must not duplicate possession/drop totals.
if (block.asItem() instanceof BlockItem item && item.getBlock() == block) {
dropped = Math.max(0, stats.getValue(Stats.ITEM_DROPPED, item));
possessed |= dropped > 0 || stats.getValue(Stats.ITEM_PICKED_UP, item) > 0;
}
return new BlockKnowledge(id(block), history.seen(), possessed, mined, dropped, history.placed(), session.carried.getOrDefault(block, 0));
}
private static Map<Block, Integer> inventory(ServerPlayer player) {
Map<Block, Integer> result = new HashMap<>();
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++) add(result, player.getInventory().getItem(slot));
if (player.containerMenu != null) add(result, player.containerMenu.getCarried());
return result;
}
private static void add(Map<Block, Integer> result, ItemStack stack) {
if (!stack.isEmpty() && stack.getItem() instanceof BlockItem item && !item.getBlock().defaultBlockState().isAir()) {
result.merge(item.getBlock(), stack.getCount(), (left, right) -> (int) Math.min(Integer.MAX_VALUE, (long) left + right));
}
}
private static void changed(ServerPlayer player, Block block, Session session, BlockKnowledgeEvents.Activity activity) {
BlockKnowledgeEvents.CHANGED.invoker().onChanged(player, value(player, block, session), activity);
}
private static Session requireHealthy(ServerPlayer player) {
Session session = session(player);
if (session.failed) throw new IllegalStateException("Sanctuary block knowledge is unavailable for " + player.getUUID());
return session;
}
private static Session session(ServerPlayer player) {
MinecraftServer server = player.level().getServer();
if (!server.isSameThread()) throw new IllegalStateException("Block knowledge must be accessed on the server thread");
return SESSIONS.computeIfAbsent(server, ignored -> new HashMap<>()).computeIfAbsent(player.getUUID(), uuid -> {
var store = new BlockKnowledgeStore(server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-blocodex"), uuid);
Session session = new Session(store);
try { session.memory = store.load(); }
catch (IOException exception) {
session.failed = true;
SanctuaryMod.LOGGER.error("Cannot load block knowledge for {}; existing history will not be replaced", uuid, exception);
}
return session;
});
}
private static void save(Session session) {
if (!session.dirty || session.failed) return;
try {
session.store.save(session.memory);
session.dirty = false;
} catch (IOException exception) {
session.failed = true;
SanctuaryMod.LOGGER.error("Cannot save block knowledge; this history is unavailable until a successful reload", exception);
}
}
private static List<Block> catalog() {
if (catalog == null) catalog = BuiltInRegistries.BLOCK.stream().filter(block -> !block.defaultBlockState().isAir())
.sorted(Comparator.comparing(BlockKnowledgeService::id)).toList();
return catalog;
}
private static String id(Block block) { return BuiltInRegistries.BLOCK.getKey(block).toString(); }
private static boolean finite(Vec3 vector) { return Double.isFinite(vector.x) && Double.isFinite(vector.y) && Double.isFinite(vector.z); }
private static final class Session {
final BlockKnowledgeStore store;
BlockKnowledgeMemory memory;
Map<Block, Integer> carried = Map.of();
boolean dirty;
boolean failed;
Session(BlockKnowledgeStore store) { this.store = store; }
}
}
@@ -0,0 +1,232 @@
package fr.koka.sanctuary.knowledge;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.Strictness;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import java.io.IOException;
import java.io.StringReader;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
/** Additive per-player sidecar. A failed session cannot replace its existing evidence. */
public final class BlockKnowledgeStore {
private static final int MAX_BYTES = 16 * 1024 * 1024;
private static final int MAX_BLOCKS = 100_000;
private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create();
private static final Set<String> ROOT_FIELDS = Set.of("schema", "player", "blocks");
private static final Set<String> ENTRY_FIELDS = Set.of("seen", "possessed", "placed");
private final Path directory;
private final Path file;
private final UUID player;
private boolean loaded;
private boolean failed;
private byte[] previousBytes;
private Map<String, BlockKnowledgeMemory.Entry> previousEntries;
public BlockKnowledgeStore(Path directory, UUID player) {
this.directory = Objects.requireNonNull(directory, "directory").toAbsolutePath().normalize();
this.player = Objects.requireNonNull(player, "player");
this.file = this.directory.resolve(player + ".json");
}
/** Missing state starts in memory only. Invalid state requires repair and a new store instance. */
public synchronized BlockKnowledgeMemory load() throws IOException {
if (failed) throw new IOException("Block knowledge storage failed earlier; inspect it before reloading");
try {
checkDirectory();
byte[] bytes = readBytes();
Map<String, BlockKnowledgeMemory.Entry> entries = bytes == null ? Map.of() : decode(bytes);
previousBytes = bytes;
previousEntries = entries;
loaded = true;
return BlockKnowledgeMemory.restored(entries);
} catch (IOException | RuntimeException error) {
failed = true;
throw storageError("Could not load block knowledge", error);
}
}
public synchronized void save(BlockKnowledgeMemory memory) throws IOException {
if (failed || !loaded) throw new IOException("Block knowledge must load successfully before saving");
Path temporary = null;
try {
Map<String, BlockKnowledgeMemory.Entry> entries = Objects.requireNonNull(memory, "memory").entries();
checkDirectory();
if (!Arrays.equals(previousBytes, readBytes())) {
throw new IOException("Block knowledge changed outside this store; refusing to replace it");
}
if (previousEntries.equals(entries)) return;
byte[] bytes = encode(entries);
Files.createDirectories(directory);
temporary = Files.createTempFile(directory, "." + player + "-", ".tmp");
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
ByteBuffer buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) channel.write(buffer);
channel.force(true);
}
// Unsupported atomic replacement fails explicitly; no unsafe fallback is used.
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
temporary = null;
forceDirectory();
byte[] verified = readBytes();
if (!Arrays.equals(bytes, verified) || !decode(verified).equals(entries)) {
throw new IOException("Block knowledge read-back differs from its saved snapshot");
}
previousBytes = verified;
previousEntries = entries;
} catch (IOException | RuntimeException error) {
failed = true;
throw storageError("Could not save block knowledge", error);
} finally {
if (temporary != null) {
try { Files.deleteIfExists(temporary); } catch (IOException ignored) { /* Preserve the original failure. */ }
}
}
}
private void checkDirectory() throws IOException {
if (!Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)
&& !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Block knowledge path is not a regular directory");
}
}
private byte[] readBytes() throws IOException {
if (Files.notExists(file, LinkOption.NOFOLLOW_LINKS)) return null;
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Block knowledge is not a regular file");
}
if (Files.size(file) > MAX_BYTES) throw new IOException("Block knowledge exceeds its size limit");
try (var input = Files.newInputStream(file)) {
byte[] bytes = input.readNBytes(MAX_BYTES + 1);
if (bytes.length > MAX_BYTES) throw new IOException("Block knowledge exceeds its size limit");
return bytes;
}
}
private Map<String, BlockKnowledgeMemory.Entry> decode(byte[] bytes) throws IOException {
String text = StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString();
try (JsonReader reader = new JsonReader(new StringReader(text))) {
reader.setStrictness(Strictness.STRICT);
requireToken(reader, JsonToken.BEGIN_OBJECT);
reader.beginObject();
Set<String> fields = new HashSet<>();
Map<String, BlockKnowledgeMemory.Entry> entries = null;
while (reader.hasNext()) {
String field = field(reader, fields, ROOT_FIELDS);
switch (field) {
case "schema" -> {
if (nonnegativeLong(reader) != 1) throw new IOException("Unsupported block knowledge schema");
}
case "player" -> {
requireToken(reader, JsonToken.STRING);
if (!reader.nextString().equals(player.toString())) throw new IOException("Block knowledge player UUID differs");
}
case "blocks" -> entries = readEntries(reader);
default -> throw new IOException("Unknown block knowledge field");
}
}
reader.endObject();
if (!fields.equals(ROOT_FIELDS)) throw new IOException("Missing block knowledge fields");
requireToken(reader, JsonToken.END_DOCUMENT);
return Map.copyOf(entries);
} catch (RuntimeException error) {
throw new IOException("Invalid block knowledge JSON", error);
}
}
private static Map<String, BlockKnowledgeMemory.Entry> readEntries(JsonReader reader) throws IOException {
requireToken(reader, JsonToken.BEGIN_OBJECT);
reader.beginObject();
Map<String, BlockKnowledgeMemory.Entry> entries = new HashMap<>();
while (reader.hasNext()) {
if (entries.size() >= MAX_BLOCKS) throw new IOException("Too many block knowledge entries");
String blockId = reader.nextName();
BlockKnowledgeMemory.requireBlockId(blockId);
if (entries.containsKey(blockId)) throw new IOException("Duplicate block knowledge identifier");
requireToken(reader, JsonToken.BEGIN_OBJECT);
reader.beginObject();
Set<String> fields = new HashSet<>();
boolean seen = false;
boolean possessed = false;
long placed = 0;
while (reader.hasNext()) {
switch (field(reader, fields, ENTRY_FIELDS)) {
case "seen" -> { requireToken(reader, JsonToken.BOOLEAN); seen = reader.nextBoolean(); }
case "possessed" -> { requireToken(reader, JsonToken.BOOLEAN); possessed = reader.nextBoolean(); }
case "placed" -> placed = nonnegativeLong(reader);
default -> throw new IOException("Unknown block knowledge entry field");
}
}
reader.endObject();
if (!fields.equals(ENTRY_FIELDS)) throw new IOException("Missing block knowledge entry fields");
entries.put(blockId, new BlockKnowledgeMemory.Entry(seen, possessed, placed));
}
reader.endObject();
return entries;
}
private static String field(JsonReader reader, Set<String> seen, Set<String> allowed) throws IOException {
String name = reader.nextName();
if (!allowed.contains(name) || !seen.add(name)) throw new IOException("Unknown or duplicate block knowledge field: " + name);
return name;
}
private static long nonnegativeLong(JsonReader reader) throws IOException {
requireToken(reader, JsonToken.NUMBER);
String value = reader.nextString();
if (!value.matches("0|[1-9][0-9]{0,18}")) throw new IOException("Block knowledge count must be a nonnegative integer");
try { return Long.parseLong(value); }
catch (NumberFormatException error) { throw new IOException("Block knowledge count exceeds the long range", error); }
}
private static void requireToken(JsonReader reader, JsonToken token) throws IOException {
if (reader.peek() != token) throw new IOException("Unexpected block knowledge JSON token; expected " + token);
}
private byte[] encode(Map<String, BlockKnowledgeMemory.Entry> entries) throws IOException {
if (entries.size() > MAX_BLOCKS) throw new IOException("Too many block knowledge entries");
JsonObject root = new JsonObject();
root.addProperty("schema", 1);
root.addProperty("player", player.toString());
JsonObject blocks = new JsonObject();
new TreeMap<>(entries).forEach((id, entry) -> {
JsonObject object = new JsonObject();
object.addProperty("seen", entry.seen());
object.addProperty("possessed", entry.possessed());
object.addProperty("placed", entry.placed());
blocks.add(id, object);
});
root.add("blocks", blocks);
byte[] bytes = (JSON.toJson(root) + "\n").getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Block knowledge exceeds its size limit");
return bytes;
}
private void forceDirectory() throws IOException {
// Windows does not support opening directories through FileChannel.
if ("\\".equals(directory.getFileSystem().getSeparator())) return;
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); }
}
private static IOException storageError(String message, Exception error) {
return error instanceof IOException io ? io : new IOException(message, error);
}
}

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