diff --git a/README.md b/README.md index c2a1449..0fd1c4b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,24 @@ publiée** : le placement des structures redevient sélectif. Les palettes, intérieurs et filons de l’alpha.21 sont conservés. Voir [les essais22](docs/testing-alpha22.md) et [la distribution](docs/packwiz.md). +## Alpha.24 — les lieux suspendus, livraison locale vérifiée + +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 l’herbe, des fleurs et des petits arbres ; exactement +l’un 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 l’option 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. La publication puis la synchronisation Prism sont en préparation. + ## Alpha.23.1 — livraison locale vérifiée Le [chantier de génération](docs/generation-alpha23.1.md) réunit le Blocodex, une diff --git a/docs/backlog.md b/docs/backlog.md index b5f3f98..16216af 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -941,3 +941,14 @@ 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. +Publication et synchronisation en préparation. diff --git a/docs/generation-alpha24.md b/docs/generation-alpha24.md new file mode 100644 index 0000000..784c16b --- /dev/null +++ b/docs/generation-alpha24.md @@ -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 l’alpha.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 d’expansion, +registre et admission d’au moins une structure adaptée par expansion acceptée. + +Les cinq sites cherchent la périphérie, hors de l’emprise 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 n’est pas prolongé vers les sites. +Aucun pont, quai, pilier jusqu’au sol ou accès automatique n’est 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 d’herbe 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 d’or, 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 d’eau 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 d’un 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 l’ensemble 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 s’applique qu’aux 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 d’eau, 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. L’admission +d’expansion, le journal et la reprise de vide vérifient ces emprises même si +aucun joueur n’a 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 qu’une famille de structures vanilla manque au serveur. +`/locate structure` et `/sanctuary census structures aerial [site]` les +référencent sans ouvrir d’expansion 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 n’est 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). diff --git a/docs/packwiz.md b/docs/packwiz.md index b08963b..971907b 100644 --- a/docs/packwiz.md +++ b/docs/packwiz.md @@ -13,6 +13,25 @@ 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 locale vérifié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 +``` + +L’accès Git est rétabli. Les artefacts sont prêts ; leur publication, l’avance +du canal puis les deux synchronisations isolées et celles de l’instance +Prism existante restent à effectuer. Le ZIP d’amorçage suit le canal stable ; +il ne sert pas à réimporter l’instance existante. + ## 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 diff --git a/docs/testing-alpha24.md b/docs/testing-alpha24.md new file mode 100644 index 0000000..61caa94 --- /dev/null +++ b/docs/testing-alpha24.md @@ -0,0 +1,262 @@ +# 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 l’ordre inverse +de chargement. Les tests purs, deux admissions réelles d’expansion 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 publication et la synchronisation +restent en préparation. +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 n’est ouverte ou modifiée. Aucun essai Windows +natif ni contrôle graphique du client avec shaders n’a été exécuté. + +Les résultats retenus figurent dans `build/alpha24-evidence/matrix.json`, +avec les rapports `--.json` et leurs journaux `.log`. +Un résultat n’est retenu qu’aprè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 l’export 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 l’ordre 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 l’alpha.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. + +L’ordre 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 s’ils 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 d’un 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 d’occupation 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 n’apparaî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 d’expansion reste inchangé pendant ces visites. + +La source d’eau est présente et conservée ; l’essai 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 l’herbe** +(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 l’emprise 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 d’adaptation 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 d’appels 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, l’unique +source prévue produit de l’eau sous l’îlot à `399 194 312`, puis à +`399 8 312`, après **1 180 ticks d’attente contrôlée**. À Y8, le fluide +est de l’eau courante, pas une source artificiellement posée. Aucun chemin +vertical de blocs sources n’est 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. +L’exigence 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 l’ancienne limite de 200 ticks du fixture était trop courte. +Le premier essai interrompu n’est 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ée–rivage | +| --- | --- | --- | ---: | +| 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 n’est 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 l’ajout de végétation aérienne, a été comparé à +`build/alpha231-evidence/final-medium.json`. Tous les champs sont identiques, +à l’exception 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. +L’essai d’admission et d’eau utilise un nouveau monde : + +```sh +./gradlew :sanctuary:runGameTest \ + -PsanctuaryAerial24FlowAdmission=true \ + -PsanctuaryTestGeneration=24 -PsanctuaryTestDiameter=724 \ + -PsanctuaryTestSeed=0 -PsanctuaryDensityMaps=false +``` + +Cette dernière commande reproduit l’essai d’admission 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. + +L’accès Gitea est rétabli, avec la permission de pousser. La distribution +est en préparation ; la publication et la synchronisation Prism ne sont pas +encore effectuées. L’état final des artefacts et du canal sera consigné après +les contrôles de livraison. diff --git a/gradle.properties b/gradle.properties index f46f51e..7532532 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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.23.1 -pack_version=0.1.0-alpha.23.1 +mod_version=0.1.0-alpha.24 +pack_version=0.1.0-alpha.24 maven_group=fr.koka.sanctuary diff --git a/mods/sanctuary/build.gradle b/mods/sanctuary/build.gradle index 233275d..e91af78 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -40,7 +40,11 @@ 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' : 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' : '23')) + 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' : '24')) + 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')) @@ -94,6 +98,10 @@ tasks.named('processGametestResources') { inputs.property('heritage21', heritage21) def lostCity21 = providers.gradleProperty('sanctuaryLostCity21Tests').map { it.toBoolean() }.getOrElse(false) inputs.property('lostCity21', lostCity21) + 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) @@ -111,7 +119,7 @@ tasks.named('processGametestResources') { def voidReuse = providers.gradleProperty('sanctuaryVoidReuseTests').map { it.toBoolean() }.getOrElse(false) inputs.property('voidReuse', voidReuse) filesMatching('fabric.mod.json') { - expand(gametest_entrypoint: 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 + expand(gametest_entrypoint: 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') @@ -588,3 +596,20 @@ tasks.named('check') { dependsOn('expansionOcean23Smoke') } } 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') } diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Aerial24FlowAdmissionGameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Aerial24FlowAdmissionGameTests.java new file mode 100644 index 0000000..68bcba2 --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Aerial24FlowAdmissionGameTests.java @@ -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(); + 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(); + 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(); + 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 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>(); 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 requests(long seed, List islands, AerialPlan24 plan, boolean collision) { + var result = new ArrayList(); + 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 coords(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); } + private static void write(Map 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 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); } + } +} diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Aerial24WorldGameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Aerial24WorldGameTests.java new file mode 100644 index 0000000..928fb1b --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Aerial24WorldGameTests.java @@ -0,0 +1,398 @@ +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(); + try { begin(helper, report); } + catch (Exception failure) { failure(report, failure); throw failure; } + } + + private static void begin(GameTestHelper helper, Map 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(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(); + 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(); 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("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 report) { + var root = generator.source().islands().stream().filter(ExpansionIsland::origin).findFirst().orElseThrow(); + var views = new LinkedHashMap(); + 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 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 starts(GameTestHelper helper, ServerLevel level, AerialPlan24 plan) { + var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); + var result = new TreeMap(); + 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> inspect(GameTestHelper helper, ServerLevel level, AerialPlan24 plan, + boolean fresh, Map report) { + var expected = new LinkedHashMap>(); int chestCount = 0, waterSources = 0; + var vegetation = new TreeMap(); + var shores = new TreeMap(); 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(); + 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 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 mutate(GameTestHelper helper, ServerLevel level, AerialPlan24 plan, List occupants, + Map> models) { + var emptied = new ArrayList>(); var removed = new ArrayList>(); var loot = new TreeMap(); + 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.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>(); 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 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 starts) { + var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); + var holders = new ArrayList>(); + 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 render(AerialPlan24.Site site) { + var result = new HashMap(); + 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 table, long seed) { set(x, y, z, Blocks.CHEST.defaultBlockState()); } + }); + return result; + } + private static Map> countSites(ServerLevel level, AerialPlan24 plan) { + var all = new TreeMap>(); + for (var site : plan.sites()) { + var counts = new TreeMap(); + 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 tags(ServerLevel level, Map starts) { + var result = new TreeMap(); var context = StructurePieceSerializationContext.fromLevel(level); + starts.forEach((id, start) -> result.put(id, start.createTag(context, start.getChunkPos()).toString())); return result; + } + private static List 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 files(Path directory) throws java.io.IOException { + var found = new TreeMap(); + 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 a, Map 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 report) throws java.io.IOException { Files.createDirectories(path.toAbsolutePath().getParent()); Files.writeString(path, JSON.toJson(report)); } + private static void failure(Map 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); } + } +} diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java index b76ce47..d5607a9 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java @@ -61,12 +61,12 @@ public final class PopulationDiagnostics { 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) fr.koka.sanctuary.expansion.ExpansionOcean23.BIOMES + if (unified.rootGeneration() == 23 || unified.rootGeneration() == 24) fr.koka.sanctuary.expansion.ExpansionOcean23.BIOMES .forEach(name -> expected.add("minecraft:" + name)); helper.assertTrue(generator.getBiomeSource() instanceof SanctuaryBiomeSource && generator.getBiomeSource().possibleBiomes().stream().map(b -> b.getRegisteredName()) .collect(java.util.stream.Collectors.toSet()).equals(expected), - "Historical holders stay intact; only generation23 adds the expansion ocean and shore palette"); + "Historical holders stay intact; generations23/24 add the expansion ocean and shore palette"); helper.assertTrue(binding.random() != level.getChunkSource().randomState(), "The unified starter must use its real terrain state, never the expansion host fallback"); } else { @@ -215,7 +215,7 @@ public final class PopulationDiagnostics { void prepare() throws IOException { boolean heritage = level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator sanctuary - && (sanctuary.rootGeneration() == 23 || sanctuary.rootGeneration() == 22 || (sanctuary.rootGeneration() == 19 || sanctuary.rootGeneration() == 20 || sanctuary.rootGeneration() == 21) && sanctuary.experimentalStructures()); + && (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. diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/UnifiedWorldSettingsDiagnostics.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/UnifiedWorldSettingsDiagnostics.java index ae2c1d2..cdf712e 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/UnifiedWorldSettingsDiagnostics.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/UnifiedWorldSettingsDiagnostics.java @@ -61,7 +61,10 @@ final class UnifiedWorldSettingsDiagnostics { 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"); - helper.assertTrue(generator.rootGeneration() == 23 && generator.source().generation() == 23 && generator.experimentalStructures(), "The public preset uses generation23 with its new-world option"); + 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"); + helper.assertTrue(generator.rootGeneration() == 24 && generator.source().generation() == 24 && generator.experimentalStructures(), "The public preset uses generation24 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)) { @@ -72,11 +75,11 @@ final class UnifiedWorldSettingsDiagnostics { } var ops = RegistryOps.create(JsonOps.INSTANCE, access); - var activeCodec = 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 activeCodec = 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(), - "Generation23 omitted experimental option defaults to enabled"); + "Generation24 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"); @@ -135,7 +138,7 @@ final class UnifiedWorldSettingsDiagnostics { } // 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) return; + || active.rootGeneration() != 21 && active.rootGeneration() != 22 && active.rootGeneration() != 23 && active.rootGeneration() != 24) return; helper.assertTrue(!active.protectsCityDecoration(outside) && !active.intersectsExperimentalStructures(outside.getX(), outside.getY(), outside.getZ(), outside.getX() + 1, outside.getY() + 1, outside.getZ() + 1), @@ -145,7 +148,7 @@ final class UnifiedWorldSettingsDiagnostics { 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) && helper.getLevel().structureManager().shouldGenerateStructures()) + if ((active.rootGeneration() == 22 || active.rootGeneration() == 23 || active.rootGeneration() == 24) && helper.getLevel().structureManager().shouldGenerateStructures()) helper.assertTrue(!interior.isEmpty(), "The attached generation22 world has core construction to protect"); for (var position : interior) helper.assertTrue(active.protectsCityDecoration(position) && active.intersectsExperimentalStructures(position.getX(), position.getY(), position.getZ(), diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/SanctuaryGameTestServerMixin.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/SanctuaryGameTestServerMixin.java index 373a979..7ce2217 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/SanctuaryGameTestServerMixin.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/SanctuaryGameTestServerMixin.java @@ -21,7 +21,7 @@ public abstract class SanctuaryGameTestServerMixin { // generation test's deadline before its IO/workers can complete. This is test-only pacing. @Inject(method = "waitUntilNextTick", at = @At("RETURN")) private void sanctuary$paceExpansionWorkers(CallbackInfo ci) { - if (Boolean.getBoolean("sanctuary.test.admission23") || Boolean.getBoolean("sanctuary.test.ocean23") || Boolean.getBoolean("sanctuary.test.secrets23") || Boolean.getBoolean("sanctuary.test.native23") || Boolean.getBoolean("sanctuary.test.selective22") || (Boolean.getBoolean("sanctuary.test.heritage20") || Boolean.getBoolean("sanctuary.test.heritage21") || Boolean.getBoolean("sanctuary.test.lostCity21") || Boolean.getBoolean("sanctuary.test.transit21")) || Boolean.getBoolean("sanctuary.test.lostCity20") + if (Boolean.getBoolean("sanctuary.test.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; @@ -47,9 +47,9 @@ public abstract class SanctuaryGameTestServerMixin { "Lnet/minecraft/world/level/levelgen/presets/WorldPresets;FLAT_ALL_DIMENSIONS:Lnet/minecraft/resources/ResourceKey;")) private static ResourceKey sanctuary$selectActualPreset() { if (Boolean.getBoolean("sanctuary.test.unified")) { - int generation = Integer.getInteger("sanctuary.test.generation", 23); - if (generation != 13 && generation != 14 && generation != 15 && generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22 && generation != 23) throw new IllegalArgumentException("Unsupported test generation"); - return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(generation == 23 ? "sanctuary" : "sanctuary_v" + generation)); + int generation = Integer.getInteger("sanctuary.test.generation", 24); + if (generation != 13 && generation != 14 && generation != 15 && generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22 && generation != 23 && generation != 24) throw new IllegalArgumentException("Unsupported test generation"); + return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(generation == 24 ? "sanctuary" : "sanctuary_v" + generation)); } if (Boolean.getBoolean("sanctuary.test.expansion")) return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("expansion_lab")); int players = Integer.getInteger("sanctuary.test.players", 10); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java index 6ef297e..5792f28 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java @@ -76,6 +76,9 @@ public final class SanctuaryMod implements ModInitializer { 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); + 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(); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SanctuaryWorldOptionsScreen.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SanctuaryWorldOptionsScreen.java index 1dbffc5..f0d64d2 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SanctuaryWorldOptionsScreen.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SanctuaryWorldOptionsScreen.java @@ -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 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || generator.rootGeneration() == 23) { + if (generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24)) { 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(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) ? "sanctuary.world_options.heritage_hint" : "sanctuary.world_options.experimental_hint"), font) + contents.addChild(new MultiLineTextWidget(Component.translatable(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)) ? "sanctuary.world_options.heritage_hint" : "sanctuary.world_options.experimental_hint"), font) .setMaxWidth(310).setCentered(true)); } layout.addToContents(contents); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionChunkGenerator.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionChunkGenerator.java index 05f057a..26176fe 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionChunkGenerator.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionChunkGenerator.java @@ -129,6 +129,7 @@ public class ExpansionChunkGenerator extends ChunkGenerator { 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); ExpansionStructures.generate(this, registries, state, structures, chunk, templates, dimension); } @Override public void spawnOriginalMobs(WorldGenRegion region) { diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionIsland.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionIsland.java index 4e1488f..8d02d58 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionIsland.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionIsland.java @@ -45,7 +45,7 @@ public record ExpansionIsland(String id, String parent, String direction, String 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(generation).contains(relief)) throw new IllegalArgumentException("Unknown island profile"); - if (relief.equals("ocean") && (generation != 23 || id.equals("sanctuary") || diameter != 512 && diameter != 1024)) + if (relief.equals("ocean") && ((generation != 23 && generation != 24) || 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"); @@ -59,7 +59,7 @@ public record ExpansionIsland(String id, String parent, String direction, String 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 || id.equals("sanctuary"))) + if (!structurePriorities.isEmpty() && ((generation != 23 && generation != 24) || id.equals("sanctuary"))) throw new IllegalArgumentException("Structure priorities are only available for generation23 expansions"); } @@ -70,8 +70,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 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || generation == 23; } - public static List reliefs(int generation) { return generation == 23 ? RELIEFS23 : RELIEFS; } + 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); } + public static List reliefs(int generation) { return (generation == 23 || generation == 24) ? 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(); } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionJournal.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionJournal.java index 4fa39bb..fce147e 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionJournal.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionJournal.java @@ -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 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 && !initialAerialToken.isEmpty()) bindAerial(initialAerialToken); + else if (rootGeneration != 24 && !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 && 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 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; } @@ -150,17 +169,20 @@ public final class ExpansionJournal { 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) journalFields.add("aerial_plan"); + requireFields(root, journalFields); + if (integer(root, "schema") != (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) bindAerial(string(root, "aerial_plan")); JsonArray entries = root.getAsJsonArray("islands"); List 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 && entry.has("structure_priorities")) fields.add("structure_priorities"); + if ((rootGeneration == 23 || rootGeneration == 24) && entry.has("structure_priorities")) fields.add("structure_priorities"); 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(), @@ -191,6 +213,7 @@ 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 && aerialPlan24.conflicts(island)) throw new IOException("Expansion reservation overlaps a permanent aerial site: " + island.id()); previous.put(island.id(), island); } return checked; @@ -212,7 +235,8 @@ public final class ExpansionJournal { private JsonObject encode(List entries) { JsonObject root = new JsonObject(); - root.addProperty("schema", 1); root.addProperty("world_seed", seed); root.addProperty("root_diameter", rootDiameter); + root.addProperty("schema", rootGeneration == 24 ? 2 : 1); root.addProperty("world_seed", seed); root.addProperty("root_diameter", rootDiameter); + if (rootGeneration == 24) root.addProperty("aerial_plan", aerialToken); JsonArray array = new JsonArray(); for (ExpansionIsland island : entries) { JsonObject entry = new JsonObject(); @@ -232,6 +256,14 @@ public final class ExpansionJournal { 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), token, seed, rootDiameter); + 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()) { diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionOcean23.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionOcean23.java index c5af44c..0c689e5 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionOcean23.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionOcean23.java @@ -24,14 +24,14 @@ public final class ExpansionOcean23 { } private ExpansionOcean23() {} public static boolean active(ExpansionIsland island) { - return island != null && island.generation() == 23 && !island.origin() && island.relief().equals("ocean"); + return island != null && (island.generation() == 23 || island.generation() == 24) && !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 || diameter < 512 || climate.equals("lost_city")) return "natural"; + if ((generation != 23 && generation != 24) || 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) { diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionRuntime.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionRuntime.java index fb5f68e..fef0686 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionRuntime.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionRuntime.java @@ -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,19 @@ 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 && !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), level.getSeed(), generator.initialDiameter(), + () -> AerialPlanner24.forWorld(level, sanctuary)).token(); + } + var journal = generator.rootGeneration() == 24 + ? new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter(), 24, initialAerialToken) + : new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter(), generator.rootGeneration()); + if (generator.rootGeneration() == 24) { + if (!(generator instanceof SanctuaryChunkGenerator sanctuary)) throw new IOException("Aerial world requires the Sanctuary generator"); + sanctuary.setAerialPlan24(journal.aerialPlan24()); + } ExpansionVoidReuse.open(level, generator, journal.islands()); generator.initialize(level, journal.islands()); var session = new Session(level, generator, journal); @@ -87,7 +102,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); @@ -109,15 +124,15 @@ public final class ExpansionRuntime { if (islands().size() >= 64) throw new IllegalArgumentException("island_limit"); // 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 + var priorities = generator.rootGeneration() == 23 || generator.rootGeneration() == 24 ? fr.koka.sanctuary.knowledge.structures.StructureCensusService.priorities(level) : List.of(); var candidates = ExpansionPlacement.candidates(level.getSeed(), islands(), id, parent, direction, climate, diameter, relief, minimumDistance) - .stream().map(value -> value.withStructurePriorities(priorities)).toList(); + .stream().filter(value -> !aerialConflicts(value)).map(value -> value.withStructurePriorities(priorities)).toList(); boolean nativeRefused = false; for (var value : candidates) { ExpansionAdmission.checkDeadline(deadline); try { - if (value.generation() == 23) ExpansionStructures23.verifyExpansion(level, value, deadline); + 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"); @@ -143,6 +158,10 @@ public final class ExpansionRuntime { ExpansionAdmission.checkDeadline(deadline); throw new IllegalArgumentException(nativeRefused ? "native_structure_unavailable" : "no_free_space"); } + private boolean aerialConflicts(ExpansionIsland island) { + return generator.rootGeneration() == 24 && generator instanceof SanctuaryChunkGenerator sanctuary + && sanctuary.aerialPlan24().conflicts(island); + } public void resume(String id) { requireHealthyJournal(); var island = island(id); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionStructures.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionStructures.java index 6c199b2..982be63 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionStructures.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionStructures.java @@ -44,7 +44,7 @@ public final class ExpansionStructures { public static void generate(ExpansionChunkGenerator generator, RegistryAccess registries, ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk, StructureTemplateManager templates, ResourceKey level) { - if (generator.rootGeneration() == 23) { + if ((generator.rootGeneration() == 23 || generator.rootGeneration() == 24)) { ExpansionStructures23.generate(generator, registries, state, manager, chunk, templates, level); return; } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionStructures23.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionStructures23.java index 004a9c7..7fed2dc 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionStructures23.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionStructures23.java @@ -61,6 +61,7 @@ public final class ExpansionStructures23 { public static List 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; } public record Candidate(ChunkPos chunk, long roll) {} public record Accepted(Holder structure, StructureStart start) {} public static final class NoSuitableStructureException extends IllegalArgumentException { @@ -90,7 +91,7 @@ public final class ExpansionStructures23 { StructureTemplateManager templates, ResourceKey dimension) { if (!Level.OVERWORLD.equals(dimension) || !manager.shouldGenerateStructures()) return; ExpansionIsland island = generator.islandAt(chunk.getPos().getMiddleBlockX(), chunk.getPos().getMiddleBlockZ()); - if (island == null || island.generation() != 23) return; + if (island == null || !nativeGeneration(island.generation())) 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()) { @@ -110,7 +111,7 @@ public final class ExpansionStructures23 { * No active source snapshot is mutated and no real world chunk is requested. */ public static void verifyExpansion(ServerLevel level, ExpansionIsland island, long deadlineNanos) { - if (island.generation() != 23 || island.origin() || !level.structureManager().shouldGenerateStructures()) return; + if (!nativeGeneration(island.generation()) || island.origin() || !level.structureManager().shouldGenerateStructures()) 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()); @@ -384,7 +385,7 @@ public final class ExpansionStructures23 { public static Pair> findNearest(ServerLevel level, HolderSet wanted, BlockPos origin, int radius, boolean skipReferenced) { if (!(level.getChunkSource().getGenerator() instanceof ExpansionChunkGenerator generator) - || generator.rootGeneration() != 23 || !level.structureManager().shouldGenerateStructures()) return null; + || !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; @@ -392,7 +393,7 @@ public final class ExpansionStructures23 { boolean sanctuaryRequested = wanted.stream().anyMatch(h -> h.unwrapKey().orElseThrow().identifier().getNamespace().equals("sanctuary")); var candidates = new ArrayList(); for (ExpansionIsland island : generator.source().islands()) { - if (!nativeRequested || island.generation() != 23 || !island.status().equals("ready")) continue; + 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); @@ -429,7 +430,7 @@ public final class ExpansionStructures23 { private static void addSanctuaryCandidates(SanctuaryChunkGenerator generator, List positions) { for (ExpansionIsland island : generator.source().islands()) { - if (island.generation() != 23 || !island.status().equals("ready")) continue; + 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())); @@ -439,6 +440,8 @@ public final class ExpansionStructures23 { 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.aerialPlan24().sites() + .forEach(site -> addPosition(positions, site.startChunkX(), site.startChunkZ())); } private static void addPosition(List positions, int x, int z) { positions.add(new Candidate(new ChunkPos(x, z), 0)); } private static BlockPos locator(StructureStart start, BlockPos origin) { @@ -536,7 +539,7 @@ public final class ExpansionStructures23 { NativeView(ExpansionChunkGenerator host, ExpansionIsland island, ExpansionBiomeSource source, RegistryAccess registries) { super(source, host.initialDiameter(), true); this.host = host; this.island = island; this.registries = registries; } - @Override public int rootGeneration() { return 23; } + @Override public int rootGeneration() { return island.generation(); } @Override public Holder structureBiome(Holder 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); } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionVoidReuse.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionVoidReuse.java index e5585ca..18f50a5 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionVoidReuse.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionVoidReuse.java @@ -86,6 +86,9 @@ 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 instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator sanctuary + && sanctuary.aerialPlan24().conflicts(island)) + throw new ExpansionAdmission.OccupiedException("aerial_reserved: expansion overlaps a permanent initial site"); List positions = positions(island); var entityStorage = entityRegion(level); var poiStorage = ((ExpansionVoidPoiStorageAccess) level.getPoiManager()).sanctuary$simpleRegionStorage(); @@ -173,6 +176,9 @@ public final class ExpansionVoidReuse { public static void open(ServerLevel level, ExpansionChunkGenerator generator, List islands) throws IOException { requireServerThread(level); close(level); if (!ExpansionIsland.spatialGeneration(generator.rootGeneration())) return; + if (generator.rootGeneration() == 24 && 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()); 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. diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/knowledge/structures/StructureCensusCommands.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/knowledge/structures/StructureCensusCommands.java index 40a98e2..6b7de08 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/knowledge/structures/StructureCensusCommands.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/knowledge/structures/StructureCensusCommands.java @@ -16,6 +16,12 @@ public final class StructureCensusCommands { public static void register() { CommandRegistrationCallback.EVENT.register((dispatcher, registries, environment) -> { var structures = Commands.literal("structures").executes(context -> report(context.getSource(), null)) + .then(Commands.literal("aerial").executes(context -> aerial(context.getSource(), null)) + .then(Commands.argument("site", StringArgumentType.word()) + .suggests((context, builder) -> SharedSuggestionProvider.suggest( + StructureCensusService.aerialSites(context.getSource().getLevel()).stream() + .map(StructureCensusService.AerialSite::id), builder)) + .executes(context -> aerial(context.getSource(), StringArgumentType.getString(context, "site"))))) .then(Commands.literal("island").then(Commands.argument("id", StringArgumentType.word()) .suggests((context, builder) -> SharedSuggestionProvider.suggest( StructureCensusService.snapshot(context.getSource().getServer()).regions().stream() @@ -51,6 +57,23 @@ public final class StructureCensusCommands { send(source, "help"); return 1; } + private static int aerial(CommandSourceStack source, String selected) { + var sites = StructureCensusService.aerialSites(source.getLevel()).stream() + .filter(site -> selected == null || site.id().equals(selected)).toList(); + if (sites.isEmpty()) { + source.sendFailure(selected == null ? Component.translatable(TEXT + "aerial.none") + : Component.translatable(TEXT + "aerial.unknown_site", selected)); + return 0; + } + for (var site : sites) { + var coverage = site.coverage(); + send(source, "aerial.site", site.id(), site.structure(), + Component.translatable(TEXT + "aerial.status." + site.status().name().toLowerCase(java.util.Locale.ROOT)), + coverage.startsChunks(), coverage.expectedChunks(), coverage.featurePassChunks(), coverage.expectedChunks(), + coverage.fullChunks(), coverage.expectedChunks()); + } + return 1; + } private static int dimension(CommandSourceStack source, String id) { var snapshot = StructureCensusService.snapshot(source.getServer()); if (!snapshot.available()) { source.sendFailure(Component.translatable(TEXT + "unavailable")); return 0; } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/knowledge/structures/StructureCensusService.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/knowledge/structures/StructureCensusService.java index e6b8930..a346d63 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/knowledge/structures/StructureCensusService.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/knowledge/structures/StructureCensusService.java @@ -83,7 +83,8 @@ public final class StructureCensusService { public static List priorities(ServerLevel level) { assertServerThread(level.getServer()); if (!level.dimension().equals(Level.OVERWORLD) || !level.structureManager().shouldGenerateStructures() - || !(level.getChunkSource().getGenerator() instanceof ExpansionChunkGenerator generator) || generator.rootGeneration() != 23) + || !(level.getChunkSource().getGenerator() instanceof ExpansionChunkGenerator generator) + || generator.rootGeneration() != 23 && generator.rootGeneration() != 24) return List.of(); return confirmedPriorities(snapshot(level.getServer()), level.dimension().identifier().toString()); } @@ -156,7 +157,7 @@ public final class StructureCensusService { }).toList(); if (matching.isEmpty()) return Presence.OTHER_DIMENSION; // This is an explicit generation23 habitat requirement, not an inferred lack of biomes. - if (island != null && island.generation() == 23 && !island.relief().equals("ocean") + if (island != null && (island.generation() == 23 || island.generation() == 24) && !island.relief().equals("ocean") && matching.stream().allMatch(entry -> entry.habitat() == ExpansionStructureCatalog23.Habitat.OCEAN)) return Presence.INCOMPATIBLE; return null; } @@ -172,6 +173,31 @@ public final class StructureCensusService { .map(ExpansionStructureCatalog23.Entry::family).findFirst().orElse(id); } + /** Planned aerial sites have their own finite scopes. Planning is never recorded as + * generated evidence, and these sites never enter the vanilla absence-priority pool. */ + public static List aerialSites(ServerLevel level) { + assertServerThread(level.getServer()); + if (!level.dimension().equals(Level.OVERWORLD) || !level.structureManager().shouldGenerateStructures() + || !(level.getChunkSource().getGenerator() instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator generator) + || generator.rootGeneration() != 24) return List.of(); + var session = current(level.getServer()); + var evidence = session.memory.snapshot(); + String dimension = level.dimension().identifier().toString(); + var result = new ArrayList(); + for (var site : generator.aerialPlan24().sites()) { + String structure = fr.koka.sanctuary.worldgen.aerial.AerialStructures24.structureId(site); + var scope = site.claimedChunks().stream().map(chunk -> new ChunkKey(dimension, chunk.x(), chunk.z())).toList(); + var coverage = evidence.coverage(scope); + var key = new SiteKey(dimension, structure, site.startChunkX(), 0, site.startChunkZ(), false); + var observed = evidence.sites().get(key); + AerialStatus status = session.failed ? AerialStatus.UNKNOWN : observed != null + ? evidence.generated(observed) ? AerialStatus.GENERATED : AerialStatus.ACCEPTED + : coverage.startsComplete() ? AerialStatus.MISSING : AerialStatus.PLANNED; + result.add(new AerialSite(site.id(), structure, dimension, coverage, status)); + } + return List.copyOf(result); + } + /** Finite ownership is derived from the immutable expansion journal descriptors, * rather than duplicated in the sidecar or inferred from currently loaded chunks. */ public static List scope(String dimension, ExpansionIsland island) { @@ -222,6 +248,8 @@ public final class StructureCensusService { Session(StructureCensusStore store) { this.store = store; } } public enum Presence { PRESENT, ABSENT, UNKNOWN, INCOMPATIBLE, OTHER_DIMENSION } + public enum AerialStatus { PLANNED, ACCEPTED, GENERATED, MISSING, UNKNOWN } + public record AerialSite(String id, String structure, String dimension, Coverage coverage, AerialStatus status) {} public record FamilyCount(int accepted, int generated, Presence presence) {} public record Region(String dimension, String island, int generation, String status, Coverage coverage, Map families) { public Region { families = Map.copyOf(families); } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/UnifiedDecorationProtectionMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/UnifiedDecorationProtectionMixin.java index 5b9b63c..76fb686 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/UnifiedDecorationProtectionMixin.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/UnifiedDecorationProtectionMixin.java @@ -20,6 +20,11 @@ public abstract class UnifiedDecorationProtectionMixin { var region = (WorldGenRegion) (Object) this; if (!(region.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator) || !fr.koka.sanctuary.expansion.ExpansionIsland.spatialGeneration(generator.rootGeneration())) return; + // Aerial pieces write in genuine void chunks, within their own immutable site and current chunk only. + if (generator.rootGeneration() == 24 && fr.koka.sanctuary.worldgen.aerial.AerialStructures24.isPlacing()) { + if (!fr.koka.sanctuary.worldgen.aerial.AerialStructures24.mayWrite(pos)) callback.setReturnValue(false); + return; // Vanilla still applies its write-zone and build-height checks. + } var center = region.getCenter(); var owner = generator.islandForChunk(center.x(), center.z()); var destination = generator.islandForChunk(pos.getX() >> 4, pos.getZ() >> 4); @@ -39,7 +44,11 @@ public abstract class UnifiedDecorationProtectionMixin { if (!(region.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator)) return; // Vanilla rejects writes outside its region before reading the destination chunk. if (!region.isWithinWriteZone(pos)) return; - if (generator.rootGeneration() == 23) { + if (generator.rootGeneration() == 24 && fr.koka.sanctuary.worldgen.aerial.AerialStructures24.isPlacing()) { + if (!fr.koka.sanctuary.worldgen.aerial.AerialStructures24.mayWrite(pos)) callback.setReturnValue(false); + return; + } + if ((generator.rootGeneration() == 23 || generator.rootGeneration() == 24)) { if (fr.koka.sanctuary.worldgen.mineral.MineralSanctuaryStructures23.isPlacing()) return; var island = generator.islandAt(pos.getX(), pos.getZ()); if (fr.koka.sanctuary.expansion.ExpansionOcean23.active(island) @@ -55,7 +64,7 @@ public abstract class UnifiedDecorationProtectionMixin { if (state.is(Blocks.SUGAR_CANE)) return; // Generation16 reserves its architectural volume before decoration. Only its own // native pieces may replace water there; published generations retain the old rule. - if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || generator.rootGeneration() == 23) + if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24)) && fr.koka.sanctuary.worldgen.city.LostCityStructures.isPlacing() && generator.protectsCityDecoration(pos)) return; if (generator.protectsDecoration(pos) && !region.getBlockState(pos).equals(state)) { diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuaryBiomeSource.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuaryBiomeSource.java index 94ab8c4..e67372c 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuaryBiomeSource.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuaryBiomeSource.java @@ -62,6 +62,10 @@ public final class SanctuaryBiomeSource extends ExpansionBiomeSource { Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes) ).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 23))); + public static final MapCodec AERIAL24_CODEC = RecordCodecBuilder.mapCodec(i -> i.group( + Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes) + ).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 24))); + private final List> allBiomes; private final PopulationIslandBiomeSource population; private final boolean cities; @@ -77,7 +81,7 @@ public final class SanctuaryBiomeSource extends ExpansionBiomeSource { this.generation = generation; this.cities = ExpansionIsland.spatialGeneration(generation); allBiomes = List.copyOf(biomes); - if (generation == 23) for (String name : fr.koka.sanctuary.expansion.ExpansionOcean23.BIOMES) + if ((generation == 23 || generation == 24)) for (String name : fr.koka.sanctuary.expansion.ExpansionOcean23.BIOMES) required(ResourceKey.create(net.minecraft.core.registries.Registries.BIOME, net.minecraft.resources.Identifier.withDefaultNamespace(name))); cityBiome = cities ? required(ResourceKey.create(net.minecraft.core.registries.Registries.BIOME, @@ -98,7 +102,7 @@ public final class SanctuaryBiomeSource extends ExpansionBiomeSource { cityPlans = java.util.Map.copyOf(plans); } - @Override protected MapCodec codec() { return generation == 23 ? SECRETS23_CODEC : generation == 22 ? SELECTIVE22_CODEC : generation == 21 ? NETWORK21_CODEC : generation == 20 ? ARCHITECTURE20_CODEC : generation == 19 ? HERITAGE19_CODEC : generation == 17 ? RUINS17_CODEC : generation == 16 ? CITY16_CODEC : generation == 15 ? CITY15_CODEC : cities ? CITY_CODEC : CODEC; } + @Override protected MapCodec codec() { return generation == 24 ? AERIAL24_CODEC : generation == 23 ? SECRETS23_CODEC : generation == 22 ? SELECTIVE22_CODEC : generation == 21 ? NETWORK21_CODEC : generation == 20 ? ARCHITECTURE20_CODEC : generation == 19 ? HERITAGE19_CODEC : generation == 17 ? RUINS17_CODEC : generation == 16 ? CITY16_CODEC : generation == 15 ? CITY15_CODEC : cities ? CITY_CODEC : CODEC; } @Override public BiomeResolver createResolver(Climate.Sampler sampler) { BiomeResolver woodlands = population.createResolver(sampler); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuaryChunkGenerator.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuaryChunkGenerator.java index bf1238d..a1b95bd 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuaryChunkGenerator.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuaryChunkGenerator.java @@ -75,6 +75,41 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { Codec.BOOL.optionalFieldOf("experimental_structures", true).forGetter(SanctuaryChunkGenerator::experimentalStructures) ).apply(i, (source, diameter, experimental) -> new SanctuaryChunkGenerator(source, diameter, 23, experimental))); + public static final MapCodec AERIAL24_CODEC = RecordCodecBuilder.mapCodec(i -> i.group( + SanctuaryBiomeSource.AERIAL24_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source), + DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter), + Codec.BOOL.optionalFieldOf("experimental_structures", true).forGetter(SanctuaryChunkGenerator::experimentalStructures) + ).apply(i, (source, diameter, experimental) -> new SanctuaryChunkGenerator(source, diameter, 24, experimental))); + + private volatile fr.koka.sanctuary.worldgen.aerial.AerialPlan24 aerialPlan24 = fr.koka.sanctuary.worldgen.aerial.AerialPlan24.EMPTY; + public fr.koka.sanctuary.worldgen.aerial.AerialPlan24 aerialPlan24() { return aerialPlan24; } + public void setAerialPlan24(fr.koka.sanctuary.worldgen.aerial.AerialPlan24 plan) { + if (generation != 24) throw new IllegalStateException("Aerial plans require generation24"); + aerialPlan24 = java.util.Objects.requireNonNull(plan); + } + /** Plan root secrets before certifying aerial shore viewpoints, without requesting chunks. */ + public void prepareAerialSampling24(ServerLevel level) { + if (generation != 24) throw new IllegalStateException("Aerial sampling requires generation24"); + heightAccessor = level; + cityStructuresEnabled = level.structureManager().shouldGenerateStructures(); + var root = java.util.List.of(ExpansionIsland.origin(initialDiameter(), level.getSeed(), 24)); + super.initialize(level, root); + context(root.getFirst()); + // Same order as initialize. Prepared plans are reused after the manifest is persisted. + prepareCities(root); + prepareTransit(root); + prepareUndergroundHall22(root); + prepareMineral23(root); + prepareRuins(root); + prepareHeritage(root); + prepareTransit(root); + } + public net.minecraft.world.level.NoiseColumn aerialBaseColumn24(int x, int z) { + var current = originContext; + if (current == null || heightAccessor == null) throw new IllegalStateException("Aerial sampling before world attachment"); + return current.generator().getBaseColumn(x, z, heightAccessor, current.random()); + } + private volatile fr.koka.sanctuary.worldgen.mineral.MineralSanctuaryPlan23 mineralSanctuary23 = fr.koka.sanctuary.worldgen.mineral.MineralSanctuaryPlan23.EMPTY; private boolean mineralPrepared23; public fr.koka.sanctuary.worldgen.mineral.MineralSanctuaryPlan23 mineralSanctuary23() { return mineralSanctuary23; } @@ -130,12 +165,12 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { } private SanctuaryChunkGenerator(SanctuaryBiomeSource source, int initialDiameter, int generation, boolean experimental) { super(source, initialDiameter, true); - this.experimentalStructures = (generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || generation == 23) && experimental; + this.experimentalStructures = (generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || (generation == 23 || generation == 24)) && experimental; this.generation = generation; if (generation != source.generation()) throw new IllegalArgumentException("Sanctuary generator and biome source versions must agree"); } @Override public SanctuaryBiomeSource source() { return (SanctuaryBiomeSource) super.source(); } - @Override protected MapCodec codec() { return generation == 23 ? SECRETS23_CODEC : generation == 22 ? SELECTIVE22_CODEC : generation == 21 ? NETWORK21_CODEC : generation == 20 ? ARCHITECTURE20_CODEC : generation == 19 ? HERITAGE19_CODEC : generation == 17 ? RUINS17_CODEC : generation == 16 ? CITY16_CODEC : generation == 15 ? CITY15_CODEC : generation == 14 ? CITY_CODEC : CODEC; } + @Override protected MapCodec codec() { return generation == 24 ? AERIAL24_CODEC : generation == 23 ? SECRETS23_CODEC : generation == 22 ? SELECTIVE22_CODEC : generation == 21 ? NETWORK21_CODEC : generation == 20 ? ARCHITECTURE20_CODEC : generation == 19 ? HERITAGE19_CODEC : generation == 17 ? RUINS17_CODEC : generation == 16 ? CITY16_CODEC : generation == 15 ? CITY15_CODEC : generation == 14 ? CITY_CODEC : CODEC; } public SanctuaryChunkGenerator withInitialDiameter(int diameter) { return new SanctuaryChunkGenerator(source().copy(), diameter, generation, experimentalStructures); } @@ -146,11 +181,15 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { public fr.koka.sanctuary.worldgen.ruins.RuinsPlan17 ruinsPlan17() { return ruinsPlan17; } public fr.koka.sanctuary.worldgen.heritage.HeritagePlan19 heritagePlan19() { return heritagePlan19; } public boolean intersectsExperimentalStructures(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { - return generation == 23 && mineralSanctuary23.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ) - || (generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || generation == 23) && ruinsPlan17.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ) - || (generation == 19 || generation == 20 || generation == 21 || generation == 22 || generation == 23) && heritagePlan19.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ) - || (generation == 21 || generation == 22 || generation == 23) && transitPlan21.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ) - || (generation == 22 || generation == 23) && undergroundHallProtection22.intersects(minX, minY, minZ, maxX, maxY, maxZ); + if (generation == 24) for (var site : aerialPlan24.sites()) { + if (minX <= site.maxX() + 1 && maxX >= site.minX() - 1 && minZ <= site.maxZ() + 1 && maxZ >= site.minZ() - 1 + && minY <= site.maxY() + 1 && maxY >= (site.waterfall() ? 0 : site.minY() - 1)) return true; + } + return (generation == 23 || generation == 24) && mineralSanctuary23.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ) + || (generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || (generation == 23 || generation == 24)) && ruinsPlan17.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ) + || (generation == 19 || generation == 20 || generation == 21 || generation == 22 || (generation == 23 || generation == 24)) && heritagePlan19.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ) + || (generation == 21 || generation == 22 || (generation == 23 || generation == 24)) && transitPlan21.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ) + || (generation == 22 || (generation == 23 || generation == 24)) && undergroundHallProtection22.intersects(minX, minY, minZ, maxX, maxY, maxZ); } public IslandCapacity capacity() { return switch (initialDiameter()) { @@ -185,7 +224,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { // Publish one root context before any generation workers use it. context(islands.getFirst()); prepareCities(islands); - if (generation == 22 || generation == 23) { prepareTransit(islands); prepareUndergroundHall22(islands); } + if (generation == 22 || (generation == 23 || generation == 24)) { prepareTransit(islands); prepareUndergroundHall22(islands); } prepareMineral23(islands); prepareRuins(islands); prepareHeritage(islands); @@ -194,7 +233,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { @Override public void setIslands(List islands) { // Complete and publish city geometry before workers can observe the new owner list. prepareCities(islands); - if (generation == 22 || generation == 23) { prepareTransit(islands); prepareUndergroundHall22(islands); } + if (generation == 22 || (generation == 23 || generation == 24)) { prepareTransit(islands); prepareUndergroundHall22(islands); } prepareMineral23(islands); prepareRuins(islands); prepareHeritage(islands); @@ -209,7 +248,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { source().setCityPlans(java.util.Map.of()); return; } - if ((generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || generation == 23) && !cityStructuresEnabled) { + if ((generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || (generation == 23 || generation == 24)) && !cityStructuresEnabled) { cityProtection16 = fr.koka.sanctuary.worldgen.city.LostCityProtection16.EMPTY; activeCityPlans = java.util.Map.of(); source().setCityPlans(java.util.Map.of()); @@ -225,7 +264,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { } cityProtection15 = protection.build(); } - if (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || generation == 23) { + if (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || (generation == 23 || generation == 24)) { var protection = new fr.koka.sanctuary.worldgen.city.LostCityProtection16.Builder(); for (var island : islands) { protection.add(published.get(island.id()), undergroundPlan16(island)); @@ -240,13 +279,13 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { source().setCityPlans(published); } private void prepareRuins(List islands) { - if ((generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22 && generation != 23) || !experimentalStructures || !cityStructuresEnabled || ruinsPrepared) return; + if ((generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22 && (generation != 23 && generation != 24)) || !experimentalStructures || !cityStructuresEnabled || ruinsPrepared) return; var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow(); var terrain = context(root); var sampled = new fr.koka.sanctuary.worldgen.ruins.RuinsTerrain17(root, terrain.generator(), terrain.random(), heightAccessor, pos -> protectsCore22(pos) || protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ())); long start = System.nanoTime(); - var plan = generation == 23 + var plan = (generation == 23 || generation == 24) ? fr.koka.sanctuary.worldgen.ruins.RuinsPlanner23.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled) : fr.koka.sanctuary.worldgen.ruins.RuinsPlanner17.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled); ruinsPlan17 = plan; @@ -255,16 +294,16 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { root.seed(), plan.networks().size(), plan.samples(), sampled.columnsUsed(), (System.nanoTime() - start) / 1_000_000); } private void prepareHeritage(List islands) { - if ((generation != 19 && generation != 20 && generation != 21 && generation != 22 && generation != 23) || !experimentalStructures || !cityStructuresEnabled || heritagePrepared) return; + if ((generation != 19 && generation != 20 && generation != 21 && generation != 22 && (generation != 23 && generation != 24)) || !experimentalStructures || !cityStructuresEnabled || heritagePrepared) return; var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow(); var terrain = context(root); - if (generation == 21 || generation == 22 || generation == 23) { + if (generation == 21 || generation == 22 || (generation == 23 || generation == 24)) { var sampled = new fr.koka.sanctuary.worldgen.heritage.HeritageTerrain21(root, terrain.generator(), terrain.random(), heightAccessor, pos -> protectsCore22(pos) || protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()) || ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ())); long start = System.nanoTime(); sampled.discoverLandmarks(lostCityPlan(root), ruinsPlan17); - var plan = generation == 23 + var plan = (generation == 23 || generation == 24) ? fr.koka.sanctuary.worldgen.heritage.HeritagePlanner23.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled) : generation == 22 ? fr.koka.sanctuary.worldgen.heritage.HeritagePlanner22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled) @@ -289,7 +328,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { (System.nanoTime() - start) / 1_000_000, plan.diagnostics()); } private void prepareTransit(List islands) { - if ((generation != 21 && generation != 22 && generation != 23) || generation == 21 && !experimentalStructures || !cityStructuresEnabled || transitPrepared) return; + if ((generation != 21 && generation != 22 && (generation != 23 && generation != 24)) || generation == 21 && !experimentalStructures || !cityStructuresEnabled || transitPrepared) return; var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow(); var terrain = context(root); var sampled = new fr.koka.sanctuary.worldgen.transit.TransitTerrain21(root, terrain.generator(), terrain.random(), @@ -300,7 +339,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { ? fr.koka.sanctuary.worldgen.transit.TransitPlanner22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled) : fr.koka.sanctuary.worldgen.transit.TransitPlanner21.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled); int directColumns = 0; - if ((generation == 22 || generation == 23) && transitPlan21.isEmpty()) { + if ((generation == 22 || (generation == 23 || generation == 24)) && transitPlan21.isEmpty()) { directColumns = sampled.columnsUsed(); sampled = new fr.koka.sanctuary.worldgen.transit.TransitTerrain21(root, terrain.generator(), terrain.random(), heightAccessor, pos -> protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ())); @@ -311,12 +350,12 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { transitPlan21.modules().size(), transitPlan21.samples(), directColumns + sampled.columnsUsed(), (System.nanoTime() - start) / 1_000_000, transitPlan21.diagnostic()); } private boolean protectsCore22(net.minecraft.core.BlockPos pos) { - return (generation == 22 || generation == 23) && (transitPlan21.protects(pos.getX(), pos.getY(), pos.getZ()) + return (generation == 22 || (generation == 23 || generation == 24)) && (transitPlan21.protects(pos.getX(), pos.getY(), pos.getZ()) || undergroundHallProtection22.contains(pos.getX(), pos.getY(), pos.getZ()) - || generation == 23 && mineralSanctuary23.protects(pos.getX(), pos.getY(), pos.getZ())); + || (generation == 23 || generation == 24) && mineralSanctuary23.protects(pos.getX(), pos.getY(), pos.getZ())); } private void prepareUndergroundHall22(List islands) { - if ((generation != 22 && generation != 23) || !cityStructuresEnabled || undergroundHallPrepared22) return; + if ((generation != 22 && (generation != 23 && generation != 24)) || !cityStructuresEnabled || undergroundHallPrepared22) return; var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow(); if (undergroundPlan16(root).networks().stream().flatMap(n -> n.modules().stream()) .anyMatch(m -> m.kind() == fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Kind.EXPANSION_HALL)) { @@ -333,7 +372,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { @Override public boolean protectedAt(int x, int y, int z) { return sampled.protectedAt(x, y, z); } @Override public boolean exteriorAir(int x, int y, int z, int dx, int dz) { return sampled.exteriorAir(x, y, z, dx, dz); } }; - undergroundHall22 = generation == 23 + undergroundHall22 = (generation == 23 || generation == 24) ? fr.koka.sanctuary.worldgen.city.LostCityUnderground23.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), hallTerrain) : fr.koka.sanctuary.worldgen.city.LostCityUnderground22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), hallTerrain); var protection = new fr.koka.sanctuary.worldgen.city.LostCityProtection16.Builder(); @@ -343,7 +382,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { undergroundHall22.networks().size(), undergroundHall22.samples(), sampled.columnsUsed(), (System.nanoTime() - start) / 1_000_000); } private void prepareMineral23(List islands) { - if (generation != 23 || !experimentalStructures || !cityStructuresEnabled || mineralPrepared23) return; + if ((generation != 23 && generation != 24) || !experimentalStructures || !cityStructuresEnabled || mineralPrepared23) return; var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow(); var terrain = context(root); var sampled = new fr.koka.sanctuary.worldgen.city.LostCityTerrain21(root, terrain.generator(), terrain.random(), heightAccessor, @@ -381,13 +420,13 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { } } public fr.koka.sanctuary.worldgen.city.LostCityPlan lostCityPlan(ExpansionIsland island) { - if (generation == 23 && island.origin()) return fr.koka.sanctuary.worldgen.city.LostCityPlan.EMPTY; + if ((generation == 23 || generation == 24) && island.origin()) return fr.koka.sanctuary.worldgen.city.LostCityPlan.EMPTY; if (!ExpansionIsland.spatialGeneration(generation) || island.generation() != generation || (!island.origin() && !island.climate().equals("lost_city"))) return fr.koka.sanctuary.worldgen.city.LostCityPlan.EMPTY; return cityPlans.computeIfAbsent(cityIdentity(island), ignored -> { long start = System.nanoTime(); var terrain = context(island); - if (generation == 21 || generation == 22 || generation == 23) { + if (generation == 21 || generation == 22 || (generation == 23 || generation == 24)) { var sampled = new fr.koka.sanctuary.worldgen.city.LostCityTerrain21(island, terrain.generator(), terrain.random(), heightAccessor, pos -> island.origin() && protectsDecoration(pos)); var placement = generation >= 22 @@ -492,12 +531,13 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { }); } public boolean protectsCityDecoration(net.minecraft.core.BlockPos pos) { - if (generation == 23 && mineralSanctuary23.protects(pos.getX(), pos.getY(), pos.getZ())) return true; - if (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || generation == 23) return cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()) - || (generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || generation == 23) && ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ()) - || (generation == 19 || generation == 20 || generation == 21 || generation == 22 || generation == 23) && heritagePlan19.protects(pos.getX(), pos.getY(), pos.getZ()) - || (generation == 21 || generation == 22 || generation == 23) && transitPlan21.protects(pos.getX(), pos.getY(), pos.getZ()) - || (generation == 22 || generation == 23) && undergroundHallProtection22.contains(pos.getX(), pos.getY(), pos.getZ()); + if (generation == 24 && aerialPlan24.protects(pos)) return true; + if ((generation == 23 || generation == 24) && mineralSanctuary23.protects(pos.getX(), pos.getY(), pos.getZ())) return true; + if (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || (generation == 23 || generation == 24)) return cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()) + || (generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || (generation == 23 || generation == 24)) && ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ()) + || (generation == 19 || generation == 20 || generation == 21 || generation == 22 || (generation == 23 || generation == 24)) && heritagePlan19.protects(pos.getX(), pos.getY(), pos.getZ()) + || (generation == 21 || generation == 22 || (generation == 23 || generation == 24)) && transitPlan21.protects(pos.getX(), pos.getY(), pos.getZ()) + || (generation == 22 || (generation == 23 || generation == 24)) && undergroundHallProtection22.contains(pos.getX(), pos.getY(), pos.getZ()); if (generation == 15) return cityProtection15.contains(pos.getX(), pos.getY(), pos.getZ()); if (generation != 14) return false; var island = islandAt(pos.getX(), pos.getZ()); @@ -519,20 +559,20 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { return citySupports.getOrDefault(cityIdentity(island), List.of()); } public boolean intersectsCityConstruction16(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { - return (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || generation == 23) && cityProtection16.intersects(minX, minY, minZ, maxX, maxY, maxZ); + return (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || (generation == 23 || generation == 24)) && cityProtection16.intersects(minX, minY, minZ, maxX, maxY, maxZ); } public fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan undergroundPlan16(ExpansionIsland island) { - if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22 && generation != 23) return new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan(List.of(), 0); + if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22 && (generation != 23 && generation != 24)) return new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan(List.of(), 0); lostCityPlan(island); return undergroundPlans16.getOrDefault(cityIdentity(island), new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan(List.of(), 0)); } public List citySupports16(ExpansionIsland island) { - if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22 && generation != 23) return List.of(); + if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22 && (generation != 23 && generation != 24)) return List.of(); lostCityPlan(island); return citySupports16.getOrDefault(cityIdentity(island), List.of()); } public LostCityPlanner16.Fallback cityReservation16(ExpansionIsland island) { - if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22 && generation != 23) return null; + if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22 && (generation != 23 && generation != 24)) return null; lostCityPlan(island); return cityReservations16.get(cityIdentity(island)); } @@ -542,16 +582,16 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { @Override public com.mojang.datafixers.util.Pair> findNearestMapStructure( ServerLevel level, net.minecraft.core.HolderSet structures, net.minecraft.core.BlockPos origin, int radius, boolean skipReferenced) { - if (generation == 23) return fr.koka.sanctuary.expansion.ExpansionStructures23.findNearest(level, structures, origin, radius, skipReferenced); + if ((generation == 23 || generation == 24)) return fr.koka.sanctuary.expansion.ExpansionStructures23.findNearest(level, structures, origin, radius, skipReferenced); if (generation == 22 && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.KEY)) return fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.findNearest(level, structures, origin, radius, skipReferenced); if (structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.city.LostCityStructures.KEY)) return fr.koka.sanctuary.worldgen.city.LostCityStructures.findNearest(level, structures, origin, radius, skipReferenced); - if ((generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || generation == 23) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.KEY)) + if ((generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || (generation == 23 || generation == 24)) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.KEY)) return fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.findNearest(level, structures, origin, radius, skipReferenced); - if ((generation == 19 || generation == 20 || generation == 21 || generation == 22 || generation == 23) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.KEY)) + if ((generation == 19 || generation == 20 || generation == 21 || generation == 22 || (generation == 23 || generation == 24)) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.KEY)) return fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.findNearest(level, structures, origin, radius, skipReferenced); - if ((generation == 21 || generation == 22 || generation == 23) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.transit.TransitStructures21.KEY)) + if ((generation == 21 || generation == 22 || (generation == 23 || generation == 24)) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.transit.TransitStructures21.KEY)) return fr.koka.sanctuary.worldgen.transit.TransitStructures21.findNearest(level, structures, origin, radius, skipReferenced); return super.findNearestMapStructure(level, structures, origin, radius, skipReferenced); } @@ -580,7 +620,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { @Override protected void afterTerrain(ExpansionIsland island, ChunkAccess chunk) { // Root water/lava are already applied by the Population hook on the named delegate. if (!island.origin()) super.afterTerrain(island, chunk); - if ((generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || generation == 23) && cityStructuresEnabled) { + if ((generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22 || (generation == 23 || generation == 24)) && cityStructuresEnabled) { var reserved = cityReservations16.get(cityIdentity(island)); if (reserved != null) { var mutable = new net.minecraft.core.BlockPos.MutableBlockPos(); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialGeometry24.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialGeometry24.java new file mode 100644 index 0000000..a42d4eb --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialGeometry24.java @@ -0,0 +1,139 @@ +package fr.koka.sanctuary.worldgen.aerial; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.minecraft.core.BlockPos; + +/** Shared geometry for rendering, native locators and executable interior witnesses. */ +public final class AerialGeometry24 { + private AerialGeometry24() {} + public static BlockPos world(AerialPlan24.Site site, int x, int y, int z) { + return new BlockPos(site.worldX(x, z), site.minY() + y, site.worldZ(x, z)); + } + public static BlockPos chest(AerialPlan24.Site s) { + return switch (s.kind()) { + case BALLOON -> world(s, s.width() / 2 - 2, 2, s.depth() / 2 + 2); + case MERCHANT_PROVISIONS, MERCHANT_TOOLS -> world(s, 7, 6, 22); + case MINERAL_ISLET -> { + int x = s.width() / 2 + 4, z = s.depth() / 2 + 2; + yield world(s, x, terrainTop(s, x, z) - 3, z); + } + }; + } + public static BlockPos occupant(AerialPlan24.Site s) { + if (!s.merchant()) throw new IllegalArgumentException("Only merchant ships have occupants"); + return world(s, 5, 6, 20); + } + public static UUID occupantUuid(AerialPlan24.Site s) { + return UUID.nameUUIDFromBytes(("sanctuary:aerial24:" + s.id() + ":" + s.seed() + ":merchant") + .getBytes(StandardCharsets.UTF_8)); + } + public static BlockPos workstation(AerialPlan24.Site s) { return world(s, 7, 6, 19); } + public static BlockPos waterfallSource(AerialPlan24.Site s) { + if (!s.waterfall()) throw new IllegalArgumentException("This site has no waterfall"); + return world(s, s.width() / 2, rockHeight(s) - 3, 3); + } + public static BlockPos entry(AerialPlan24.Site s) { + return switch (s.kind()) { + case BALLOON -> world(s, s.width() / 2, 2, s.depth() / 2 - 3); + case MERCHANT_PROVISIONS, MERCHANT_TOOLS -> world(s, 7, 6, 12); + case MINERAL_ISLET -> world(s, s.width() / 2, terrainTop(s, s.width() / 2, s.depth() / 2) + 1, s.depth() / 2); + }; + } + public static List interiorChecks(AerialPlan24.Site s) { + if (s.merchant()) return List.of(entry(s), world(s, 5, 6, 16), occupant(s), world(s, 5, 6, 23)); + if (s.kind() == AerialPlan24.Kind.BALLOON) return List.of(entry(s), world(s, s.width() / 2, 2, s.depth() / 2)); + return List.of(entry(s)); + } + public static int terrainTop(AerialPlan24.Site s, int x, int z) { + int height = rockHeight(s); + if (s.waterfall() && Math.abs(x - s.width() / 2) <= 2 && z <= 6) return height - 4; + if (radial(s, x, z) < .13) return height - 1; + double phase = (s.seed() & 1023) * .01; + return Math.clamp(height - 3 + (int) Math.round(2 * Math.sin(x * .17 + phase) + 1.5 * Math.cos(z * .13 - phase)), height - 7, height - 1); + } + public static int rockHeight(AerialPlan24.Site s) { return s.rockHeight(); } + public static double radial(AerialPlan24.Site s, int x, int z) { + double dx = (x - (s.width() - 1) * .5) / (s.width() * .48); + double dz = (z - (s.depth() - 1) * .5) / (s.depth() * .48); + double phase = (s.seed() & 1023) * .01, angle = Math.atan2(dz, dx); + double edge = .84 + .105 * Math.sin(angle * 3 + phase) + .055 * Math.cos(angle * 5 - phase) + + .025 * Math.sin(angle * 9 + phase * .7); + if (s.waterfall()) edge = Math.max(edge, .9 - 2.5 * Math.pow(angle + Math.PI / 2, 2)); + return Math.hypot(dx, dz) / edge; + } + public static int terrainBottom(AerialPlan24.Site s, int x, int z) { + double r = Math.min(1, radial(s, x, z)); + if (r < .065) return 0; + int top = terrainTop(s, x, z); + double taper = (r - .065) / .935; + double angle = Math.atan2(z - s.depth() * .5, x - s.width() * .5); + double ribs = 2 * Math.sin(angle * 5 + (s.seed() & 255) * .02) * taper * (1 - taper); + // The underside meets the rim within two blocks, instead of ending in a vertical cylinder. + return Math.clamp((int) Math.round((top - 1) * Math.pow(taper, .85) + ribs), 0, top - 1); + } + /** Open rock fractures deepen towards the rim; the central arrival and buried cache stay intact. */ + public static int fractureFloor(AerialPlan24.Site s, int x, int z) { + if (s.waterfall() && Math.abs(x - s.width() / 2) <= 3 && z <= 8) return Integer.MAX_VALUE; + double dx = x - (s.width() - 1) * .5, dz = z - (s.depth() - 1) * .5; + double phase = (s.seed() & 1023) * .006, start = Math.min(s.width(), s.depth()) * .21; + int floor = Integer.MAX_VALUE; + for (int fracture = 0; fracture < 2; fracture++) { + double angle = phase + fracture * 2.45; + double along = dx * Math.cos(angle) + dz * Math.sin(angle); + double across = -dx * Math.sin(angle) + dz * Math.cos(angle); + double bend = 1.1 * Math.sin(along * .24 + fracture); + if (along > start && Math.abs(across + bend) < 1.05 + (along - start) * .09) + floor = Math.min(floor, terrainTop(s, x, z) - 2 - (int) ((along - start) * 1.3)); + } + return floor; + } + public static boolean mineralBody(AerialPlan24.Site s, int x, int y, int z) { + if (x < 0 || z < 0 || x >= s.width() || z >= s.depth()) return false; + if (s.waterfall() && Math.abs(x - s.width() / 2) <= 2 && z <= 6) + return y >= rockHeight(s) - 8 + Math.max(0, 3 - z) && y <= rockHeight(s) - 4; + return radial(s, x, z) <= 1 && y >= terrainBottom(s, x, z) && y <= terrainTop(s, x, z) + && y < fractureFloor(s, x, z); + } + /** Continuous soil cover broken by rocky edges, exposed seams and open fractures. */ + public static boolean naturalSurface(AerialPlan24.Site s, int x, int z) { + if (s.waterfall() && Math.abs(x - s.width() / 2) <= 4 && z <= 9) return false; + int top = terrainTop(s, x, z); + if (radial(s, x, z) > .97 || !mineralBody(s, x, top, z) || !mineralBody(s, x, top - 1, z)) return false; + double phase = (s.seed() & 1023) * .01; + double patches = .6 * Math.sin(x * .17 + phase) + .45 * Math.cos(z * .14 - phase) + .25 * Math.sin((x + z) * .24); + return patches > -.75; + } + public static boolean arrivalColumn(AerialPlan24.Site s, int x, int z) { + return Math.abs(x - s.width() / 2) <= 1 && Math.abs(z - s.depth() / 2) <= 1; + } + public record Tree(int x, int groundY, int z, int trunkHeight, boolean birch) {} + /** Small woodland groups are planned against the whole site before any chunk is rendered. */ + public static List trees(AerialPlan24.Site s) { + if (s.kind() != AerialPlan24.Kind.MINERAL_ISLET) return List.of(); + var trees = new ArrayList(); + int target = 2 + (s.width() - 40) / 10; + for (int attempt = 0; attempt < 256 && trees.size() < target; attempt++) { + long hash = mix(s.seed() ^ 0x24A11F0AE57L ^ (attempt * 0x9e3779b97f4a7c15L)); + int x = 6 + (int) Math.floorMod(hash, s.width() - 12); + int z = 6 + (int) Math.floorMod(mix(hash), s.depth() - 12); + int dx = x - s.width() / 2, dz = z - s.depth() / 2; + if (dx * dx + dz * dz < 36 || (dx - 4) * (dx - 4) + (dz - 2) * (dz - 2) < 25) continue; + if (trees.stream().anyMatch(t -> (t.x() - x) * (t.x() - x) + (t.z() - z) * (t.z() - z) < 81)) continue; + int ground = terrainTop(s, x, z); + if (!naturalSurface(s, x, z)) continue; + boolean supported = true; + for (int ox = -2; ox <= 2 && supported; ox++) for (int oz = -2; oz <= 2; oz++) { + int neighbour = terrainTop(s, x + ox, z + oz); + if (Math.abs(neighbour - ground) > 1 || !mineralBody(s, x + ox, neighbour, z + oz)) { + supported = false; break; + } + } + if (supported) trees.add(new Tree(x, ground, z, 4 + (int) (hash & 1), trees.size() % 3 == 1)); + } + return List.copyOf(trees); + } + static long mix(long n) { n = (n ^ n >>> 30) * 0xbf58476d1ce4e5b9L; n = (n ^ n >>> 27) * 0x94d049bb133111ebL; return n ^ n >>> 31; } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPiece24.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPiece24.java new file mode 100644 index 0000000..bdd4199 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPiece24.java @@ -0,0 +1,113 @@ +package fr.koka.sanctuary.worldgen.aerial; + +import fr.koka.sanctuary.worldgen.city.LostCityRenderer; +import java.util.Set; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.ByteTag; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.IntTag; +import net.minecraft.nbt.LongTag; +import net.minecraft.nbt.StringTag; +import net.minecraft.resources.ResourceKey; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.entity.npc.villager.VillagerProfession; +import net.minecraft.world.entity.npc.villager.VillagerType; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.StructurePiece; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext; +import net.minecraft.world.level.storage.loot.LootTable; + +/** Native geometry and one-shot occupant flag; neither removed loot nor an extracted merchant is respawned. */ +public final class AerialPiece24 extends StructurePiece { + private final AerialPlan24.Site site; + private boolean occupantPlaced; + public AerialPiece24(AerialPlan24.Site site) { + super(AerialStructures24.PIECE, 0, site.bounds()); this.site = site; setOrientation(null); + } + public AerialPiece24(StructurePieceSerializationContext context, CompoundTag tag) { + super(AerialStructures24.PIECE, tag); + site = decode(tag.getCompound("SanctuaryAerial").orElseThrow()); + if (!(tag.get("OccupantPlaced") instanceof ByteTag)) throw new IllegalArgumentException("Missing occupant marker"); + byte value = tag.getByte("OccupantPlaced").orElseThrow(); + if (value != 0 && value != 1) throw new IllegalArgumentException("Invalid occupant marker"); + occupantPlaced = value == 1; + if (occupantPlaced && !site.merchant()) throw new IllegalArgumentException("Non-merchant site has occupant marker"); + if (!getBoundingBox().equals(site.bounds())) throw new IllegalArgumentException("Aerial piece bounding box differs from saved geometry"); + setOrientation(null); + } + public AerialPlan24.Site site() { return site; } + public boolean occupantPlaced() { return occupantPlaced; } + public static CompoundTag encode(AerialPlan24.Site s) { + var tag = new CompoundTag(); tag.putInt("Schema", 1); tag.putString("Id", s.id()); tag.putString("Kind", s.kind().name()); + tag.putInt("X", s.minX()); tag.putInt("Y", s.minY()); tag.putInt("Z", s.minZ()); + tag.putInt("Width", s.width()); tag.putInt("Height", s.height()); tag.putInt("Depth", s.depth()); + tag.putInt("Facing", s.facing()); tag.putLong("Seed", s.seed()); tag.putInt("ShoreX", s.shoreX()); + tag.putInt("ShoreY", s.shoreY()); tag.putInt("ShoreZ", s.shoreZ()); tag.putBoolean("Waterfall", s.waterfall()); return tag; + } + public static AerialPlan24.Site decode(CompoundTag tag) { + if (!tag.keySet().equals(Set.of("Schema", "Id", "Kind", "X", "Y", "Z", "Width", "Height", "Depth", "Facing", "Seed", "ShoreX", "ShoreY", "ShoreZ", "Waterfall"))) + throw new IllegalArgumentException("Unexpected aerial piece fields"); + for (String name : new String[]{"Schema", "X", "Y", "Z", "Width", "Height", "Depth", "Facing", "ShoreX", "ShoreY", "ShoreZ"}) + if (!(tag.get(name) instanceof IntTag)) throw new IllegalArgumentException("Expected aerial integer " + name); + if (!(tag.get("Id") instanceof StringTag) || !(tag.get("Kind") instanceof StringTag) || !(tag.get("Seed") instanceof LongTag) + || !(tag.get("Waterfall") instanceof ByteTag) || tag.getInt("Schema").orElseThrow() != 1) + throw new IllegalArgumentException("Invalid aerial schema or fields"); + byte waterfall = tag.getByte("Waterfall").orElseThrow(); + if (waterfall != 0 && waterfall != 1) throw new IllegalArgumentException("Invalid waterfall flag"); + return new AerialPlan24.Site(tag.getString("Id").orElseThrow(), AerialPlan24.Kind.valueOf(tag.getString("Kind").orElseThrow()), + tag.getInt("X").orElseThrow(), tag.getInt("Y").orElseThrow(), tag.getInt("Z").orElseThrow(), + tag.getInt("Width").orElseThrow(), tag.getInt("Height").orElseThrow(), tag.getInt("Depth").orElseThrow(), + tag.getInt("Facing").orElseThrow(), tag.getLong("Seed").orElseThrow(), tag.getInt("ShoreX").orElseThrow(), + tag.getInt("ShoreY").orElseThrow(), tag.getInt("ShoreZ").orElseThrow(), waterfall == 1); + } + @Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) { + tag.put("SanctuaryAerial", encode(site)); tag.putBoolean("OccupantPlaced", occupantPlaced); + } + @Override public BlockPos getLocatorPosition() { return AerialGeometry24.entry(site); } + @Override public void postProcess(WorldGenLevel level, StructureManager manager, ChunkGenerator generator, + RandomSource random, BoundingBox clip, ChunkPos chunk, BlockPos pivot) { + AerialStructures24.placing(site, () -> { + AerialRenderer24.render(site, new LostCityRenderer.BlockWriter() { + private BlockPos position(int x, int y, int z) { return new BlockPos(site.minX() + x, site.minY() + y, site.minZ() + z); } + @Override public void set(int x, int y, int z, BlockState state) { + var pos = position(x, y, z); + if (site.bounds().isInside(pos) && clip.isInside(pos)) { + placeBlock(level, state, pos.getX(), pos.getY(), pos.getZ(), clip); + if (state.is(Blocks.WATER)) level.getChunk(pos).markPosForPostProcessing(pos); + } + } + @Override public void chest(int x, int y, int z, ResourceKey table, long seed) { + var pos = position(x, y, z); + if (site.bounds().isInside(pos) && clip.isInside(pos)) createChest(level, clip, RandomSource.create(seed), pos, table, null); + } + }, clip); + placeOccupant(level, clip); + }); + } + private synchronized void placeOccupant(WorldGenLevel level, BoundingBox clip) { + if (!site.merchant() || occupantPlaced) return; + var at = AerialGeometry24.occupant(site); + if (!clip.isInside(at)) return; + // Match vanilla structure-owned occupants: the persisted flag records placement, not continued presence. + var villager = EntityTypes.VILLAGER.create(level.getLevel(), EntitySpawnReason.STRUCTURE); + if (villager == null) throw new IllegalStateException("Merchant entity could not be created"); + villager.snapTo(at.getX() + .5, at.getY(), at.getZ() + .5, site.facing() * 90, 0); + villager.finalizeSpawn(level, level.getCurrentDifficultyAt(at), EntitySpawnReason.STRUCTURE, null); + var profession = site.kind() == AerialPlan24.Kind.MERCHANT_PROVISIONS ? VillagerProfession.FARMER : VillagerProfession.TOOLSMITH; + villager.setVillagerData(villager.getVillagerData().withType(level.registryAccess(), VillagerType.PLAINS) + .withProfession(level.registryAccess(), profession).withLevel(1)); + villager.setVillagerDataFinalized(true); + villager.setVillagerXp(1); // A previously practising merchant keeps the profession when carried away from its workstation. + villager.setPersistenceRequired(); villager.setUUID(AerialGeometry24.occupantUuid(site)); + occupantPlaced = true; + level.addFreshEntityWithPassengers(villager); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPlan24.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPlan24.java new file mode 100644 index 0000000..e9507fb --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPlan24.java @@ -0,0 +1,129 @@ +package fr.koka.sanctuary.worldgen.aerial; + +import fr.koka.sanctuary.expansion.ExpansionIsland; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.levelgen.structure.BoundingBox; + +/** Immutable initial sites. Claims reserve only their columns, never new terrain or a surrounding ring. */ +public final class AerialPlan24 { + /** Vegetation occupies additional height; it never replaces the twenty to thirty-five blocks of rock. */ + public static final int VEGETATION_HEADROOM = 8; + /** Overworld water can spread seven blocks horizontally after landing. Only the outlet reserves this neighbourhood. */ + public static final int WATERFALL_SPREAD = 7; + public static final AerialPlan24 EMPTY = new AerialPlan24(List.of()); + private final List sites; + private final Set claims; + + public enum Kind { BALLOON, MERCHANT_PROVISIONS, MERCHANT_TOOLS, MINERAL_ISLET } + + public AerialPlan24(List sites) { + this.sites = List.copyOf(sites); + if (!sites.isEmpty() && (sites.size() != 5 + || sites.stream().filter(s -> s.kind() == Kind.BALLOON).count() != 1 + || sites.stream().filter(s -> s.kind() == Kind.MERCHANT_PROVISIONS).count() != 1 + || sites.stream().filter(s -> s.kind() == Kind.MERCHANT_TOOLS).count() != 1 + || sites.stream().filter(s -> s.kind() == Kind.MINERAL_ISLET).count() != 2 + || sites.stream().filter(Site::waterfall).count() != 1)) + throw new IllegalArgumentException("An aerial plan contains all five sites and exactly one waterfall, or is disabled"); + var ids = new HashSet(); + var columns = new HashSet(); + for (Site site : sites) { + if (!ids.add(site.id())) throw new IllegalArgumentException("Duplicate aerial site"); + for (ChunkPos chunk : site.claimedChunks()) if (!columns.add(chunk.pack())) + throw new IllegalArgumentException("Overlapping aerial claims"); + } + if (columns.size() > 512) throw new IllegalArgumentException("Aerial claims exceed their finite budget"); + claims = Set.copyOf(columns); + } + + public List sites() { return sites; } + public boolean isEmpty() { return sites.isEmpty(); } + public Set claimsChunks() { return claims; } + public List atStartChunk(int x, int z) { + return sites.stream().filter(s -> s.startChunkX() == x && s.startChunkZ() == z).toList(); + } + public boolean ownsChunk(int x, int z) { return claims.contains(ChunkPos.pack(x, z)); } + public boolean conflicts(ExpansionIsland island) { + if (island.origin()) return false; + for (long packed : claims) { + var chunk = ChunkPos.unpack(packed); + if (island.ownsChunk(chunk.x(), chunk.z())) return true; + } + return false; + } + public boolean protects(BlockPos pos) { return sites.stream().anyMatch(s -> s.mayWrite(pos)); } + + /** Width and depth are local dimensions; facing rotates them around the world-space bounding-box minimum. */ + public record Site(String id, Kind kind, int minX, int minY, int minZ, int width, int height, int depth, + int facing, long seed, int shoreX, int shoreY, int shoreZ, boolean waterfall) { + public Site { + if (id == null || !id.matches("[a-z][a-z0-9_]{0,47}") || kind == null || facing < 0 || facing > 3 + || Math.abs((long) minX) > 2048 || Math.abs((long) minZ) > 2048 + || Math.abs((long) shoreX) > 1024 || Math.abs((long) shoreZ) > 1024 + || shoreY < 1 || shoreY > 383 || minY < 32 || height < 8 || height > 35 + VEGETATION_HEADROOM || minY + height > 350 + || width < 7 || width > 64 || depth < 7 || depth > 64 + || waterfall && kind != Kind.MINERAL_ISLET) + throw new IllegalArgumentException("Invalid aerial site geometry"); + if (kind == Kind.MINERAL_ISLET && (width < 40 || depth < 40 || height < 20 + VEGETATION_HEADROOM) + || kind == Kind.BALLOON && height != 28 + || (kind == Kind.MERCHANT_PROVISIONS || kind == Kind.MERCHANT_TOOLS) && (width != 11 || depth != 27 || height != 18)) + throw new IllegalArgumentException("Aerial site differs from its version24 gabarit"); + int worldWidth = facing % 2 == 0 ? width : depth; + int worldDepth = facing % 2 == 0 ? depth : width; + double cx = minX + (worldWidth - 1) / 2.0, cz = minZ + (worldDepth - 1) / 2.0; + if (Math.hypot(cx - shoreX, cz - shoreZ) > 512) + throw new IllegalArgumentException("Aerial site is farther than512 blocks from its shore witness"); + } + public int worldWidth() { return facing % 2 == 0 ? width : depth; } + public int worldDepth() { return facing % 2 == 0 ? depth : width; } + public int rockHeight() { + if (kind != Kind.MINERAL_ISLET) throw new IllegalStateException("Only mineral islets have a rock height"); + return height - VEGETATION_HEADROOM; + } + public int maxX() { return minX + worldWidth() - 1; } + public int maxY() { return minY + height - 1; } + public int maxZ() { return minZ + worldDepth() - 1; } + public int worldX(int x, int z) { + return minX + switch (facing) { case 0 -> x; case 1 -> depth - 1 - z; case 2 -> width - 1 - x; default -> z; }; + } + public int worldZ(int x, int z) { + return minZ + switch (facing) { case 0 -> z; case 1 -> x; case 2 -> depth - 1 - z; default -> width - 1 - x; }; + } + public BoundingBox bounds() { return new BoundingBox(minX, minY, minZ, maxX(), maxY(), maxZ()); } + public int startChunkX() { return (minX + worldWidth() / 2) >> 4; } + public int startChunkZ() { return (minZ + worldDepth() / 2) >> 4; } + public BlockPos locator() { return AerialGeometry24.entry(this); } + public boolean merchant() { return kind == Kind.MERCHANT_PROVISIONS || kind == Kind.MERCHANT_TOOLS; } + public BoundingBox waterfallClaimBounds() { + if (!waterfall) throw new IllegalStateException("This site has no waterfall"); + int x = worldX(width / 2, -1), z = worldZ(width / 2, -1); + return new BoundingBox(x - WATERFALL_SPREAD, 0, z - WATERFALL_SPREAD, + x + WATERFALL_SPREAD, AerialGeometry24.waterfallSource(this).getY(), z + WATERFALL_SPREAD); + } + public List claimedChunks() { + var result = new LinkedHashSet(); + addChunks(bounds(), result); + if (waterfall) addChunks(waterfallClaimBounds(), result); + return List.copyOf(result); + } + public boolean ownsChunk(int x, int z) { + return includesChunk(bounds(), x, z) || waterfall && includesChunk(waterfallClaimBounds(), x, z); + } + public boolean mayWrite(BlockPos pos) { + return bounds().isInside(pos) || waterfall && waterfallClaimBounds().isInside(pos); + } + private static boolean includesChunk(BoundingBox bounds, int x, int z) { + return x >= (bounds.minX() >> 4) && x <= (bounds.maxX() >> 4) + && z >= (bounds.minZ() >> 4) && z <= (bounds.maxZ() >> 4); + } + private static void addChunks(BoundingBox bounds, Set output) { + for (int x = bounds.minX() >> 4; x <= bounds.maxX() >> 4; x++) + for (int z = bounds.minZ() >> 4; z <= bounds.maxZ() >> 4; z++) output.add(new ChunkPos(x, z)); + } + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPlanner24.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPlanner24.java new file mode 100644 index 0000000..ca794d0 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialPlanner24.java @@ -0,0 +1,150 @@ +package fr.koka.sanctuary.worldgen.aerial; + +import fr.koka.sanctuary.expansion.ExpansionIsland; +import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.NoiseColumn; +import fr.koka.sanctuary.worldgen.aerial.AerialPlan24.Kind; +import fr.koka.sanctuary.worldgen.aerial.AerialPlan24.Site; + +/** Fixed work limits, not wall-clock-dependent draws. No chunk is requested or modified by this planner. */ +public final class AerialPlanner24 { + public static final int MAX_ATTEMPTS_PER_SITE = 64; + public static final int MAX_COLUMNS = 8192; + private AerialPlanner24() {} + public interface Terrain { + NoiseColumn column(int x, int z); + /** Planned structures may add rock above the original noise column or cut its support. */ + default boolean reserved(int x, int z, int minY, int maxY) { return false; } + } + + public static AerialPlan24 forWorld(ServerLevel level, SanctuaryChunkGenerator generator) { + if (generator.rootGeneration() != 24) throw new IllegalArgumentException("Aerial planning requires generation24"); + if (!level.structureManager().shouldGenerateStructures() || !generator.experimentalStructures()) return AerialPlan24.EMPTY; + generator.prepareAerialSampling24(level); + return plan(level.getSeed(), generator.initialDiameter(), new 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) { + return generator.intersectsExperimentalStructures(x, minY, z, x, maxY, z); + } + }); + } + + public static AerialPlan24 plan(long seed, int diameter, Terrain terrain) { + if (diameter != 512 && diameter != 724 && diameter != 1024) throw new IllegalArgumentException("Unsupported aerial root size"); + int radius = diameter == 724 ? 395 : diameter / 2 + 32; + var probe = new Probe(terrain, radius); + var sites = new ArrayList(); + var claimed = new HashSet(); + Kind[] kinds = {Kind.BALLOON, Kind.MERCHANT_PROVISIONS, Kind.MERCHANT_TOOLS, Kind.MINERAL_ISLET, Kind.MINERAL_ISLET}; + String[] ids = {"balloon", "merchant_provisions", "merchant_tools", "mineral_islet_water", "mineral_islet_dry"}; + double initialAngle = unit(mix(seed ^ 0xA24AE21AL)) * Math.PI * 2; + for (int ordinal = 0; ordinal < kinds.length; ordinal++) { + long siteSeed = ExpansionIsland.deriveSeed(seed ^ 0xA24AE21AL, ids[ordinal]); + int width = kinds[ordinal] == Kind.BALLOON ? 15 : ordinal < 3 ? 11 : 40 + 4 * (int) Math.floorMod(siteSeed, 7); + int depth = kinds[ordinal] == Kind.BALLOON ? 19 : ordinal < 3 ? 27 : width; + int bodyHeight = kinds[ordinal] == Kind.BALLOON ? 28 : ordinal < 3 ? 18 : 20 + (int) Math.floorMod(mix(siteSeed), 16); + int height = bodyHeight + (ordinal >= 3 ? AerialPlan24.VEGETATION_HEADROOM : 0); + Site accepted = null; + for (int attempt = 0; attempt < MAX_ATTEMPTS_PER_SITE; attempt++) { + long draw = mix(siteSeed + attempt * 0x9e3779b97f4a7c15L); + double angle = initialAngle + ordinal * (Math.PI * 2 / 5) + (unit(draw) - .5) * .9 + + (attempt / 16) * .41; + Shore shore = shore(probe, angle, radius); + if (shore == null) continue; + int facing = (int) Math.floorMod(mix(draw), 4); + int worldWidth = facing % 2 == 0 ? width : depth, worldDepth = facing % 2 == 0 ? depth : width; + // The full claim remains outside initial terrain ownership, including corner chunks. + double distance = radius + 32 + Math.hypot(worldWidth, worldDepth) / 2 + 64 + (attempt % 4) * 20; + int cx = (int) Math.round(Math.cos(angle) * distance), cz = (int) Math.round(Math.sin(angle) * distance); + if (Math.hypot(cx - shore.x, cz - shore.z) > 500) continue; + int bottom = ordinal == 0 ? Math.clamp(shore.y + 24, 286, 321) + : ordinal < 3 ? Math.clamp(shore.y - 4 + (int) Math.floorMod(draw, 13), 180, 300) + : Math.clamp(shore.y + 20 - bodyHeight / 2, 210, 320 - bodyHeight); + bottom = Math.min(bottom, 350 - height); + var site = new Site(ids[ordinal], kinds[ordinal], cx - worldWidth / 2, bottom, cz - worldDepth / 2, + width, height, depth, facing, siteSeed, shore.x, shore.y, shore.z, ordinal == 3); + if (site.claimedChunks().stream().anyMatch(c -> claimed.contains(c.pack()))) continue; + boolean rootOverlap = site.claimedChunks().stream().anyMatch(c -> { + int nearestX = Math.clamp(0, c.getMinBlockX(), c.getMaxBlockX()); + int nearestZ = Math.clamp(0, c.getMinBlockZ(), c.getMaxBlockZ()); + return Math.hypot(nearestX, nearestZ) <= radius + 32; + }); + if (rootOverlap || !visible(probe, shore, cx, bottom + (ordinal == 0 ? 18 : bodyHeight - 4), cz)) continue; + accepted = site; break; + } + if (accepted == null) throw new IllegalStateException("aerial_sites_unavailable: could not place " + ids[ordinal] + " within the bounded search"); + sites.add(accepted); + accepted.claimedChunks().forEach(c -> claimed.add(c.pack())); + } + var result = new AerialPlan24(sites); + System.getLogger(AerialPlanner24.class.getName()).log(System.Logger.Level.INFO, + "Aerial24 seed=" + seed + " diameter=" + diameter + " sites=" + result.sites().size() + + " claims=" + result.claimsChunks().size() + " columns=" + probe.columns.size()); + return result; + } + + private static Shore shore(Probe probe, double angle, int radius) { + double dx = Math.cos(angle), dz = Math.sin(angle); + // An outside-in scan finds the outer shore, never an internal pond. Refine its edge to a block. + for (int r = radius; r >= 32; r -= 8) { + int x = (int) Math.round(dx * r), z = (int) Math.round(dz * r), top = probe.surface(x, z); + if (top < 120 || top > 326 || probe.terrain.reserved(x, z, top, top + 3)) continue; + Shore best = new Shore(x, top + 1, z); + for (int fine = 1; fine <= 8; fine++) { + int fx = (int) Math.round(dx * (r + fine)), fz = (int) Math.round(dz * (r + fine)); + int ft = probe.surface(fx, fz); + if (ft < 120 || ft > 326 || probe.terrain.reserved(fx, fz, ft, ft + 3)) break; + best = new Shore(fx, ft + 1, fz); + } + return best; + } + return null; + } + + private static boolean visible(Probe probe, Shore shore, int targetX, int targetY, int targetZ) { + double distance = Math.hypot(targetX - shore.x, targetZ - shore.z); + int steps = (int) Math.ceil(distance); + for (int step = 1; step < steps; step++) { + double t = (double) step / steps; + int x = (int) Math.floor(shore.x + .5 + (targetX - shore.x) * t); + int z = (int) Math.floor(shore.z + .5 + (targetZ - shore.z) * t); + int y = (int) Math.floor(shore.y + 2.0 + (targetY - shore.y - 2.0) * t); + if (probe.solid(x, y, z) || probe.terrain.reserved(x, z, y, y)) return false; + } + return true; + } + private record Shore(int x, int y, int z) {} + private static final class Probe { + final Terrain terrain; final int radius; + final Map columns = new HashMap<>(); + Probe(Terrain terrain, int radius) { this.terrain = terrain; this.radius = radius; } + NoiseColumn column(int x, int z) { + if (Math.hypot(x, z) >= radius) return null; + long key = ((long) x << 32) ^ (z & 0xffffffffL); + var present = columns.get(key); if (present != null) return present; + if (columns.size() >= MAX_COLUMNS) throw new IllegalStateException("aerial_sites_unavailable: terrain column budget exhausted"); + var result = terrain.column(x, z); + if (result == null) throw new IllegalStateException("Aerial terrain query returned no column"); + columns.put(key, result); return result; + } + int surface(int x, int z) { + var column = column(x, z); if (column == null) return 0; + for (int y = 382; y > 0; y--) if (column.getBlock(y).isSolid() && column.getBlock(y).getFluidState().isEmpty()) return y; + return 0; + } + boolean solid(int x, int y, int z) { + var column = column(x, z); return column != null && !column.getBlock(y).isAir(); + } + } + private static long mix(long value) { + value = (value ^ value >>> 30) * 0xbf58476d1ce4e5b9L; + value = (value ^ value >>> 27) * 0x94d049bb133111ebL; + return value ^ value >>> 31; + } + private static double unit(long value) { return (value >>> 11) * 0x1.0p-53; } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialRenderer24.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialRenderer24.java new file mode 100644 index 0000000..0826a50 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialRenderer24.java @@ -0,0 +1,276 @@ +package fr.koka.sanctuary.worldgen.aerial; + +import fr.koka.sanctuary.SanctuaryMod; +import fr.koka.sanctuary.worldgen.city.LostCityRenderer; +import fr.koka.sanctuary.worldgen.detail.ConnectedBlocks20; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BedPart; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.DoubleBlockHalf; +import net.minecraft.world.level.block.state.properties.Half; +import net.minecraft.world.level.block.state.properties.SlabType; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.storage.loot.LootTable; + +/** Complete supported silhouettes; no terrain pillars, remote writes, or fabricated flowing-water columns. */ +public final class AerialRenderer24 { + private AerialRenderer24() {} + /** Export actual final block states for inspection, including rotations and generated chest positions. */ + public static void writeDiagnostic(Path path, List sites) throws IOException { + var result = new ArrayList>(); + for (var site : sites) { + var blocks = blocks(site); + var list = blocks.entrySet().stream().filter(e -> !e.getValue().isAir()).map(e -> { + var p = e.getKey(); var state = e.getValue(); + return List.of(p.getX() - site.minX(), p.getY() - site.minY(), p.getZ() - site.minZ(), + BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), state.toString()); + }).toList(); + result.add(Map.of("id", site.id(), "kind", site.kind().name(), "min", List.of(site.minX(), site.minY(), site.minZ()), + "size", List.of(site.worldWidth(), site.height(), site.worldDepth()), "seed", site.seed(), "facing", site.facing(), "blocks", list)); + } + if (path.toAbsolutePath().getParent() != null) Files.createDirectories(path.toAbsolutePath().getParent()); + Files.writeString(path, new com.google.gson.GsonBuilder().create().toJson(result) + "\n"); + } + public static Map blocks(AerialPlan24.Site site) { + var blocks = new LinkedHashMap(); + render(site, new LostCityRenderer.BlockWriter() { + @Override public void set(int x, int y, int z, BlockState state) { blocks.put(new BlockPos(site.minX() + x, site.minY() + y, site.minZ() + z), state); } + @Override public void chest(int x, int y, int z, ResourceKey loot, long seed) { set(x, y, z, Blocks.CHEST.defaultBlockState()); } + }); + return Map.copyOf(blocks); + } + public static ResourceKey loot(AerialPlan24.Site s) { + return ResourceKey.create(Registries.LOOT_TABLE, SanctuaryMod.id("chests/aerial24/" + s.kind().name().toLowerCase(java.util.Locale.ROOT))); + } + public static void render(AerialPlan24.Site s, LostCityRenderer.BlockWriter out) { render(s, out, s.bounds()); } + public static void render(AerialPlan24.Site s, LostCityRenderer.BlockWriter out, BoundingBox clip) { + // Connections see the complete airship before the native destination clips its own chunk. + // Mineral columns need no connection buffer and keep their bounded per-chunk scan. + LostCityRenderer.BlockWriter clipped = new LostCityRenderer.BlockWriter() { + @Override public void set(int x, int y, int z, BlockState state) { + if (clip.isInside(s.minX() + x, s.minY() + y, s.minZ() + z)) out.set(x, y, z, state); + } + @Override public void chest(int x, int y, int z, ResourceKey table, long seed) { + if (clip.isInside(s.minX() + x, s.minY() + y, s.minZ() + z)) out.chest(x, y, z, table, seed); + } + }; + var connected = s.kind() == AerialPlan24.Kind.MINERAL_ISLET ? null : ConnectedBlocks20.buffer(clipped); + var destination = connected == null ? clipped : connected; + var b = new Builder(s, destination, connected == null ? clip : s.bounds()); + switch (s.kind()) { + case BALLOON -> b.balloon(); + case MERCHANT_PROVISIONS, MERCHANT_TOOLS -> b.ship(); + case MINERAL_ISLET -> b.islet(); + } + var chest = AerialGeometry24.chest(s); + destination.chest(chest.getX() - s.minX(), chest.getY() - s.minY(), chest.getZ() - s.minZ(), loot(s), s.seed() ^ 0x24C4E57L); + if (connected != null) connected.flush(); + } + private static final class Builder { + final AerialPlan24.Site s; + final LostCityRenderer.BlockWriter out; + final BoundingBox clip; + final Rotation rotation; + Builder(AerialPlan24.Site s, LostCityRenderer.BlockWriter out, BoundingBox clip) { + this.s = s; this.out = out; this.clip = clip; + rotation = switch (s.facing()) { case 1 -> Rotation.CLOCKWISE_90; case 2 -> Rotation.CLOCKWISE_180; case 3 -> Rotation.COUNTERCLOCKWISE_90; default -> Rotation.NONE; }; + } + void set(int x, int y, int z, Block block) { set(x, y, z, block.defaultBlockState()); } + void set(int x, int y, int z, BlockState state) { + if (x < 0 || y < 0 || z < 0 || x >= s.width() || y >= s.height() || z >= s.depth()) return; + int wx = s.worldX(x, z), wz = s.worldZ(x, z); + if (clip.isInside(wx, s.minY() + y, wz)) out.set(wx - s.minX(), y, wz - s.minZ(), state.rotate(rotation)); + } + void box(int x0, int y0, int z0, int x1, int y1, int z1, Block block) { + for (int x = x0; x <= x1; x++) for (int z = z0; z <= z1; z++) for (int y = y0; y <= y1; y++) set(x, y, z, block); + } + void slab(int x, int y, int z) { set(x, y, z, Blocks.OAK_SLAB.defaultBlockState().setValue(BlockStateProperties.SLAB_TYPE, SlabType.BOTTOM)); } + void stair(int x, int y, int z, Direction direction, Half half) { + set(x, y, z, Blocks.OAK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, direction).setValue(BlockStateProperties.HALF, half)); + } + void lantern(int x, int y, int z) { set(x, y, z, Blocks.LANTERN.defaultBlockState().setValue(BlockStateProperties.HANGING, true)); } + void door(int x, int y, int z, Direction direction) { + var state = Blocks.OAK_DOOR.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, direction); + set(x, y, z, state.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.LOWER)); + set(x, y + 1, z, state.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.UPPER)); + } + void bed(int x, int y, int z) { + var state = Blocks.BED.white().defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.SOUTH); + set(x, y, z, state.setValue(BlockStateProperties.BED_PART, BedPart.FOOT)); + set(x, y, z + 1, state.setValue(BlockStateProperties.BED_PART, BedPart.HEAD)); + } + void balloon() { + int cx = s.width() / 2, cz = s.depth() / 2; + // The envelope is a rounded shell. Continuous spars attach the four real suspensions. + Block accent = (s.seed() & 1) == 0 ? Blocks.WOOL.yellow() : Blocks.WOOL.lightBlue(); + for (int y = 9; y <= 27; y++) for (int x = 0; x < s.width(); x++) for (int z = 0; z < s.depth(); z++) { + double q = Math.pow((x - cx) / 7.1, 2) + Math.pow((z - cz) / 9.1, 2) + Math.pow((y - 18) / 9.2, 2); + if (q <= 1.02 && q >= .73) set(x, y, z, Math.abs(x - cx) <= 1 || y == 19 ? accent : y % 6 == 0 ? Blocks.WOOL.lightGray() : Blocks.WOOL.white()); + } + box(cx - 3, 0, cz - 4, cx + 3, 1, cz + 4, Blocks.OAK_PLANKS); + for (int x = cx - 3; x <= cx + 3; x++) for (int z = cz - 4; z <= cz + 4; z++) { + if (Math.abs(x - cx) == 3 || Math.abs(z - cz) == 4) { + set(x, 2, z, Blocks.OAK_PLANKS); slab(x, 3, z); + } + } + // One entry faces a usable standing deck, with a removable railing for boarding. + set(cx, 2, cz - 4, Blocks.OAK_FENCE_GATE); set(cx, 3, cz - 4, Blocks.AIR); + for (int x : new int[]{cx - 3, cx + 3}) for (int z : new int[]{cz - 4, cz + 4}) { + box(x, 3, z, x, 5, z, Blocks.STRIPPED_OAK_LOG); + box(x, 6, z, x, 12, z, Blocks.IRON_CHAIN); + } + for (int z : new int[]{cz - 4, cz + 4}) box(cx - 4, 12, z, cx + 4, 12, z, Blocks.STRIPPED_OAK_LOG); + for (int x : new int[]{cx - 3, cx + 3}) box(x, 12, cz - 5, x, 12, cz + 5, Blocks.STRIPPED_OAK_LOG); + box(cx - 3, 6, cz, cx + 3, 6, cz, Blocks.STRIPPED_OAK_LOG); + box(cx - 3, 5, cz - 4, cx - 3, 6, cz + 4, Blocks.STRIPPED_OAK_LOG); + box(cx + 3, 5, cz - 4, cx + 3, 6, cz + 4, Blocks.STRIPPED_OAK_LOG); + set(cx, 5, cz, Blocks.COPPER_BULB.waxed().unaffected().defaultBlockState().setValue(BlockStateProperties.LIT, true)); + lantern(cx - 2, 5, cz); lantern(cx + 2, 5, cz); + set(cx + 2, 2, cz + 2, Blocks.CRAFTING_TABLE); + set(cx - 2, 2, cz - 2, Blocks.BARREL); + set(cx + 2, 2, cz - 2, Blocks.HAY_BLOCK); + } + int halfWidth(int y, int z) { + int end = Math.min(z, s.depth() - 1 - z); + return Math.max(0, Math.min(5, Math.min(1 + end, 1 + y))); + } + void ship() { + int cx = 5; + for (int z = 0; z < s.depth(); z++) for (int y = 0; y <= 5; y++) { + int half = halfWidth(y, z); + for (int x = cx - half; x <= cx + half; x++) { + boolean hull = y == 0 || y == 5 || Math.abs(x - cx) == half || z < 2 || z >= 25; + set(x, y, z, hull ? (y == 0 ? Blocks.STRIPPED_OAK_LOG : Blocks.OAK_PLANKS) : Blocks.AIR); + } + } + // Curved gunwales remain closed; the wide centre stays clear for boarding small boats. + for (int z = 1; z <= 25; z++) { + int half = halfWidth(5, z); + for (int side : new int[]{-1, 1}) { + int x = cx + side * half; + set(x, 6, z, Blocks.OAK_FENCE); // Connection properties are completed below. + if (z % 5 == 0) set(x, 6, z, Blocks.STRIPPED_OAK_LOG); + } + } + for (int x = 3; x <= 7; x++) { set(x, 6, 1, Blocks.OAK_FENCE); set(x, 6, 25, Blocks.OAK_FENCE); } + // Roof, walls, threshold and cabin circulation share one continuous frame. + box(2, 6, 17, 8, 9, 24, Blocks.OAK_PLANKS); + box(3, 6, 18, 7, 9, 23, Blocks.AIR); + box(2, 10, 17, 8, 10, 24, Blocks.OAK_PLANKS); + for (int z = 17; z <= 24; z++) { + stair(1, 10, z, Direction.EAST, Half.BOTTOM); stair(9, 10, z, Direction.WEST, Half.BOTTOM); + } + for (int x : new int[]{2, 8}) for (int z : new int[]{17, 24}) box(x, 6, z, x, 10, z, Blocks.STRIPPED_OAK_LOG); + for (int z = 19; z <= 21; z++) for (int y = 7; y <= 8; y++) { set(2, y, z, Blocks.GLASS); set(8, y, z, Blocks.GLASS); } + for (int x = 4; x <= 6; x++) set(x, 8, 24, Blocks.GLASS); + door(5, 6, 17, Direction.SOUTH); + bed(3, 6, 22); + set(7, 6, 19, s.kind() == AerialPlan24.Kind.MERCHANT_PROVISIONS ? Blocks.COMPOSTER : Blocks.SMITHING_TABLE); + set(3, 6, 19, Blocks.CRAFTING_TABLE); set(3, 6, 20, Blocks.BARREL); + lantern(5, 9, 20); lantern(5, 9, 23); + // Cale reached by an actual supported stair run; no disconnected decorative floor. + for (int step = 0; step < 4; step++) { + int z = 10 + step, y = 4 - step; + box(5, 1, z, 5, y, z, Blocks.OAK_PLANKS); + box(5, y + 1, z, 5, 6, z, Blocks.AIR); + stair(5, y, z, Direction.NORTH, Half.BOTTOM); + } + box(3, 1, 7, 7, 1, 20, Blocks.OAK_PLANKS); + set(3, 2, 17, Blocks.BARREL); set(7, 2, 17, Blocks.BARREL); + lantern(3, 4, 8); lantern(7, 4, 8); + Block cargo = s.kind() == AerialPlan24.Kind.MERCHANT_PROVISIONS ? Blocks.HAY_BLOCK : Blocks.IRON_BLOCK; + box(2, 6, 5, 3, 7, 7, cargo); box(7, 6, 5, 8, 6, 7, Blocks.BARREL); + box(5, 6, 4, 5, 16, 4, Blocks.STRIPPED_OAK_LOG); + for (int x = 1; x <= 9; x++) + set(x, 16, 4, Blocks.STRIPPED_OAK_LOG.defaultBlockState().setValue(BlockStateProperties.AXIS, Direction.Axis.X)); + for (int x = 2; x <= 8; x++) + set(x, 8, 4, Blocks.STRIPPED_OAK_LOG.defaultBlockState().setValue(BlockStateProperties.AXIS, Direction.Axis.X)); + set(5, 17, 4, Blocks.LANTERN); + // Seven by seven sail around the continuous mast: yard above, boom below, clear deck beneath. + for (int y = 9; y <= 15; y++) for (int x = 2; x <= 8; x++) + if (x != cx) set(x, y, 4, y == 11 ? Blocks.WOOL.lightGray() : Blocks.WOOL.white()); + // Stable handrails are explicit, rather than depending on neighbouring chunk updates. + for (int z = 2; z <= 24; z++) for (int x : new int[]{0, 10}) if (halfWidth(5, z) == 5 && z % 5 != 0 && !(x == 0 && z == 12)) + set(x, 6, z, Blocks.OAK_FENCE.defaultBlockState().setValue(BlockStateProperties.NORTH, true).setValue(BlockStateProperties.SOUTH, true)); + } + void islet() { + for (int x = 0; x < s.width(); x++) for (int z = 0; z < s.depth(); z++) { + if (!clip.isInside(s.worldX(x, z), s.minY(), s.worldZ(x, z)) + && (s.worldX(x,z) < clip.minX() || s.worldX(x,z) > clip.maxX() || s.worldZ(x,z) < clip.minZ() || s.worldZ(x,z) > clip.maxZ())) continue; + int top = AerialGeometry24.terrainTop(s, x, z); + for (int y = 0; y <= top; y++) if (AerialGeometry24.mineralBody(s, x, y, z)) set(x, y, z, ore(x, y, z, top)); + if (AerialGeometry24.naturalSurface(s, x, z)) { + set(x, top, z, Blocks.GRASS_BLOCK); + int soil = 2 + (int) Math.floorMod(AerialGeometry24.mix(s.seed() ^ x * 19349663L ^ z * 83492791L), 2); + for (int layer = 1; layer <= soil && top - layer > AerialGeometry24.terrainBottom(s, x, z); layer++) + if (AerialGeometry24.mineralBody(s, x, top - layer, z)) set(x, top - layer, z, Blocks.DIRT); + if (!AerialGeometry24.arrivalColumn(s, x, z)) { + long foliage = AerialGeometry24.mix(s.seed() ^ x * 374761393L ^ z * 668265263L); + int roll = (int) Math.floorMod(foliage, 100); + if (roll < 8) set(x, top + 1, z, switch ((int) Math.floorMod(foliage >>> 16, 4)) { + case 0 -> Blocks.DANDELION; case 1 -> Blocks.POPPY; case 2 -> Blocks.OXEYE_DAISY; default -> Blocks.CORNFLOWER; + }); + else if (roll < 44) set(x, top + 1, z, Blocks.SHORT_GRASS); + } + } + } + if (s.waterfall()) { + int cx = s.width() / 2, floor = AerialGeometry24.rockHeight(s) - 4; + for (int z = 0; z <= 4; z++) { + set(cx, floor, z, Blocks.STONE); + set(cx - 1, floor + 1, z, Blocks.STONE); set(cx + 1, floor + 1, z, Blocks.STONE); + box(cx, floor + 1, z, cx, floor + 3, z, Blocks.AIR); + } + set(cx, floor + 1, 4, Blocks.STONE); + set(cx, floor + 1, 3, Blocks.WATER); + } + // A true buried chest: intact roof and floor, no decorative open loot pedestal. + var c = AerialGeometry24.chest(s); + int x = s.width() / 2 + 4, z = s.depth() / 2 + 2, y = c.getY() - s.minY(); + box(x - 1, y - 1, z - 1, x + 1, y + 2, z + 1, Blocks.STONE); + for (var tree : AerialGeometry24.trees(s)) tree(tree); + } + void tree(AerialGeometry24.Tree tree) { + Block leaves = tree.birch() ? Blocks.BIRCH_LEAVES : Blocks.OAK_LEAVES; + int lastLog = tree.groundY() + tree.trunkHeight(); + for (int y = lastLog - 2; y <= lastLog + 1; y++) { + int radius = y < lastLog ? 2 : 1; + for (int dx = -radius; dx <= radius; dx++) for (int dz = -radius; dz <= radius; dz++) { + if (Math.abs(dx) + Math.abs(dz) > (radius == 2 ? 3 : 1)) continue; + int distance = Math.max(1, Math.abs(dx) + Math.abs(dz) + Math.max(0, y - lastLog)); + set(tree.x() + dx, y, tree.z() + dz, leaves.defaultBlockState() + .setValue(BlockStateProperties.DISTANCE, distance).setValue(BlockStateProperties.PERSISTENT, false)); + } + } + box(tree.x(), tree.groundY() + 1, tree.z(), tree.x(), lastLog, tree.z(), tree.birch() ? Blocks.BIRCH_LOG : Blocks.OAK_LOG); + } + Block ore(int x, int y, int z, int top) { + double depth = (double) (top - y) / Math.max(1, top); + long cell = AerialGeometry24.mix(s.seed() ^ ((long)(x / 4) << 42) ^ ((long)(z / 4) << 21) ^ (y / 3)); + int roll = (int) Math.floorMod(cell, 100); + // Relative strata make the lower suspended rock useful without pretending it is y=-64. + if (roll < 37) { + if (depth < .27) return roll < 15 ? Blocks.COAL_ORE : roll < 27 ? Blocks.COPPER_ORE : Blocks.IRON_ORE; + if (depth < .60) return roll < 13 ? Blocks.IRON_ORE : roll < 21 ? Blocks.GOLD_ORE : roll < 29 ? Blocks.LAPIS_ORE : Blocks.COAL_ORE; + return roll < 10 ? Blocks.DEEPSLATE_IRON_ORE : roll < 19 ? Blocks.DEEPSLATE_REDSTONE_ORE : roll < 27 ? Blocks.DEEPSLATE_GOLD_ORE : roll < 34 ? Blocks.DEEPSLATE_DIAMOND_ORE : Blocks.DEEPSLATE_EMERALD_ORE; + } + if (depth > .6) return roll < 59 ? Blocks.TUFF : Blocks.DEEPSLATE; + return roll < 53 ? Blocks.ANDESITE : roll < 59 ? Blocks.COBBLESTONE : Blocks.STONE; + } + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStore24.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStore24.java new file mode 100644 index 0000000..4ddc9af --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStore24.java @@ -0,0 +1,166 @@ +package fr.koka.sanctuary.worldgen.aerial; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +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.math.BigDecimal; +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.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.Set; +import java.util.function.Supplier; + +/** Content-addressed initial layout. Its hash is authoritative only once bound by the expansion journal24. */ +public final class AerialStore24 { + private static final int MAX_BYTES = 32768; + private static final Set ROOT_FIELDS = Set.of("schema", "world_seed", "root_diameter", "generation", "sites"); + private static final Set SITE_FIELDS = Set.of("id", "kind", "min_x", "min_y", "min_z", "width", "height", "depth", + "facing", "seed", "shore_x", "shore_y", "shore_z", "waterfall"); + private AerialStore24() {} + public record Stored(String token, AerialPlan24 plan) {} + + /** Store lives beside sanctuary-world-v24, so creating an orphan manifest never looks like an initialized journal. */ + public static Path directory(Path journalDirectory) { return journalDirectory.resolveSibling("sanctuary-aerial-v24"); } + + public static Stored create(Path directory, long seed, int diameter, Supplier factory) throws IOException { + AerialPlan24 plan = factory.get(); + validateExtent(plan, diameter); + String encoded = encode(plan, seed, diameter).toString() + "\n"; + byte[] bytes = encoded.getBytes(StandardCharsets.UTF_8); + if (bytes.length > MAX_BYTES) throw new IOException("Aerial layout exceeds its size limit"); + String token = digest(bytes); + if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS) && !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) + throw new IOException("Aerial manifest path is not a directory"); + Files.createDirectories(directory); + Path file = directory.resolve(token + ".json"); + if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { + Path temporary = Files.createTempFile(directory, ".aerial-", ".tmp"); + try { + try (FileChannel output = FileChannel.open(temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) { + var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true); + } + Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE); + if (!file.getFileSystem().getSeparator().equals("\\")) { + try (FileChannel parent = FileChannel.open(directory, StandardOpenOption.READ)) { parent.force(true); } + } + } finally { Files.deleteIfExists(temporary); } + } + Stored verified = load(directory, token, seed, diameter); + if (!verified.plan().sites().equals(plan.sites())) throw new IOException("Aerial manifest read-back differs from its plan"); + return verified; + } + + public static Stored load(Path directory, String token, long seed, int diameter) throws IOException { + if (token == null || !token.matches("[0-9a-f]{64}")) throw new IOException("Invalid aerial manifest token"); + Path file = directory.resolve(token + ".json"); + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) + || Files.size(file) > MAX_BYTES) throw new IOException("Missing or invalid saved aerial manifest"); + byte[] bytes = Files.readAllBytes(file); + if (!digest(bytes).equals(token)) throw new IOException("Saved aerial manifest has changed"); + try (var reader = new JsonReader(new StringReader(new String(bytes, StandardCharsets.UTF_8)))) { + reader.setStrictness(Strictness.STRICT); + JsonObject root = readJson(reader, 0).getAsJsonObject(); + if (reader.peek() != JsonToken.END_DOCUMENT) throw new IOException("Trailing aerial manifest data"); + fields(root, ROOT_FIELDS); + if (integer(root, "schema") != 1 || integer(root, "generation") != 24 + || number(root, "world_seed").longValueExact() != seed || integer(root, "root_diameter") != diameter) + throw new IOException("Aerial manifest differs from its world contract"); + var sites = new ArrayList(); + for (JsonElement item : root.getAsJsonArray("sites")) { + JsonObject site = item.getAsJsonObject(); fields(site, SITE_FIELDS); + sites.add(new AerialPlan24.Site(string(site, "id"), AerialPlan24.Kind.valueOf(string(site, "kind")), + integer(site, "min_x"), integer(site, "min_y"), integer(site, "min_z"), integer(site, "width"), + integer(site, "height"), integer(site, "depth"), integer(site, "facing"), number(site, "seed").longValueExact(), + integer(site, "shore_x"), integer(site, "shore_y"), integer(site, "shore_z"), bool(site, "waterfall"))); + } + var plan = new AerialPlan24(sites); validateExtent(plan, diameter); + return new Stored(token, plan); + } catch (RuntimeException error) { throw new IOException("Invalid aerial manifest", error); } + } + + private static void validateExtent(AerialPlan24 plan, int diameter) throws IOException { + if (diameter != 512 && diameter != 724 && diameter != 1024) throw new IOException("Unsupported aerial root size"); + int radius = (diameter == 724 ? 395 : diameter / 2 + 32) + 32; + for (long packed : plan.claimsChunks()) { + var c = net.minecraft.world.level.ChunkPos.unpack(packed); + int x = Math.clamp(0, c.getMinBlockX(), c.getMaxBlockX()); + int z = Math.clamp(0, c.getMinBlockZ(), c.getMaxBlockZ()); + if (Math.hypot(x, z) <= radius) throw new IOException("Aerial claim overlaps initial terrain ownership"); + } + } + private static JsonObject encode(AerialPlan24 plan, long seed, int diameter) { + var root = new JsonObject(); + root.addProperty("schema", 1); root.addProperty("world_seed", seed); root.addProperty("root_diameter", diameter); root.addProperty("generation", 24); + var sites = new JsonArray(); + for (var s : plan.sites()) { + var site = new JsonObject(); site.addProperty("id", s.id()); site.addProperty("kind", s.kind().name()); + site.addProperty("min_x", s.minX()); site.addProperty("min_y", s.minY()); site.addProperty("min_z", s.minZ()); + site.addProperty("width", s.width()); site.addProperty("height", s.height()); site.addProperty("depth", s.depth()); + site.addProperty("facing", s.facing()); site.addProperty("seed", s.seed()); + site.addProperty("shore_x", s.shoreX()); site.addProperty("shore_y", s.shoreY()); site.addProperty("shore_z", s.shoreZ()); + site.addProperty("waterfall", s.waterfall()); sites.add(site); + } + root.add("sites", sites); return root; + } + private static String digest(byte[] bytes) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } + catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } + private static void fields(JsonObject object, Set expected) throws IOException { + if (!object.keySet().equals(expected)) throw new IOException("Unexpected aerial manifest fields"); + } + private static String string(JsonObject object, String key) { + var value = object.getAsJsonPrimitive(key); if (!value.isString()) throw new IllegalArgumentException("Expected aerial text"); + return value.getAsString(); + } + private static BigDecimal number(JsonObject object, String key) { + var value = object.getAsJsonPrimitive(key); if (!value.isNumber()) throw new IllegalArgumentException("Expected aerial number"); + return value.getAsBigDecimal(); + } + private static int integer(JsonObject object, String key) { return number(object, key).intValueExact(); } + private static boolean bool(JsonObject object, String key) { + var value = object.getAsJsonPrimitive(key); if (!value.isBoolean()) throw new IllegalArgumentException("Expected aerial boolean"); + return value.getAsBoolean(); + } + private static JsonElement readJson(JsonReader reader, int depth) throws IOException { + if (depth > 3) throw new IOException("Aerial manifest exceeds its nesting limit"); + return switch (reader.peek()) { + case BEGIN_OBJECT -> { + reader.beginObject(); var object = new JsonObject(); + while (reader.hasNext()) { + String name = reader.nextName(); + if (object.has(name) || object.size() >= 16) throw new IOException("Duplicate or excessive aerial fields"); + object.add(name, readJson(reader, depth + 1)); + } + reader.endObject(); yield object; + } + case BEGIN_ARRAY -> { + reader.beginArray(); var array = new JsonArray(); + while (reader.hasNext()) { + if (array.size() >= 5) throw new IOException("Too many aerial sites"); + array.add(readJson(reader, depth + 1)); + } + reader.endArray(); yield array; + } + case STRING -> new JsonPrimitive(reader.nextString()); + case NUMBER -> new JsonPrimitive(new BigDecimal(reader.nextString())); + case BOOLEAN -> new JsonPrimitive(reader.nextBoolean()); + default -> throw new IOException("Invalid aerial JSON value"); + }; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStructure24.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStructure24.java new file mode 100644 index 0000000..6a53022 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStructure24.java @@ -0,0 +1,35 @@ +package fr.koka.sanctuary.worldgen.aerial; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator; +import java.util.Optional; +import java.util.Set; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureType; + +/** Each family uses its own native registry identity, independent of any terrain-island owner. */ +public final class AerialStructure24 extends Structure { + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( + settingsCodec(instance), Codec.STRING.fieldOf("family").forGetter(AerialStructure24::family) + ).apply(instance, AerialStructure24::new)); + private final String family; + public AerialStructure24(StructureSettings settings, String family) { + super(settings); + if (!Set.of("balloon", "merchant_ship", "mineral_islet").contains(family)) throw new IllegalArgumentException("Unknown aerial family"); + this.family = family; + } + public String family() { return family; } + @Override protected Optional findGenerationPoint(GenerationContext context) { + if (!(context.chunkGenerator() instanceof SanctuaryChunkGenerator generator) || generator.rootGeneration() != 24 + || !generator.experimentalStructures()) return Optional.empty(); + var sites = generator.aerialPlan24().atStartChunk(context.chunkPos().x(), context.chunkPos().z()).stream() + .filter(site -> AerialStructures24.family(site).equals(family)).toList(); + if (sites.isEmpty()) return Optional.empty(); + return Optional.of(new GenerationStub(AerialGeometry24.entry(sites.getFirst()), builder -> { + for (var site : sites) builder.addPiece(new AerialPiece24(site)); + })); + } + @Override public StructureType type() { return AerialStructures24.TYPE; } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStructures24.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStructures24.java new file mode 100644 index 0000000..5df3aba --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/aerial/AerialStructures24.java @@ -0,0 +1,75 @@ +package fr.koka.sanctuary.worldgen.aerial; + +import fr.koka.sanctuary.SanctuaryMod; +import fr.koka.sanctuary.expansion.ExpansionChunkGenerator; +import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator; +import java.util.List; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import net.minecraft.world.level.levelgen.densityfunction.SamplerContext; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.StructureType; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceType; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; + +/** Native starts for five immutable initial sites. Their void chunks need no terrain island owner. */ +public final class AerialStructures24 { + public static final ResourceKey BALLOON = key("aerial_balloon"); + public static final ResourceKey MERCHANT_SHIP = key("aerial_merchant_ship"); + public static final ResourceKey MINERAL_ISLET = key("aerial_mineral_islet"); + public static final List> KEYS = List.of(BALLOON, MERCHANT_SHIP, MINERAL_ISLET); + public static final StructureType TYPE = Registry.register(BuiltInRegistries.STRUCTURE_TYPE, + SanctuaryMod.id("aerial_v24"), () -> AerialStructure24.CODEC); + public static final StructurePieceType PIECE = Registry.register(BuiltInRegistries.STRUCTURE_PIECE, + SanctuaryMod.id("aerial_piece_v24"), AerialPiece24::new); + private static final ThreadLocal PLACING = new ThreadLocal<>(); + private AerialStructures24() {} + private static ResourceKey key(String id) { return ResourceKey.create(Registries.STRUCTURE, SanctuaryMod.id(id)); } + public static void register() {} + public static String family(AerialPlan24.Site site) { + return switch (site.kind()) { case BALLOON -> "balloon"; case MINERAL_ISLET -> "mineral_islet"; default -> "merchant_ship"; }; + } + public static ResourceKey key(AerialPlan24.Site site) { + return switch (site.kind()) { case BALLOON -> BALLOON; case MINERAL_ISLET -> MINERAL_ISLET; default -> MERCHANT_SHIP; }; + } + public static String structureId(AerialPlan24.Site site) { return key(site).identifier().toString(); } + public static boolean isPlacing() { return PLACING.get() != null; } + public static boolean mayWrite(BlockPos pos) { var site = PLACING.get(); return site != null && site.bounds().isInside(pos); } + static void placing(AerialPlan24.Site site, Runnable action) { + var old = PLACING.get(); PLACING.set(site); + try { action.run(); } finally { if (old == null) PLACING.remove(); else PLACING.set(old); } + } + public static void generate(ExpansionChunkGenerator generator, RegistryAccess registries, + ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk, + StructureTemplateManager templates, ResourceKey level) { + if (!Level.OVERWORLD.equals(level) || !manager.shouldGenerateStructures() + || !(generator instanceof SanctuaryChunkGenerator sanctuary) || generator.rootGeneration() != 24 + || !sanctuary.experimentalStructures()) return; + var sites = sanctuary.aerialPlan24().atStartChunk(chunk.getPos().x(), chunk.getPos().z()); + if (sites.isEmpty()) return; + // Climate evaluation may use the original island's random state; geometry belongs only to the saved site. + var origin = generator.source().islands().stream().filter(i -> i.origin()).findFirst().orElseThrow(); + var random = generator.randomState(origin); + var sampler = random.createClimateSampler(SamplerContext.builder().enableCaches().build()); + for (var key : sites.stream().map(AerialStructures24::key).distinct().toList()) { + Holder holder = registries.lookupOrThrow(Registries.STRUCTURE).getOrThrow(key); + Structure structure = holder.value(); + StructureStart previous = manager.getStartForStructure(structure, chunk); + if (previous != null && previous.isValid()) continue; + var start = structure.generate(holder, level, registries, generator, generator.getBiomeSource(), sampler, + random, templates, origin.seed(), chunk.getPos(), previous == null ? 0 : previous.getReferences(), + chunk, structure.biomes()::contains); + if (start.isValid()) manager.setStartForStructure(structure, start, chunk); + } + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityStructure.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityStructure.java index 6edb40f..0c3c5c8 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityStructure.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityStructure.java @@ -29,18 +29,18 @@ public final class LostCityStructure extends Structure { throw new IllegalStateException("Lost City district exceeds native structure reference range"); } return Optional.of(new GenerationStub(LostCityStructures.locator(district), pieces -> { - if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || generator.rootGeneration() == 23) && generator.cityReservation16(island) != null - && ((generator.rootGeneration() != 21 && generator.rootGeneration() != 22 && generator.rootGeneration() != 23) || generator.cityPlacement21(island).core().surface().districts().contains(district))) + if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24)) && generator.cityReservation16(island) != null + && ((generator.rootGeneration() != 21 && generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24)) || generator.cityPlacement21(island).core().surface().districts().contains(district))) pieces.addPiece(new LostCityFoundationPiece16(generator.cityReservation16(island), district.seed())); if (generator.rootGeneration() == 15) for (var support : generator.citySupports(island)) { if (support.startChunkX() == district.startChunkX() && support.startChunkZ() == district.startChunkZ()) pieces.addPiece(new LostCitySupportPiece15(support)); } - if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || generator.rootGeneration() == 23)) for (var support : generator.citySupports16(island)) { + if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24))) for (var support : generator.citySupports16(island)) { if (support.startChunkX() == district.startChunkX() && support.startChunkZ() == district.startChunkZ()) pieces.addPiece(new LostCitySupportPiece16(support)); } - district.parcels().forEach(parcel -> pieces.addPiece((generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || generator.rootGeneration() == 23) ? new LostCityPiece21(parcel) : generator.rootGeneration() == 20 ? new LostCityPiece20(parcel) : (generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || generator.rootGeneration() == 23) ? new LostCityPiece16(parcel) : generator.rootGeneration() == 15 + district.parcels().forEach(parcel -> pieces.addPiece((generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24)) ? new LostCityPiece21(parcel) : generator.rootGeneration() == 20 ? new LostCityPiece20(parcel) : (generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24)) ? new LostCityPiece16(parcel) : generator.rootGeneration() == 15 ? new LostCityPiece15(parcel) : new LostCityPiece(parcel))); if (generator.rootGeneration() == 15) for (var network : generator.undergroundPlan(island).networks()) { if (network.startChunkX() != district.startChunkX() || network.startChunkZ() != district.startChunkZ()) continue; @@ -50,13 +50,13 @@ public final class LostCityStructure extends Structure { network.modules().stream().filter(m -> m.kind() == LostCityUnderground15.Kind.ACCESS) .forEach(m -> pieces.addPiece(new LostCityUndergroundPiece15(m))); } - if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || generator.rootGeneration() == 23)) for (var network : generator.undergroundPlan16(island).networks()) { + if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24))) for (var network : generator.undergroundPlan16(island).networks()) { if (network.startChunkX() != district.startChunkX() || network.startChunkZ() != district.startChunkZ()) continue; // Surface comes first, rooms/corridors next, shafts last so every entrance remains open. network.modules().stream().filter(m -> m.kind() != LostCityUnderground16.Kind.ACCESS) - .forEach(m -> pieces.addPiece((generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || generator.rootGeneration() == 23) ? new LostCityUndergroundPiece20(m) : new LostCityUndergroundPiece16(m))); + .forEach(m -> pieces.addPiece((generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24)) ? new LostCityUndergroundPiece20(m) : new LostCityUndergroundPiece16(m))); network.modules().stream().filter(m -> m.kind() == LostCityUnderground16.Kind.ACCESS) - .forEach(m -> pieces.addPiece((generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || generator.rootGeneration() == 23) ? new LostCityUndergroundPiece20(m) : new LostCityUndergroundPiece16(m))); + .forEach(m -> pieces.addPiece((generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24)) ? new LostCityUndergroundPiece20(m) : new LostCityUndergroundPiece16(m))); } })); } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundStructure22.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundStructure22.java index 900c355..4c16e65 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundStructure22.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundStructure22.java @@ -14,9 +14,9 @@ public final class LostCityUndergroundStructure22 extends Structure { @Override protected Optional findGenerationPoint(GenerationContext context) { if (!(context.chunkGenerator() instanceof SanctuaryChunkGenerator generator) - || (generator.rootGeneration() != 22 && generator.rootGeneration() != 23)) return Optional.empty(); + || (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24))) return Optional.empty(); var island = generator.islandForChunk(context.chunkPos().x(), context.chunkPos().z()); - if (island == null || !island.origin() || (island.generation() != 22 && island.generation() != 23)) return Optional.empty(); + if (island == null || !island.origin() || (island.generation() != 22 && (island.generation() != 23 && island.generation() != 24))) return Optional.empty(); var networks = generator.undergroundHall22().networks().stream() .filter(n -> n.startChunkX() == context.chunkPos().x() && n.startChunkZ() == context.chunkPos().z()).toList(); if (networks.isEmpty()) return Optional.empty(); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundStructures22.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundStructures22.java index 1e7bfff..7604675 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundStructures22.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityUndergroundStructures22.java @@ -41,9 +41,9 @@ public final class LostCityUndergroundStructures22 { ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk, StructureTemplateManager templates, ResourceKey level) { if (!Level.OVERWORLD.equals(level) || !manager.shouldGenerateStructures() - || !(generator instanceof SanctuaryChunkGenerator sanctuary) || (generator.rootGeneration() != 22 && generator.rootGeneration() != 23)) return; + || !(generator instanceof SanctuaryChunkGenerator sanctuary) || (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24))) return; var island = generator.islandForChunk(chunk.getPos().x(), chunk.getPos().z()); - if (island == null || !island.origin() || (island.generation() != 22 && island.generation() != 23)) return; + if (island == null || !island.origin() || (island.generation() != 22 && (island.generation() != 23 && island.generation() != 24))) return; if (sanctuary.undergroundHall22().networks().stream().noneMatch(n -> n.startChunkX() == chunk.getPos().x() && n.startChunkZ() == chunk.getPos().z())) return; Holder holder = registries.lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY); @@ -63,14 +63,14 @@ public final class LostCityUndergroundStructures22 { BlockPos origin, int radius, boolean skipReferenced) { if (!Level.OVERWORLD.equals(level.dimension()) || !(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator) - || (generator.rootGeneration() != 22 && generator.rootGeneration() != 23) || !level.structureManager().shouldGenerateStructures()) return null; + || (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24)) || !level.structureManager().shouldGenerateStructures()) return null; Holder holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY); if (!structures.contains(holder)) return null; var candidates = new ArrayList(); int originChunkX = origin.getX() >> 4, originChunkZ = origin.getZ() >> 4; for (var network : generator.undergroundHall22().networks()) { var island = generator.islandForChunk(network.startChunkX(), network.startChunkZ()); - if (island == null || !island.origin() || (island.generation() != 22 && island.generation() != 23) || !island.status().equals("ready")) continue; + if (island == null || !island.origin() || (island.generation() != 22 && (island.generation() != 23 && island.generation() != 24)) || !island.status().equals("ready")) continue; if (Math.abs((long) network.startChunkX() - originChunkX) <= radius && Math.abs((long) network.startChunkZ() - originChunkZ) <= radius) candidates.add(network); } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageCommands19.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageCommands19.java index 07a8497..180643a 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageCommands19.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageCommands19.java @@ -19,7 +19,7 @@ public final class HeritageCommands19 { var level = source.getLevel(); if (!Level.OVERWORLD.equals(level.dimension()) || !(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator) - || (generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && generator.rootGeneration() != 23)) || (generator.rootGeneration() != 22 && generator.rootGeneration() != 23) && !generator.experimentalStructures()) { + || (generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24))) || (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24)) && !generator.experimentalStructures()) { source.sendFailure(Component.translatable("sanctuary.heritage.unavailable")); return 0; } @@ -28,20 +28,20 @@ public final class HeritageCommands19 { for (var site : plan.sites()) source.sendSuccess(() -> Component.translatable("sanctuary.heritage.entry", Component.translatable("sanctuary.heritage.kind." + site.kind().name().toLowerCase(Locale.ROOT)), site.entry().x(), site.entry().y(), site.entry().z()), false); - if (generator.rootGeneration() == 21 || (generator.rootGeneration() == 22 || generator.rootGeneration() == 23)) for (var module : generator.transitPlan21().modules()) { + if (generator.rootGeneration() == 21 || (generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24))) for (var module : generator.transitPlan21().modules()) { if (module.kind() != fr.koka.sanctuary.worldgen.transit.TransitPlan21.Kind.ACCESS) continue; var at = fr.koka.sanctuary.worldgen.transit.TransitPiece21.locator(module); source.sendSuccess(() -> Component.translatable("sanctuary.transit.entry", at.getX(), at.getY(), at.getZ()), false); } - if ((generator.rootGeneration() == 22 || generator.rootGeneration() == 23)) for (var network : generator.undergroundHall22().networks()) { + if ((generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24))) for (var network : generator.undergroundHall22().networks()) { var at = fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.locator(network); source.sendSuccess(() -> Component.translatable("sanctuary.underground_hall.entry", at.getX(), at.getY(), at.getZ()), false); } - if (generator.rootGeneration() == 23 && !generator.mineralSanctuary23().isEmpty()) { + if ((generator.rootGeneration() == 23 || generator.rootGeneration() == 24) && !generator.mineralSanctuary23().isEmpty()) { var at = generator.mineralSanctuary23().chamber().locator(); source.sendSuccess(() -> Component.translatable("sanctuary.mineral_sanctuary.entry", at.getX(), at.getY(), at.getZ()), false); } - return plan.sites().size() + (generator.rootGeneration() == 23 && !generator.mineralSanctuary23().isEmpty() ? 1 : 0) + ((generator.rootGeneration() == 22 || generator.rootGeneration() == 23) + return plan.sites().size() + ((generator.rootGeneration() == 23 || generator.rootGeneration() == 24) && !generator.mineralSanctuary23().isEmpty() ? 1 : 0) + ((generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24)) ? (int) generator.transitPlan21().modules().stream().filter(m -> m.kind() == fr.koka.sanctuary.worldgen.transit.TransitPlan21.Kind.ACCESS).count() + generator.undergroundHall22().networks().size() : 0); })))); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageStructure19.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageStructure19.java index 5a88ace..777a551 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageStructure19.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageStructure19.java @@ -14,7 +14,7 @@ public final class HeritageStructure19 extends Structure { @Override protected Optional findGenerationPoint(GenerationContext context) { if (!(context.chunkGenerator() instanceof SanctuaryChunkGenerator generator) - || (generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && generator.rootGeneration() != 23)) || !generator.experimentalStructures()) return Optional.empty(); + || (generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24))) || !generator.experimentalStructures()) return Optional.empty(); var island = generator.islandForChunk(context.chunkPos().x(), context.chunkPos().z()); if (island == null || !island.origin() || (island.generation() != generator.rootGeneration())) return Optional.empty(); var plan = generator.heritagePlan19(); @@ -23,9 +23,9 @@ public final class HeritageStructure19 extends Structure { // Local route segments render before buildings. No piece needs a distant link endpoint loaded. for (var segment : plan.segments()) if (segment.startChunkX() == x && segment.startChunkZ() == z) - pieces.add((generator.rootGeneration() == 21 || (generator.rootGeneration() == 22 || generator.rootGeneration() == 23)) ? new HeritagePiece21(segment) : generator.rootGeneration() == 20 ? new HeritagePiece20(segment) : new HeritagePiece19(segment)); + pieces.add((generator.rootGeneration() == 21 || (generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24))) ? new HeritagePiece21(segment) : generator.rootGeneration() == 20 ? new HeritagePiece20(segment) : new HeritagePiece19(segment)); for (var site : plan.sites()) if (site.startChunkX() == x && site.startChunkZ() == z) - pieces.add((generator.rootGeneration() == 21 || (generator.rootGeneration() == 22 || generator.rootGeneration() == 23)) ? new HeritagePiece21(site) : generator.rootGeneration() == 20 ? new HeritagePiece20(site) : new HeritagePiece19(site)); + pieces.add((generator.rootGeneration() == 21 || (generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24))) ? new HeritagePiece21(site) : generator.rootGeneration() == 20 ? new HeritagePiece20(site) : new HeritagePiece19(site)); if (pieces.isEmpty()) return Optional.empty(); for (var piece : pieces) { var box = piece.getBoundingBox(); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageStructures19.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageStructures19.java index 46bb8b0..b506d57 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageStructures19.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/heritage/HeritageStructures19.java @@ -57,7 +57,7 @@ public final class HeritageStructures19 { ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk, StructureTemplateManager templates, ResourceKey level) { if (!Level.OVERWORLD.equals(level) || !manager.shouldGenerateStructures() - || !(generator instanceof SanctuaryChunkGenerator sanctuary) || (generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && generator.rootGeneration() != 23)) + || !(generator instanceof SanctuaryChunkGenerator sanctuary) || (generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24))) || !sanctuary.experimentalStructures()) return; var island = generator.islandForChunk(chunk.getPos().x(), chunk.getPos().z()); if (island == null || !island.origin() || island.generation() != generator.rootGeneration()) return; @@ -79,7 +79,7 @@ public final class HeritageStructures19 { BlockPos origin, int radius, boolean skipReferenced) { if (!Level.OVERWORLD.equals(level.dimension()) || !(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator) - || (generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && generator.rootGeneration() != 23)) || !generator.experimentalStructures() + || (generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24))) || !generator.experimentalStructures() || !level.structureManager().shouldGenerateStructures()) return null; Holder holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY); if (!structures.contains(holder)) return null; diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/mineral/MineralSanctuaryStructure23.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/mineral/MineralSanctuaryStructure23.java index 62a7f92..507a594 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/mineral/MineralSanctuaryStructure23.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/mineral/MineralSanctuaryStructure23.java @@ -11,9 +11,9 @@ public final class MineralSanctuaryStructure23 extends Structure { public MineralSanctuaryStructure23(StructureSettings settings) { super(settings); } @Override protected Optional findGenerationPoint(GenerationContext context) { if (!(context.chunkGenerator() instanceof SanctuaryChunkGenerator generator) - || generator.rootGeneration() != 23 || !generator.experimentalStructures()) return Optional.empty(); + || (generator.rootGeneration() != 23 && generator.rootGeneration() != 24) || !generator.experimentalStructures()) return Optional.empty(); var island = generator.islandForChunk(context.chunkPos().x(), context.chunkPos().z()); - if (island == null || !island.origin() || island.generation() != 23) return Optional.empty(); + if (island == null || !island.origin() || (island.generation() != 23 && island.generation() != 24)) return Optional.empty(); var plan = generator.mineralSanctuary23(); if (plan.isEmpty()) return Optional.empty(); var c = plan.chamber(); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/mineral/MineralSanctuaryStructures23.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/mineral/MineralSanctuaryStructures23.java index 1cf9b92..a2b11e1 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/mineral/MineralSanctuaryStructures23.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/mineral/MineralSanctuaryStructures23.java @@ -40,9 +40,9 @@ public final class MineralSanctuaryStructures23 { ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk, StructureTemplateManager templates, ResourceKey level) { if (!Level.OVERWORLD.equals(level) || !manager.shouldGenerateStructures() - || !(generator instanceof SanctuaryChunkGenerator sanctuary) || generator.rootGeneration() != 23 || !sanctuary.experimentalStructures()) return; + || !(generator instanceof SanctuaryChunkGenerator sanctuary) || (generator.rootGeneration() != 23 && generator.rootGeneration() != 24) || !sanctuary.experimentalStructures()) return; var island = generator.islandForChunk(chunk.getPos().x(), chunk.getPos().z()); - if (island == null || !island.origin() || island.generation() != 23) return; + if (island == null || !island.origin() || (island.generation() != 23 && island.generation() != 24)) return; var plan = sanctuary.mineralSanctuary23(); if (plan.isEmpty() || plan.chamber().startChunkX() != chunk.getPos().x() || plan.chamber().startChunkZ() != chunk.getPos().z()) return; @@ -63,14 +63,14 @@ public final class MineralSanctuaryStructures23 { BlockPos origin, int radius, boolean skipReferenced) { if (!Level.OVERWORLD.equals(level.dimension()) || !(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator) - || generator.rootGeneration() != 23 || !level.structureManager().shouldGenerateStructures()) return null; + || (generator.rootGeneration() != 23 && generator.rootGeneration() != 24) || !level.structureManager().shouldGenerateStructures()) return null; Holder holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY); if (!structures.contains(holder)) return null; var plan = generator.mineralSanctuary23(); if (plan.isEmpty() || !generator.experimentalStructures()) return null; var c = plan.chamber(); var island = generator.islandForChunk(c.startChunkX(), c.startChunkZ()); - if (island == null || !island.origin() || island.generation() != 23 || !island.status().equals("ready")) return null; + if (island == null || !island.origin() || (island.generation() != 23 && island.generation() != 24) || !island.status().equals("ready")) return null; if (Math.abs((long) c.startChunkX() - (origin.getX() >> 4)) > radius || Math.abs((long) c.startChunkZ() - (origin.getZ() >> 4)) > radius) return null; var chunk = level.getChunk(c.startChunkX(), c.startChunkZ(), ChunkStatus.STRUCTURE_STARTS, true); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructure17.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructure17.java index 21ad9c0..2c1a03a 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructure17.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructure17.java @@ -13,7 +13,7 @@ public final class RuinsStructure17 extends Structure { @Override protected Optional findGenerationPoint(GenerationContext context) { if (!(context.chunkGenerator() instanceof SanctuaryChunkGenerator generator) - || (generator.rootGeneration() != 17 && generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && generator.rootGeneration() != 23)) || !generator.experimentalStructures()) return Optional.empty(); + || (generator.rootGeneration() != 17 && generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24))) || !generator.experimentalStructures()) return Optional.empty(); var island = generator.islandForChunk(context.chunkPos().x(), context.chunkPos().z()); if (island == null || !island.origin() || island.generation() != generator.rootGeneration()) return Optional.empty(); var networks = generator.ruinsPlan17().networks().stream() @@ -30,9 +30,9 @@ public final class RuinsStructure17 extends Structure { return Optional.of(new GenerationStub(RuinsStructures17.locator(networks.getFirst()), pieces -> { // Shafts come last so their surface hatches cannot be filled by a connected room. networks.forEach(n -> n.rooms().stream().filter(r -> r.kind() != RuinsPlan17.Kind.ACCESS) - .forEach(r -> pieces.addPiece((generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || (generator.rootGeneration() == 22 || generator.rootGeneration() == 23)) ? new RuinsPiece20(r) : new RuinsPiece17(r)))); + .forEach(r -> pieces.addPiece((generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || (generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24))) ? new RuinsPiece20(r) : new RuinsPiece17(r)))); networks.forEach(n -> n.rooms().stream().filter(r -> r.kind() == RuinsPlan17.Kind.ACCESS) - .forEach(r -> pieces.addPiece((generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || (generator.rootGeneration() == 22 || generator.rootGeneration() == 23)) ? new RuinsPiece20(r) : new RuinsPiece17(r)))); + .forEach(r -> pieces.addPiece((generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || (generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24))) ? new RuinsPiece20(r) : new RuinsPiece17(r)))); })); } @Override public StructureType type() { return RuinsStructures17.TYPE; } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructures17.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructures17.java index 1fa13cb..901b5f6 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructures17.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructures17.java @@ -55,7 +55,7 @@ public final class RuinsStructures17 { ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk, StructureTemplateManager templates, ResourceKey level) { if (!Level.OVERWORLD.equals(level) || !manager.shouldGenerateStructures() - || !(generator instanceof SanctuaryChunkGenerator sanctuary) || (generator.rootGeneration() != 17 && generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && generator.rootGeneration() != 23)) + || !(generator instanceof SanctuaryChunkGenerator sanctuary) || (generator.rootGeneration() != 17 && generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24))) || !sanctuary.experimentalStructures()) return; var island = generator.islandForChunk(chunk.getPos().x(), chunk.getPos().z()); if (island == null || !island.origin() || island.generation() != generator.rootGeneration()) return; @@ -78,7 +78,7 @@ public final class RuinsStructures17 { BlockPos origin, int radius, boolean skipReferenced) { if (!Level.OVERWORLD.equals(level.dimension()) || !(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator) - || (generator.rootGeneration() != 17 && generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && generator.rootGeneration() != 23)) || !generator.experimentalStructures() + || (generator.rootGeneration() != 17 && generator.rootGeneration() != 19 && generator.rootGeneration() != 20 && generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24))) || !generator.experimentalStructures() || !level.structureManager().shouldGenerateStructures()) return null; Holder holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY); if (!structures.contains(holder)) return null; diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitStructure21.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitStructure21.java index f6e766f..3b32457 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitStructure21.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitStructure21.java @@ -12,9 +12,9 @@ public final class TransitStructure21 extends Structure { public TransitStructure21(StructureSettings settings) { super(settings); } @Override protected Optional findGenerationPoint(GenerationContext context) { if (!(context.chunkGenerator() instanceof SanctuaryChunkGenerator generator) - || (generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && generator.rootGeneration() != 23)) || (generator.rootGeneration() != 22 && generator.rootGeneration() != 23) && !generator.experimentalStructures()) return Optional.empty(); + || (generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24))) || (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24)) && !generator.experimentalStructures()) return Optional.empty(); var island = generator.islandForChunk(context.chunkPos().x(), context.chunkPos().z()); - if (island == null || !island.origin() || (island.generation() != 21 && (island.generation() != 22 && island.generation() != 23))) return Optional.empty(); + if (island == null || !island.origin() || (island.generation() != 21 && (island.generation() != 22 && (island.generation() != 23 && island.generation() != 24)))) return Optional.empty(); var modules = generator.transitPlan21().modulesForChunk(context.chunkPos().x(), context.chunkPos().z()); if (modules.isEmpty()) return Optional.empty(); for (var module : modules) { @@ -25,7 +25,7 @@ public final class TransitStructure21 extends Structure { throw new IllegalStateException("Transit module exceeds local native reference range"); } return Optional.of(new GenerationStub(TransitPiece21.locator(modules.getFirst()), pieces -> - modules.forEach(module -> pieces.addPiece((generator.rootGeneration() == 22 || generator.rootGeneration() == 23) + modules.forEach(module -> pieces.addPiece((generator.rootGeneration() == 22 || (generator.rootGeneration() == 23 || generator.rootGeneration() == 24)) && module.kind() == TransitPlan21.Kind.JUNCTION && Integer.bitCount(module.openings()) == 2 ? new TransitPiece22(module) : new TransitPiece21(module))))); } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitStructures21.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitStructures21.java index d6fea31..db200e2 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitStructures21.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/transit/TransitStructures21.java @@ -46,10 +46,10 @@ public final class TransitStructures21 { ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk, StructureTemplateManager templates, ResourceKey level) { if (!Level.OVERWORLD.equals(level) || !manager.shouldGenerateStructures() - || !(generator instanceof SanctuaryChunkGenerator sanctuary) || (generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && generator.rootGeneration() != 23)) - || (generator.rootGeneration() != 22 && generator.rootGeneration() != 23) && !sanctuary.experimentalStructures()) return; + || !(generator instanceof SanctuaryChunkGenerator sanctuary) || (generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24))) + || (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24)) && !sanctuary.experimentalStructures()) return; var island = generator.islandForChunk(chunk.getPos().x(), chunk.getPos().z()); - if (island == null || !island.origin() || (island.generation() != 21 && (island.generation() != 22 && island.generation() != 23))) return; + if (island == null || !island.origin() || (island.generation() != 21 && (island.generation() != 22 && (island.generation() != 23 && island.generation() != 24)))) return; if (sanctuary.transitPlan21().modulesForChunk(chunk.getPos().x(), chunk.getPos().z()).isEmpty()) return; Holder holder = registries.lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY); StructureStart previous = manager.getStartForStructure(holder.value(), chunk); @@ -66,7 +66,7 @@ public final class TransitStructures21 { BlockPos origin, int radius, boolean skipReferenced) { if (!Level.OVERWORLD.equals(level.dimension()) || !(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator) - || (generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && generator.rootGeneration() != 23)) || (generator.rootGeneration() != 22 && generator.rootGeneration() != 23) && !generator.experimentalStructures() + || (generator.rootGeneration() != 21 && (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24))) || (generator.rootGeneration() != 22 && (generator.rootGeneration() != 23 && generator.rootGeneration() != 24)) && !generator.experimentalStructures() || !level.structureManager().shouldGenerateStructures()) return null; Holder holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY); if (!structures.contains(holder)) return null; diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json index 6a36dbe..4076f34 100644 --- a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json @@ -225,5 +225,17 @@ "sanctuary.structures.census.unknown_dimension": "Unknown dimension: %s", "sanctuary.structures.census.status.incompatible": "Incompatible habitat", "sanctuary.structures.census.status.other_dimension": "Reserved for another dimension", - "sanctuary.mineral_sanctuary.entry": "Mineral sanctuary: %s %s %s" + "sanctuary.mineral_sanctuary.entry": "Mineral sanctuary: %s %s %s", + "sanctuary.world_options.aerial24_hint": "Secrets, abandoned routes and five aerial sites. Vanilla structures remain independent.", + "sanctuary.structures.census.aerial.none": "No aerial sites reserved in this world.", + "sanctuary.structures.census.aerial.unknown_site": "Unknown aerial site: %s", + "sanctuary.structures.census.aerial.site": "%s · %s · %s · starts %s/%s · features %s/%s · full chunks %s/%s", + "sanctuary.structures.census.aerial.status.planned": "planned", + "sanctuary.structures.census.aerial.status.accepted": "placement accepted", + "sanctuary.structures.census.aerial.status.generated": "generated", + "sanctuary.structures.census.aerial.status.missing": "start missing after verification", + "sanctuary.structures.census.aerial.status.unknown": "unknown", + "structure.sanctuary.aerial_balloon": "Hot air balloon", + "structure.sanctuary.aerial_merchant_ship": "Merchant ship", + "structure.sanctuary.aerial_mineral_islet": "Mineral islet" } diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json index 29d9986..c542c75 100644 --- a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json @@ -225,5 +225,17 @@ "sanctuary.structures.census.unknown_dimension": "Dimension inconnue : %s", "sanctuary.structures.census.status.incompatible": "Milieu incompatible", "sanctuary.structures.census.status.other_dimension": "Réservée à une autre dimension", - "sanctuary.mineral_sanctuary.entry": "Sanctuaire minéral : %s %s %s" + "sanctuary.mineral_sanctuary.entry": "Sanctuaire minéral : %s %s %s", + "sanctuary.world_options.aerial24_hint": "Secrets, voies abandonnées et cinq lieux suspendus. Les structures vanilla restent indépendantes.", + "sanctuary.structures.census.aerial.none": "Aucun lieu suspendu réservé dans ce monde.", + "sanctuary.structures.census.aerial.unknown_site": "Lieu suspendu inconnu : %s", + "sanctuary.structures.census.aerial.site": "%s · %s · %s · départs %s/%s · décoration %s/%s · chunks complets %s/%s", + "sanctuary.structures.census.aerial.status.planned": "planifié", + "sanctuary.structures.census.aerial.status.accepted": "implantation acceptée", + "sanctuary.structures.census.aerial.status.generated": "généré", + "sanctuary.structures.census.aerial.status.missing": "départ absent après vérification", + "sanctuary.structures.census.aerial.status.unknown": "inconnu", + "structure.sanctuary.aerial_balloon": "Montgolfière", + "structure.sanctuary.aerial_merchant_ship": "Bateau marchand", + "structure.sanctuary.aerial_mineral_islet": "Îlot minéral" } diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/loot_table/chests/aerial24/balloon.json b/mods/sanctuary/src/main/resources/data/sanctuary/loot_table/chests/aerial24/balloon.json new file mode 100644 index 0000000..e58fdf8 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/loot_table/chests/aerial24/balloon.json @@ -0,0 +1,127 @@ +{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:spyglass" + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:compass" + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:clock" + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:gold_ingot", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 8, + "max": 16 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:diamond", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 2, + "max": 4 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:lead", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 4, + "max": 8 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:bread", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 12, + "max": 24 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:emerald", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 8, + "max": 16 + } + } + ] + } + ] + } + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/loot_table/chests/aerial24/merchant_provisions.json b/mods/sanctuary/src/main/resources/data/sanctuary/loot_table/chests/aerial24/merchant_provisions.json new file mode 100644 index 0000000..f9e0e2d --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/loot_table/chests/aerial24/merchant_provisions.json @@ -0,0 +1,176 @@ +{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:bread", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 24, + "max": 40 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:carrot", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 16, + "max": 32 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:potato", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 16, + "max": 32 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:wheat_seeds", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 16, + "max": 32 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:pumpkin_seeds", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 8, + "max": 16 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:melon_seeds", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 8, + "max": 16 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:cooked_beef", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 8, + "max": 16 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:golden_apple", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 1, + "max": 2 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:emerald", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 8, + "max": 16 + } + } + ] + } + ] + } + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/loot_table/chests/aerial24/merchant_tools.json b/mods/sanctuary/src/main/resources/data/sanctuary/loot_table/chests/aerial24/merchant_tools.json new file mode 100644 index 0000000..e66b3ab --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/loot_table/chests/aerial24/merchant_tools.json @@ -0,0 +1,146 @@ +{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:iron_pickaxe" + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:iron_axe" + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:iron_shovel" + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:iron_ingot", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 24, + "max": 40 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:copper_ingot", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 24, + "max": 40 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:coal", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 24, + "max": 40 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:redstone", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 16, + "max": 32 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:diamond", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 2, + "max": 5 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:emerald", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 8, + "max": 16 + } + } + ] + } + ] + } + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/loot_table/chests/aerial24/mineral_islet.json b/mods/sanctuary/src/main/resources/data/sanctuary/loot_table/chests/aerial24/mineral_islet.json new file mode 100644 index 0000000..c859414 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/loot_table/chests/aerial24/mineral_islet.json @@ -0,0 +1,157 @@ +{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:diamond", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 3, + "max": 7 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:emerald", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 8, + "max": 20 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:gold_ingot", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 12, + "max": 24 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:iron_ingot", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 24, + "max": 48 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:lapis_lazuli", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 24, + "max": 48 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:redstone", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 24, + "max": 48 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:amethyst_shard", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 12, + "max": 24 + } + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:golden_apple", + "functions": [ + { + "function": "minecraft:set_count", + "count": { + "type": "minecraft:uniform", + "min": 1, + "max": 3 + } + } + ] + } + ] + } + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/aerial_balloon.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/aerial_balloon.json new file mode 100644 index 0000000..413f497 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/aerial_balloon.json @@ -0,0 +1,10 @@ +{ + "type": "sanctuary:aerial_v24", + "family": "balloon", + "biomes": [ + "minecraft:the_void" + ], + "step": "top_layer_modification", + "terrain_adaptation": "none", + "spawn_overrides": {} +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/aerial_merchant_ship.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/aerial_merchant_ship.json new file mode 100644 index 0000000..2b5d07d --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/aerial_merchant_ship.json @@ -0,0 +1,10 @@ +{ + "type": "sanctuary:aerial_v24", + "family": "merchant_ship", + "biomes": [ + "minecraft:the_void" + ], + "step": "top_layer_modification", + "terrain_adaptation": "none", + "spawn_overrides": {} +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/aerial_mineral_islet.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/aerial_mineral_islet.json new file mode 100644 index 0000000..e254443 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/aerial_mineral_islet.json @@ -0,0 +1,10 @@ +{ + "type": "sanctuary:aerial_v24", + "family": "mineral_islet", + "biomes": [ + "minecraft:the_void" + ], + "step": "top_layer_modification", + "terrain_adaptation": "none", + "spawn_overrides": {} +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json index 0da4202..5bbc350 100644 --- a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json @@ -3,7 +3,7 @@ "minecraft:overworld": { "type": "sanctuary:sanctuary_384", "generator": { - "type": "sanctuary:island_v23", + "type": "sanctuary:island_v24", "biome_source": { "biomes": [ "minecraft:the_void", @@ -64,7 +64,7 @@ "minecraft:snowy_beach", "minecraft:stony_shore" ], - "type": "sanctuary:island_v23" + "type": "sanctuary:island_v24" }, "initial_diameter": 724 } diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_v23.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_v23.json new file mode 100644 index 0000000..0da4202 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_v23.json @@ -0,0 +1,94 @@ +{ + "dimensions": { + "minecraft:overworld": { + "type": "sanctuary:sanctuary_384", + "generator": { + "type": "sanctuary:island_v23", + "biome_source": { + "biomes": [ + "minecraft:the_void", + "minecraft:forest", + "minecraft:birch_forest", + "minecraft:flower_forest", + "minecraft:plains", + "minecraft:meadow", + "minecraft:taiga", + "minecraft:snowy_taiga", + "minecraft:snowy_plains", + "minecraft:grove", + "minecraft:ice_spikes", + "minecraft:frozen_peaks", + "minecraft:old_growth_spruce_taiga", + "minecraft:dark_forest", + "minecraft:swamp", + "minecraft:pale_garden", + "minecraft:jungle", + "minecraft:sparse_jungle", + "minecraft:bamboo_jungle", + "minecraft:mangrove_swamp", + "minecraft:savanna", + "minecraft:savanna_plateau", + "minecraft:desert", + "minecraft:badlands", + "minecraft:eroded_badlands", + "minecraft:wooded_badlands", + "minecraft:windswept_hills", + "minecraft:windswept_gravelly_hills", + "minecraft:stony_peaks", + "minecraft:dappled_forest", + "minecraft:cherry_grove", + "minecraft:lush_caves", + "minecraft:dripstone_caves", + "minecraft:sulfur_caves", + "sanctuary:population_oak_forest", + "sanctuary:population_birch_forest", + "sanctuary:population_clearing", + "sanctuary:population_dry_woodland", + "sanctuary:population_rocky_heath", + "sanctuary:population_dark_grove", + "sanctuary:population_bamboo_grove", + "sanctuary:population_sulfur_depths", + "sanctuary:population_lush_caves", + "sanctuary:population_dripstone_caves", + "sanctuary:lost_city", + "minecraft:ocean", + "minecraft:deep_ocean", + "minecraft:cold_ocean", + "minecraft:deep_cold_ocean", + "minecraft:frozen_ocean", + "minecraft:deep_frozen_ocean", + "minecraft:warm_ocean", + "minecraft:lukewarm_ocean", + "minecraft:deep_lukewarm_ocean", + "minecraft:beach", + "minecraft:snowy_beach", + "minecraft:stony_shore" + ], + "type": "sanctuary:island_v23" + }, + "initial_diameter": 724 + } + }, + "minecraft:the_end": { + "type": "minecraft:the_end", + "generator": { + "type": "minecraft:noise", + "biome_source": { + "type": "minecraft:the_end" + }, + "settings": "minecraft:end" + } + }, + "minecraft:the_nether": { + "type": "minecraft:the_nether", + "generator": { + "type": "minecraft:noise", + "biome_source": { + "type": "minecraft:multi_noise", + "preset": "minecraft:nether" + }, + "settings": "minecraft:nether" + } + } + } +} diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/aerial/Aerial24Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/aerial/Aerial24Smoke.java new file mode 100644 index 0000000..8ff6979 --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/aerial/Aerial24Smoke.java @@ -0,0 +1,170 @@ +package fr.koka.sanctuary.worldgen.aerial; + +import fr.koka.sanctuary.expansion.ExpansionIsland; +import java.util.HashSet; +import java.util.List; +import java.util.HashMap; +import java.util.Map; +import fr.koka.sanctuary.worldgen.city.LostCityRenderer; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.server.Bootstrap; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.NoiseColumn; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.storage.loot.LootTable; + +/** Pure finite planning witnesses: nine size/seed combinations, rotation and pre-visit claims. */ +public final class Aerial24Smoke { + public static void main(String[] args) { + SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); + BlockState[] column = new BlockState[384]; + for (int y = 0; y < column.length; y++) column[y] = (y >= 80 && y <= 240 ? Blocks.STONE : Blocks.AIR).defaultBlockState(); + for (int size : new int[]{512, 724, 1024}) for (long seed : new long[]{0, 42, 2026}) { + int[] queries = {0}; + var plan = AerialPlanner24.plan(seed, size, (x, z) -> { queries[0]++; return new NoiseColumn(0, column); }); + var replay = AerialPlanner24.plan(seed, size, (x, z) -> new NoiseColumn(0, column)); + require(plan.sites().equals(replay.sites()) && plan.claimsChunks().equals(replay.claimsChunks()), "Plan does not depend on time or loaded chunks"); + require(queries[0] <= AerialPlanner24.MAX_COLUMNS, "Finite terrain query budget"); + require(plan.sites().size() == 5 && plan.sites().stream().filter(AerialPlan24.Site::merchant).count() == 2, + "Five complete sites, including two distinct merchant ships"); + require(plan.sites().stream().filter(AerialPlan24.Site::waterfall).count() == 1, "Exactly one waterfall site"); + var allClaims = new HashSet(); + var root = ExpansionIsland.origin(size, seed, 24); + for (var site : plan.sites()) { + var physicalChunks = new HashSet<>(site.bounds().intersectingChunks().toList()); + if (site.waterfall()) physicalChunks.addAll(site.waterfallClaimBounds().intersectingChunks().toList()); + require(physicalChunks.equals(new HashSet<>(site.claimedChunks())), + "Claim contains only body chunks and the local waterfall outlet, without a generic margin"); + require(site.maxY() < 350 && site.minY() >= 32, "The entire site fits the dimension height"); + if (site.kind() == AerialPlan24.Kind.MINERAL_ISLET) + require(site.rockHeight() >= 20 && site.rockHeight() <= 35 + && site.height() == site.rockHeight() + AerialPlan24.VEGETATION_HEADROOM, + "Vegetation adds eight blocks of headroom without enlarging the stone body"); + double distance = Math.hypot(site.locator().getX() - site.shoreX(), site.locator().getZ() - site.shoreZ()); + require(distance <= 512, "Every site has a nearby shore witness"); + require(plan.atStartChunk(site.startChunkX(), site.startChunkZ()).contains(site), "Native departure belongs to its saved site"); + for (var chunk : site.claimedChunks()) { + require(allClaims.add(chunk.pack()), "Site claims do not overlap"); + require(site.ownsChunk(chunk.x(), chunk.z()) && plan.ownsChunk(chunk.x(), chunk.z()), "Claim query agrees with the immutable mask"); + require(!root.ownsChunk(chunk.x(), chunk.z()), "Claim does not replace initial island ownership"); + } + var overlapping = new ExpansionIsland("overlap", "sanctuary", "east", "temperate", 64, + site.startChunkX() * 16, site.startChunkZ() * 16, 42, "natural", "reserved", 24); + require(plan.conflicts(overlapping), "Expansion conflicts are known before visiting or generating a site"); + var local = new HashSet(); + for (int face = 0; face < 4; face++) { + var rotated = rotate(site, face); local.clear(); + for (int x = 0; x < site.width(); x++) for (int z = 0; z < site.depth(); z++) { + var at = new BlockPos(rotated.worldX(x, z), site.minY(), rotated.worldZ(x, z)); + require(rotated.bounds().isInside(at) && rotated.mayWrite(at), "Rotations stay inside their world bounds"); + require(local.add(at), "Rotation never folds two local columns together"); + } + } + require(!site.mayWrite(new BlockPos(site.maxX() + 2, site.minY(), site.maxZ() + 2)), "No writes outside the finite envelope"); + require(AerialPiece24.decode(AerialPiece24.encode(site)).equals(site), "Saved site descriptor round-trips without changing its seed or rotation"); + if (seed == 0) verifyRender(site); + } + require(allClaims.equals(plan.claimsChunks()), "Combined claims contain only the five finite site masks"); + var far = new ExpansionIsland("far", "sanctuary", "east", "temperate", 64, 4096, 4096, 1, "natural", "reserved", 24); + require(!plan.conflicts(far), "Aerial sites do not reserve an infinite ring around Sanctuary"); + require(!plan.conflicts(root), "Root ownership is not treated as a future expansion"); + try { plan.sites().clear(); throw new AssertionError("Mutable site list"); } + catch (UnsupportedOperationException expected) { } + try { plan.claimsChunks().clear(); throw new AssertionError("Mutable claims"); } + catch (UnsupportedOperationException expected) { } + } + require(AerialPlan24.EMPTY.isEmpty() && AerialPlan24.EMPTY.claimsChunks().isEmpty(), "Disabled sites reserve no space"); + try { new AerialPlan24(List.of(new AerialPlan24.Site("balloon", AerialPlan24.Kind.BALLOON, + 500, 290, 0, 15, 28, 19, 0, 0, 250, 240, 0, false))); throw new AssertionError("Partial plan accepted"); } + catch (IllegalArgumentException expected) { } + verifyReservedShoreAndView(column); + System.out.println("Aerial24Smoke: nine deterministic plans, five sites, four rotations, finite non-overlapping claims and pre-visit conflicts passed."); + } + private static void verifyReservedShoreAndView(BlockState[] column) { + var original = AerialPlanner24.plan(0, 724, (x, z) -> new NoiseColumn(0, column)).sites().getFirst(); + for (boolean obstructView : new boolean[]{false, true}) { + int cx = original.minX() + original.worldWidth() / 2; + int cz = original.minZ() + original.worldDepth() / 2; + int blockedX = obstructView ? (original.shoreX() + cx) / 2 : original.shoreX(); + int blockedZ = obstructView ? (original.shoreZ() + cz) / 2 : original.shoreZ(); + int[] refused = {0}, columns = {0}; + var terrain = new AerialPlanner24.Terrain() { + public NoiseColumn column(int x, int z) { columns[0]++; return new NoiseColumn(0, column); } + public boolean reserved(int x, int z, int minY, int maxY) { + boolean blocked = Math.abs(x - blockedX) <= 8 && Math.abs(z - blockedZ) <= 8; + if (blocked) refused[0]++; + return blocked; + } + }; + var replacement = AerialPlanner24.plan(0, 724, terrain); + require(refused[0] > 0 && !replacement.sites().getFirst().equals(original), + obstructView ? "A secret across the view segment rejects the original candidate" : "A secret over the shore rejects the original witness"); + require(columns[0] <= AerialPlanner24.MAX_COLUMNS, "Reservations preserve the bounded terrain sampling budget"); + for (var site : replacement.sites()) + require(!terrain.reserved(site.shoreX(), site.shoreZ(), site.shoreY() - 1, site.shoreY() + 2), + "Every accepted shore stays outside planned root structures"); + } + } + private static AerialPlan24.Site rotate(AerialPlan24.Site site, int facing) { + return new AerialPlan24.Site(site.id(), site.kind(), site.minX(), site.minY(), site.minZ(), site.width(), site.height(), site.depth(), + facing, site.seed(), site.shoreX(), site.shoreY(), site.shoreZ(), site.waterfall()); + } + private static void verifyRender(AerialPlan24.Site site) { + Map complete = new HashMap<>(), clipped = new HashMap<>(); + int[] chests = {0}; + var writer = writer(site, complete, chests); + AerialRenderer24.render(site, writer); + require(chests[0] == 1 && complete.get(AerialGeometry24.chest(site)).is(Blocks.CHEST), "Each site has exactly one real chest"); + int[] clippedChests = {0}; + for (var chunk : site.bounds().intersectingChunks().toList()) + AerialRenderer24.render(site, writer(site, clipped, clippedChests), + new BoundingBox(chunk.getMinBlockX(), 0, chunk.getMinBlockZ(), chunk.getMaxBlockX(), 383, chunk.getMaxBlockZ())); + require(complete.equals(clipped) && clippedChests[0] == 1, "Chunk clipping reconstructs the complete authored structure without duplicate chests"); + long sources = complete.values().stream().filter(state -> state.is(Blocks.WATER)).count(); + require(sources == (site.waterfall() ? 1 : 0), "Renderer writes one source, never a fabricated full waterfall column"); + if (site.kind() == AerialPlan24.Kind.MINERAL_ISLET) { + var chest = AerialGeometry24.chest(site); + for (int dy = 1; dy <= 3; dy++) require(complete.get(chest.above(dy)) != null && complete.get(chest.above(dy)).isSolid(), "Mineral chest is buried under three intact layers"); + require(complete.values().stream().anyMatch(state -> state.is(Blocks.COAL_ORE)), "Real mineable coal is available on the mineral islet"); + require(complete.values().stream().anyMatch(state -> state.is(Blocks.IRON_ORE) || state.is(Blocks.DEEPSLATE_IRON_ORE)), "Real mineable iron is available"); + 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 (complete.getOrDefault(AerialGeometry24.world(site, x, top, z), Blocks.AIR.defaultBlockState()).is(Blocks.GRASS_BLOCK)) grass++; + } + require(grass * 2 > surface, "Grass covers most intact surface columns while fissures retain exposed rock"); + var trees = AerialGeometry24.trees(site); + require(!trees.isEmpty(), "Each mineral islet supports a small woodland"); + for (var tree : trees) { + var ground = AerialGeometry24.world(site, tree.x(), tree.groundY(), tree.z()); + require(complete.get(ground).is(Blocks.GRASS_BLOCK), "Tree trunk is rooted in the real soil surface"); + for (int y = 1; y <= tree.trunkHeight(); y++) + require(complete.get(ground.above(y)).is(tree.birch() ? Blocks.BIRCH_LOG : Blocks.OAK_LOG), "Tree trunk remains continuous"); + require(complete.get(ground.above(tree.trunkHeight() + 1)).is(tree.birch() ? Blocks.BIRCH_LEAVES : Blocks.OAK_LEAVES), + "Tree crown fits entirely within its saved envelope"); + } + for (var state : complete.values()) if (state.is(Blocks.OAK_LEAVES) || state.is(Blocks.BIRCH_LEAVES)) + require(!state.getValue(BlockStateProperties.PERSISTENT) && state.getValue(BlockStateProperties.DISTANCE) < 7, + "Natural leaves have a log connection and may decay after players cut the tree"); + } + } + private static LostCityRenderer.BlockWriter writer(AerialPlan24.Site site, Map blocks, int[] chests) { + return new LostCityRenderer.BlockWriter() { + public void set(int x, int y, int z, BlockState state) { + var pos = new BlockPos(site.minX() + x, site.minY() + y, site.minZ() + z); + require(site.bounds().isInside(pos), "Authored placement stays inside its saved native bounds"); blocks.put(pos, state); + } + public void chest(int x, int y, int z, ResourceKey loot, long seed) { + chests[0]++; require(loot.equals(AerialRenderer24.loot(site)), "Each chest uses its site's distinct loot family"); + set(x, y, z, Blocks.CHEST.defaultBlockState()); + } + }; + } + private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); } +} diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/aerial/AerialPersistence24Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/aerial/AerialPersistence24Smoke.java new file mode 100644 index 0000000..95ffe43 --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/aerial/AerialPersistence24Smoke.java @@ -0,0 +1,157 @@ +package fr.koka.sanctuary.worldgen.aerial; + +import com.google.gson.JsonParser; +import com.mojang.serialization.JsonOps; +import fr.koka.sanctuary.expansion.ExpansionIsland; +import fr.koka.sanctuary.expansion.ExpansionJournal; +import fr.koka.sanctuary.expansion.ExpansionPlacement; +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.util.Arrays; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import fr.koka.sanctuary.worldgen.aerial.AerialPlan24.Kind; +import fr.koka.sanctuary.worldgen.aerial.AerialPlan24.Site; + +/** Real filesystem transaction checks; no Minecraft save, chunk or personal instance is opened. */ +public final class AerialPersistence24Smoke { + private static final long SEED = Long.MIN_VALUE + 824; + private AerialPersistence24Smoke() {} + public static void main(String[] args) throws Exception { + Path scratch = Files.createTempDirectory("sanctuary-aerial-persistence24-"); + try { + Path legacyDirectory = scratch.resolve("legacy23"); + var legacy = new ExpansionJournal(legacyDirectory, SEED, 724, 23); + Path legacyFile = legacyDirectory.resolve(ExpansionJournal.FILE_NAME); + byte[] legacyBytes = Files.readAllBytes(legacyFile); + + Path directory = scratch.resolve("sanctuary-world-v24"); + Path storeDirectory = AerialStore24.directory(directory); + reject(() -> new ExpansionJournal(directory, SEED, 724, 24), "New journal without an immutable plan"); + require(!Files.exists(directory), "Rejected unbound journal must not leave an initialized directory"); + var draws = new AtomicInteger(); + var stored = AerialStore24.create(storeDirectory, SEED, 724, () -> { draws.incrementAndGet(); return fixture(); }); + for (var site : stored.plan().sites()) { + if (site.kind() == Kind.MINERAL_ISLET) { + require(site.rockHeight() >= 20 && site.rockHeight() <= 35, "Vegetation does not reduce the rock thickness"); + require(site.height() == site.rockHeight() + 8 && site.maxY() < 350, "The persisted envelope includes eight blocks of vegetation below the ceiling"); + } + var expectedClaims = new java.util.HashSet<>(site.bounds().intersectingChunks().toList()); + if (site.waterfall()) expectedClaims.addAll(site.waterfallClaimBounds().intersectingChunks().toList()); + require(expectedClaims.equals(java.util.Set.copyOf(site.claimedChunks())), "Claims contain only body chunks and the local waterfall outlet"); + for (var chunk : expectedClaims) require(site.ownsChunk(chunk.x(), chunk.z()), "Ownership agrees with the exact finite mask"); + require(!site.ownsChunk((site.maxX() >> 4) + 2, (site.maxZ() >> 4) + 2), "Unused corners of the old broad margin stay free"); + } + require(!Files.exists(directory), "Manifest creation must not prematurely initialize the journal"); + require(draws.get() == 1 && stored.plan().sites().size() == 5, "One complete initial plan is recorded"); + var journal = new ExpansionJournal(directory, SEED, 724, 24, stored.token()); + var root = ExpansionIsland.origin(724, SEED, 24); + Path journalFile = directory.resolve(ExpansionJournal.FILE_NAME); + byte[] pristineJournal = Files.readAllBytes(journalFile); + require(JsonParser.parseString(Files.readString(journalFile)).getAsJsonObject().get("schema").getAsInt() == 2, + "Only the new generation24 journal uses its new schema"); + var reloaded = new ExpansionJournal(directory, SEED, 724, 24); + require(reloaded.aerialToken().equals(stored.token()) && reloaded.aerialPlan24().sites().equals(stored.plan().sites()), + "Reload obtains the exact stored sites and token without calling a planner"); + require(draws.get() == 1, "Reload must not draw another layout"); + require(reloaded.aerialPlan24().sites().stream().filter(s -> s.kind() == Kind.MINERAL_ISLET) + .map(Site::rockHeight).toList().equals(List.of(28, 32)), + "Reload preserves the original rock heights independently from vegetation headroom"); + journal.commit(List.of(root)); + require(Arrays.equals(pristineJournal, Files.readAllBytes(journalFile)), "A no-op commit preserves bytes"); + + var collision = child("collision", 672, 0); + require(!collision.conflicts(root), "Collision witness is outside initial island reservations"); + require(stored.plan().conflicts(collision), "A never-generated satellite reserves its chunks"); + reject(() -> journal.commit(List.of(root, collision)), "Reservation over an unvisited aerial site"); + require(journal.healthy() && Arrays.equals(pristineJournal, Files.readAllBytes(journalFile)), + "Ordinary rejected geometry preserves the healthy journal"); + var free = ExpansionPlacement.candidates(SEED, List.of(root), "free_space", "sanctuary", "south", "temperate", 64, "natural", 0) + .stream().filter(value -> !stored.plan().conflicts(value)).findFirst().orElseThrow(); + journal.commit(List.of(root, free)); + require(new ExpansionJournal(directory, SEED, 724, 24).islands().equals(List.of(root, free)), + "Claims do not immobilize the complete surrounding ring"); + byte[] withFree = Files.readAllBytes(journalFile); + var poisoned = JsonParser.parseString(Files.readString(journalFile)).getAsJsonObject(); + poisoned.getAsJsonArray("islands").add(ExpansionIsland.CODEC.encodeStart(JsonOps.INSTANCE, collision).getOrThrow()); + Files.writeString(journalFile, poisoned.toString()); + byte[] poisonedBytes = Files.readAllBytes(journalFile); + reject(() -> new ExpansionJournal(directory, SEED, 724, 24), "Reload with an externally introduced claim overlap"); + require(Arrays.equals(poisonedBytes, Files.readAllBytes(journalFile)), "Rejected reload does not repair or rewrite history"); + Files.write(journalFile, withFree); + + Path manifestFile = storeDirectory.resolve(stored.token() + ".json"); + byte[] manifestBytes = Files.readAllBytes(manifestFile); + Files.writeString(manifestFile, "{}"); + reject(() -> AerialStore24.load(storeDirectory, stored.token(), SEED, 724), "Changed manifest bytes"); + reject(() -> new ExpansionJournal(directory, SEED, 724, 24), "Changed manifest linked by an existing journal"); + require(Arrays.equals(withFree, Files.readAllBytes(journalFile)), "Manifest corruption leaves the journal untouched"); + Files.write(manifestFile, manifestBytes); + Files.delete(manifestFile); + reject(() -> new ExpansionJournal(directory, SEED, 724, 24), "Missing manifest cannot be regenerated on reload"); + reject(() -> journal.commit(List.of(root, free.ready())), "Missing manifest stops later commits too"); + require(!journal.healthy(), "Uncertain manifest authority stops the current session"); + Files.write(manifestFile, manifestBytes); + require(new ExpansionJournal(directory, SEED, 724, 24).islands().equals(List.of(root, free)), "Explicit reload restores a repaired authority"); + reject(() -> AerialStore24.load(storeDirectory, stored.token(), SEED + 1, 724), "Other world seed"); + reject(() -> AerialStore24.load(storeDirectory, stored.token(), SEED, 1024), "Other initial size"); + reject(() -> AerialStore24.load(storeDirectory, "../manifest", SEED, 724), "Non-digest token"); + String duplicate = new String(manifestBytes, StandardCharsets.UTF_8).replaceFirst("\\{", "{\"schema\":1,"); + String duplicateToken = digest(duplicate.getBytes(StandardCharsets.UTF_8)); + Files.writeString(storeDirectory.resolve(duplicateToken + ".json"), duplicate); + reject(() -> AerialStore24.load(storeDirectory, duplicateToken, SEED, 724), "A correctly hashed manifest still rejects duplicate fields"); + + for (String option : List.of("global-off", "experimental-off")) { + Path offDirectory = scratch.resolve(option).resolve("sanctuary-world-v24"); + var empty = AerialStore24.create(AerialStore24.directory(offDirectory), SEED, 724, () -> AerialPlan24.EMPTY); + var off = new ExpansionJournal(offDirectory, SEED, 724, 24, empty.token()); + require(off.aerialPlan24().isEmpty() && off.aerialPlan24().claimsChunks().isEmpty(), "Disabled plan reserves no aerial sites"); + require(new ExpansionJournal(offDirectory, SEED, 724, 24).aerialPlan24().isEmpty(), "Disabled plan stays disabled after reload"); + off.commit(List.of(root, collision)); + } + var alternative = AerialStore24.create(storeDirectory, SEED, 724, () -> AerialPlan24.EMPTY); + var swapped = JsonParser.parseString(Files.readString(journalFile)).getAsJsonObject(); + swapped.addProperty("aerial_plan", alternative.token()); + var healthy = new ExpansionJournal(directory, SEED, 724, 24); + Files.writeString(journalFile, swapped.toString()); + reject(() -> healthy.commit(List.of(root, free.ready())), "Another valid manifest cannot replace the current session's immutable layout"); + Files.write(journalFile, withFree); + + reject(() -> new ExpansionJournal(legacyDirectory, SEED, 724, 24), "No implicit migration from23"); + reject(() -> new ExpansionJournal(directory, SEED, 724, 23), "No implicit downgrade to23"); + legacy.commit(legacy.islands()); + require(Arrays.equals(legacyBytes, Files.readAllBytes(legacyFile)), "Historical23 bytes remain identical"); + require(!Files.readString(legacyFile).contains("aerial_plan") && !legacy.aerialPlan24().conflicts(collision), "No aerial authority added to23"); + System.out.println("Aerial persistence24: manifest-first bootstrap, strict reload, claim conflicts, disabled plans and legacy23 passed"); + } finally { + try (var paths = Files.walk(scratch)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.deleteIfExists(path); + } + } + } + + private static ExpansionIsland child(String id, int x, int z) { + return new ExpansionIsland(id, "sanctuary", "east", "temperate", 64, x, z, + ExpansionIsland.deriveSeed(SEED, id), "natural", "reserved", 24); + } + private static AerialPlan24 fixture() { + return new AerialPlan24(List.of( + new Site("balloon", Kind.BALLOON, 660, 290, 0, 15, 28, 19, 0, 1, 390, 240, 0, false), + new Site("merchant_provisions", Kind.MERCHANT_PROVISIONS, 0, 235, 660, 11, 18, 27, 0, 2, 0, 240, 390, false), + new Site("merchant_tools", Kind.MERCHANT_TOOLS, -740, 242, 0, 11, 18, 27, 0, 3, -390, 240, 0, false), + new Site("mineral_islet_water", Kind.MINERAL_ISLET, -450, 255, -650, 48, 36, 48, 0, 4, -200, 240, -330, true), + new Site("mineral_islet_dry", Kind.MINERAL_ISLET, 460, 260, -600, 56, 40, 56, 0, 5, 250, 240, -300, false))); + } + private static String digest(byte[] bytes) throws Exception { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } + private static void require(boolean value, String message) { if (!value) throw new AssertionError(message); } + private static void reject(IOAction action, String message) throws Exception { + try { action.run(); } catch (IOException expected) { return; } + throw new AssertionError("Expected rejection: " + message); + } + @FunctionalInterface private interface IOAction { void run() throws Exception; } +} diff --git a/packwiz/pack.toml b/packwiz/pack.toml index 2c14952..bfa39dc 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "0.1.0-alpha.23.1" +version = "0.1.0-alpha.24" pack-format = "packwiz:1.1.0" [index]