From 0969ed52dae6789d62afb6b8bb0ed1f578c23b33 Mon Sep 17 00:00:00 2001 From: koka Date: Wed, 9 Sep 2026 19:14:09 +0200 Subject: [PATCH] Add alpha.17 experimental forgotten structure networks --- CHANGELOG.md | 19 + README.md | 27 +- docs/backlog.md | 29 ++ docs/testing-alpha17.md | 95 +++++ docs/vision.md | 5 + gradle.properties | 4 +- mods/sanctuary/build.gradle | 19 +- .../gametest/Ruins17WorldGameTests.java | 351 ++++++++++++++++++ .../UnifiedWorldSettingsDiagnostics.java | 22 +- .../mixin/SanctuaryGameTestServerMixin.java | 11 +- .../java/fr/koka/sanctuary/SanctuaryMod.java | 3 + .../client/SanctuaryWorldOptionsScreen.java | 13 +- .../expansion/ExpansionChunkGenerator.java | 1 + .../sanctuary/expansion/ExpansionIsland.java | 4 +- .../expansion/ExpansionStructures.java | 3 + .../UnifiedDecorationProtectionMixin.java | 5 +- .../worldgen/SanctuaryBiomeSource.java | 6 +- .../worldgen/SanctuaryChunkGenerator.java | 61 ++- .../worldgen/city/LostCityStructure.java | 8 +- .../worldgen/ruins/RuinsPiece17.java | 114 ++++++ .../sanctuary/worldgen/ruins/RuinsPlan17.java | 123 ++++++ .../worldgen/ruins/RuinsPlanner17.java | 217 +++++++++++ .../worldgen/ruins/RuinsRenderer17.java | 277 ++++++++++++++ .../worldgen/ruins/RuinsStructure17.java | 39 ++ .../worldgen/ruins/RuinsStructures17.java | 111 ++++++ .../worldgen/ruins/RuinsTerrain17.java | 60 +++ .../assets/sanctuary/lang/en_us.json | 5 +- .../assets/sanctuary/lang/fr_fr.json | 5 +- .../has_structure/forgotten_networks.json | 55 +++ .../structure/forgotten_networks.json | 7 + .../worldgen/world_preset/sanctuary.json | 2 +- .../worldgen/world_preset/sanctuary_v16.json | 81 ++++ .../expansion/ExpansionJournalSmoke.java | 15 + .../worldgen/ruins/RuinsPiece17Smoke.java | 55 +++ .../worldgen/ruins/RuinsPlanner17Smoke.java | 174 +++++++++ .../worldgen/ruins/RuinsRenderer17Smoke.java | 219 +++++++++++ packwiz/pack.toml | 2 +- 37 files changed, 2203 insertions(+), 44 deletions(-) create mode 100644 docs/testing-alpha17.md create mode 100644 mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Ruins17WorldGameTests.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsPiece17.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsPlan17.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsPlanner17.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsRenderer17.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructure17.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructures17.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsTerrain17.java create mode 100644 mods/sanctuary/src/main/resources/data/sanctuary/tags/worldgen/biome/has_structure/forgotten_networks.json create mode 100644 mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/forgotten_networks.json create mode 100644 mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_v16.json create mode 100644 mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/ruins/RuinsPiece17Smoke.java create mode 100644 mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/ruins/RuinsPlanner17Smoke.java create mode 100644 mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/ruins/RuinsRenderer17Smoke.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f29611e..667aea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 0.1.0-alpha.17 — 2026-09-09 + +- Prototype de trois réseaux oubliés sur l’île initiale : mines et ateliers, + salles étranges, infrastructures abandonnées. Accès depuis la surface, + couloirs et embranchements, outils vanilla utilisables, mobilier et traces de + chantier. Placement borné, hors ville et eaux protégées, avec appuis vérifiés. +- Option FR/EN « Structures expérimentales » dans Personnaliser, enregistrée + avec la taille du nouveau monde. Les connexions entre réseaux, le réacteur, + la progression et les reliques ne sont pas encore implémentés. +- Codec `sanctuary:island_v17`, pièces natives indépendantes, journal 17. + Les mondes 16 et antérieurs conservent leurs plans et futurs chunks. + L’option ne supprime ni n’ajoute rétroactivement des structures. + +`check build assemblePack` passe. Création et réouverture natives : 15 pièces, +7 639 blocs contrôlés, 49 cibles accessibles, 12 appuis et 3 coffres conservés. +Le cas désactivé et la sauvegarde des trois tailles passent. La génération 16 +conserve les empreintes natives et le journal des témoins publiés. Artefacts +vérifiés, publication en cours ; voir [le protocole](docs/testing-alpha17.md). + ## 0.1.0-alpha.16 — 2026-09-09 - Ville compacte obligatoire sur la nouvelle île initiale, structures activées. diff --git a/README.md b/README.md index 073e6c9..e6c1e6a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,29 @@ décrits dans la vision ne sont pas encore implémentés. **L’alpha.16 est pub piscines, des parkings profonds et des égouts ouverts. Le canal packwiz et la même instance Prism sont à jour ; voir [Distribution](docs/packwiz.md). +## Alpha.17 — prototype des réseaux oubliés + +La nouvelle génération expérimentale recherche trois petits réseaux sur l’île +initiale : mines et ateliers, salles étranges, installations techniques +abandonnées. Chaque réseau possède une entrée de surface et des salles reliées, +avec mobilier, outils vanilla et traces de travaux interrompus. Les continents +conservent leur génération urbaine sans recevoir ces réseaux supplémentaires. + +Dans **Personnaliser**, l’option **Structures expérimentales** accompagne les +trois tailles habituelles. Elle est enregistrée avec chaque nouveau monde ; +elle permet de comparer un monde avec les nouveaux lieux et un autre avec le +socle urbain seul. La présence des réseaux dépend des emplacements admissibles +trouvés dans un budget borné. Les connexions entre familles restent à construire. + +Le prototype demande un **nouveau monde**. Les mondes 16 et antérieurs gardent +leur génération. Revenir au binaire 16 ne permet pas d’ouvrir un monde 17 : les +mondes d’essai restent séparés. Voir [le contrat alpha.17](docs/testing-alpha17.md). +Le réacteur, la progression et les reliques restent prévus pour la suite. + +`check build assemblePack`, création/réouverture et option désactivée passent. +Les empreintes natives de la génération 16 restent identiques aux témoins publiés. +Les artefacts 17 sont vérifiés ; publication et mise à jour du canal en cours. + ## Alpha.16 — Sanctuary habitée avant nous Une ville compacte apparaît sur la nouvelle île de départ lorsque les structures @@ -381,7 +404,7 @@ sera une mise à jour explicite, avec vérification des API et des sauvegardes. | Fabric API | 0.160.0+26.3 | | Fabric Loom | 1.17.20 | | Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 | -| Sanctuary / pack | 0.1.0-alpha.16 | +| Sanctuary / pack | 0.1.0-alpha.17 | Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise uniquement la bibliothèque standard et repère aussi une installation Python @@ -410,7 +433,7 @@ décrits dans [Validation](docs/testing.md). Résultats : -- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.16.jar` : mod à installer avec +- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.17.jar` : mod à installer avec Fabric API sur la version Minecraft indiquée. - `build/packwiz/` : pack de développement complet, avec le mod construit et l'index vérifié. Voir [Installation packwiz](packwiz/README.md). diff --git a/docs/backlog.md b/docs/backlog.md index 5d2c293..7a74039 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -668,6 +668,35 @@ Release et canal packwiz publiés ; même instance Prism synchronisée deux fois avec les 767 fichiers personnels et réglages suivis conservés. Voir [le reçu](packwiz.md). Les choix des futurs réseaux et donjons sont consignés dans la vision. +### WG-20 — Réseaux oubliés, prototype réversible — alpha.17 + +**Branche :** `codex/structures-experiment-alpha17`. + +**Contrat :** rendre explorables les trois familles validées : mines et ateliers, +lieux étranges physiques dans l’île, infrastructures abandonnées. Les traces +privilégient des installations utilisables et des chantiers interrompus. + +- Nouveau générateur 17 et option « Structures expérimentales » dans le bouton + natif Personnaliser. La taille et l’option sont enregistrées avec le nouveau + monde ; aucune activation rétroactive ou suppression de structures existantes. +- Conserver les codecs, pièces, plans et futurs chunks des générations publiées, + dont 16. La ville 16 reste le socle de 17 ; les nouveaux réseaux disposent de + leurs pièces natives, emprises protégées et calculs bornés propres. +- Trois petits réseaux recherchés sur l’île initiale, avec accès de surface, + salles, galeries et mobilier. Les continents ne reçoivent pas ce prototype. + Les connexions entre réseaux seront évaluées après les premières implantations. +- Ne pas implémenter de réacteur, progression, reliques ou règles de récompenses + personnelles. Les coffres éventuels utilisent des tables vanilla différées. +- Tester terrain/supports, connexions internes, accès, réglage sauvegardé, + placement natif et réouverture. Livrer le pack et synchroniser l’instance + existante selon le protocole packwiz. Donner graines et coordonnées d’essai. + +**État : code et artefacts vérifiés, publication en cours.** Création/réouverture, +option désactivée, compatibilité 16 et `check build assemblePack` passent. +Retour arrière par branche/artefact et option désactivée +pour un autre nouveau monde. Aucun retour binaire vers 16 dans une sauvegarde 17 +n’est déclaré compatible ; les mondes d’essai restent séparés. + ## Réserve de thèmes futurs Ces thèmes servent à retrouver la vision, pas à demander leur implémentation immédiate. On en extrait un ticket seulement lorsqu'il devient utile au prochain incrément jouable. diff --git a/docs/testing-alpha17.md b/docs/testing-alpha17.md new file mode 100644 index 0000000..a819dbd --- /dev/null +++ b/docs/testing-alpha17.md @@ -0,0 +1,95 @@ +# Alpha.17 — réseaux oubliés, prototype réversible + +**État : code et artefacts vérifiés ; publication en cours.** Ticket WG-20, branche +`codex/structures-experiment-alpha17`. Minecraft 26.3-pre-2, mêmes dépendances +que l’alpha.16. Cet essai porte sur les lieux et leur génération. + +## Contrat + +Créer un **nouveau monde Sanctuary**, puis **Personnaliser** : Petit 512, +Moyen 724 par défaut, Grand 1 024 et **Structures expérimentales** (activées par +défaut). Le choix est enregistré dans le générateur 17, avec la taille. Désactiver +l’option pour un autre nouveau monde permet de comparer avec le socle urbain 16. +Ce bouton ne supprime ni ne rajoute de structures dans une sauvegarde existante. + +Les anciens mondes gardent leurs codecs, rendus, pièces et futurs chunks. Le +preset 16 historique est conservé à l’identique et caché à la création. Le 17 +possède son propre journal ; aucune conversion ni rétrogradation de sauvegarde +n’est proposée. Le retour au binaire 16 concerne seulement les mondes 16 et +antérieurs, jamais un monde 17. + +Les nouvelles structures sont recherchées sur l’île initiale uniquement, hors +ville et eaux protégées. Trois petits réseaux distincts : mines et ateliers, +salles étranges, installations techniques abandonnées. Salles et couloirs se +raccordent à des accès de surface. Les fondations reposent sur le terrain ou sur +des appuis courts certifiés. La présence dépend des emplacements trouvés dans +un budget borné. Les jonctions entre familles restent une étape suivante. + +Les établis, fourneaux et outils vanilla donnent des usages aux installations ; +rails, soutènements et chantiers interrompus suggèrent les anciens occupants. +Les lieux étranges sont physiques dans l’île, distincts de la future dimension +Backrooms. Aucun réacteur, progression, relique collective, récompense personnelle +ou nouveau spawner n’est ajouté par ce prototype. Les coffres éventuels utilisent +les tables de butin vanilla. + +## Vérification et essai + +Le monde **Moyen, graine 0**, option expérimentale activée, reçoit trois réseaux +avec cinq pièces chacun. Les contrôles natifs portent sur **15 pièces**, **7 639 +blocs**, **13 chunks FULL**, **12 appuis**, **49 cibles de parcours** et **3 coffres +à butin différé**. Les empreintes des starts, le plan et un bloc d’or témoin sont +conservés après réouverture dans une seconde JVM. Les trappes sont ouvertes pour +vérifier le parcours réel d’un joueur, puis refermées avant la sauvegarde. + +| Réseau | Entrée X / Y / Z | +| --- | --- | +| Mine et atelier | **78 / 252 / 179** | +| Salles étranges et archives | **−194 / 254 / −22** | +| Locaux techniques | **116 / 253 / −157** | + +Avec les commandes autorisées : + +```text +/locate structure sanctuary:forgotten_networks +``` + +La commande donne une entrée de surface. Le prototype recherche les trois +familles, sans les garantir sur chaque graine. Les neuf types de salles et +couloirs plus l’accès sont testés sur des dimensions et ouvertures variées ; +les plafonds, appuis de mobilier, orientations et chemins sont contrôlés. + +Les trois smokes dédiés passent. Les huit contrôles moteur du socle passent, +avec conservation des cavités et eaux naturelles. Les trois tailles et les deux +valeurs de l’option passent le stockage réel des paramètres sur disque. + +La planification des réseaux prend environ une seconde après celle de la ville +sur le témoin 0 de ce Mac : 915 colonnes lues et 11 974 sondages, pour des plafonds +respectifs de 5 000 et 250 000. Cela ne mesure ni le rendu client ni le chargement +complet sur une autre machine. Rapports locaux ignorés : +`build/alpha17-evidence/ruins17-create.json` et `ruins17-reload.json`. +Logs : `build/alpha17-native-create-final.log`, `alpha17-native-reload.log`, +`alpha17-first-native-base.log`, `alpha17-planner-piece-smokes.log`. + +L’essai natif avec **Structures expérimentales désactivées** passe : plan vide, +zéro sondage expérimental et booléen conservé. Rapport : +`build/alpha17-evidence/ruins17-disabled.json`, log `build/alpha17-native-disabled.log`. +La création et la réouverture actives utilisent les processus 32029 et 32123 ; le +cas désactivé utilise 32176. Aucune sauvegarde personnelle n’a été ouverte. + +La création et la réouverture des mondes de génération **16** passent sous le +binaire 17 : les 71 pièces de la ville initiale et du continent témoin gardent +leurs empreintes natives, ainsi que le journal, les paramètres et le bloc d’or +témoin de la version publiée. Rapports : +`build/alpha17-evidence/legacy16-create.json` et `legacy16-reload.json`. +Le preset 16 historique est également identique octet par octet. La comparaison +avec les reçus publiés est consignée dans `legacy16-comparison.json` : +création sous 17 dans le processus 32244, réouverture dans 32429. + +`./gradlew check build assemblePack` passe : 52 tâches, huit GameTests moteur +et les trois smokes du prototype. Le JAR de production exclut les classes de +test ; les versions, les hashes du pack, le MRpack et le ZIP d’amorçage Prism +sont vérifiés. Journal final : `build/alpha17-final-check-build-pack.log`. + +La publication et la synchronisation de l’instance sont en cours. Aucun essai +Windows natif ni revue visuelle dans le client n’est revendiqué ; le prochain +essai de Koka validera l’ambiance. diff --git a/docs/vision.md b/docs/vision.md index 84ec70e..f5388da 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -231,6 +231,11 @@ L'ambition est de reprendre puis d'étendre la centaine de biomes du catalogue T spawners pourront rester exploitables après exploration sans régénérer les donjons. Les règles précises restent à concevoir. Le réacteur et ces nouveaux systèmes ne font pas partie de l’alpha.16. + Le prototype alpha.17 commence par trois petits réseaux indépendants, avec + accès de surface, ateliers, galeries, salles étranges et locaux techniques. + L’option « Structures expérimentales » se choisit pour un nouveau monde. + Les connexions entre familles et les systèmes de butin spécifiques restent + à construire ; voir [le contrat du prototype](testing-alpha17.md). - **Secret rooms et challenge rooms** : des accès présents dans le monde mènent à des indoors aménagés, avec des équipements, des épreuves ou des gisements de fer, d'or, d'argent ou de titane. ### Dimensions diff --git a/gradle.properties b/gradle.properties index 67dbc33..964a63b 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.16 -pack_version=0.1.0-alpha.16 +mod_version=0.1.0-alpha.17 +pack_version=0.1.0-alpha.17 maven_group=fr.koka.sanctuary diff --git a/mods/sanctuary/build.gradle b/mods/sanctuary/build.gradle index e883870..5c54342 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -40,12 +40,14 @@ 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' : '16')) + providers.gradleProperty('sanctuaryLostCityTests').map { it.toBoolean() }.getOrElse(false) ? '14' : providers.gradleProperty('sanctuaryLostCity15Tests').map { it.toBoolean() }.getOrElse(false) ? '15' : providers.gradleProperty('sanctuaryLostCity16Tests').map { it.toBoolean() }.getOrElse(false) ? '16' : '17')) systemProperty('sanctuary.test.expansion', providers.gradleProperty('sanctuaryExpansionTests').getOrElse('false')) systemProperty('sanctuary.test.expansionReload', providers.gradleProperty('sanctuaryExpansionReload').getOrElse('false')) systemProperty('sanctuary.test.lostCity', providers.gradleProperty('sanctuaryLostCityTests').getOrElse('false')) systemProperty('sanctuary.test.lostCity15', providers.gradleProperty('sanctuaryLostCity15Tests').getOrElse('false')) systemProperty('sanctuary.test.lostCity16', providers.gradleProperty('sanctuaryLostCity16Tests').getOrElse('false')) + systemProperty('sanctuary.test.ruins17', providers.gradleProperty('sanctuaryRuins17Tests').getOrElse('false')) + systemProperty('sanctuary.test.experimentalStructures', providers.gradleProperty('sanctuaryExperimentalStructures').getOrElse('true')) systemProperty('sanctuary.test.cityMatrix', providers.gradleProperty('sanctuaryCityMatrix').getOrElse('false')) systemProperty('sanctuary.test.cityMatrixOnly', providers.gradleProperty('sanctuaryCityMatrixOnly').getOrElse('false')) systemProperty('sanctuary.test.voidReuse', providers.gradleProperty('sanctuaryVoidReuseTests').getOrElse('false')) @@ -65,10 +67,12 @@ tasks.named('processGametestResources') { inputs.property('lostCity15', lostCity15) def lostCity16 = providers.gradleProperty('sanctuaryLostCity16Tests').map { it.toBoolean() }.getOrElse(false) inputs.property('lostCity16', lostCity16) + def ruins17 = providers.gradleProperty('sanctuaryRuins17Tests').map { it.toBoolean() }.getOrElse(false) + inputs.property('ruins17', ruins17) def voidReuse = providers.gradleProperty('sanctuaryVoidReuseTests').map { it.toBoolean() }.getOrElse(false) inputs.property('voidReuse', voidReuse) filesMatching('fabric.mod.json') { - expand(gametest_entrypoint: 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: 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') @@ -400,3 +404,14 @@ tasks.register('lostCityFoundation16Smoke', JavaExec) { mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityFoundation16Smoke' } tasks.named('check') { dependsOn('lostCityFoundation16Smoke') } + +// The experimental pieces and algorithms are additive; historical city suites remain unchanged. +['Planner', 'Renderer', 'Piece'].each { component -> + def smoke = tasks.register("ruins17${component}Smoke", JavaExec) { + group = 'verification' + dependsOn('testClasses') + classpath = sourceSets.test.runtimeClasspath + mainClass = "fr.koka.sanctuary.worldgen.ruins.Ruins${component}17Smoke" + } + tasks.named('check') { dependsOn(smoke) } +} diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Ruins17WorldGameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Ruins17WorldGameTests.java new file mode 100644 index 0000000..7a58161 --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Ruins17WorldGameTests.java @@ -0,0 +1,351 @@ +package fr.koka.sanctuary.gametest; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mojang.serialization.JsonOps; +import fr.koka.sanctuary.SanctuaryMod; +import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator; +import fr.koka.sanctuary.worldgen.city.LostCityRenderer; +import fr.koka.sanctuary.worldgen.ruins.RuinsPiece17; +import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17; +import fr.koka.sanctuary.worldgen.ruins.RuinsRenderer17; +import fr.koka.sanctuary.worldgen.ruins.RuinsStructures17; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.Predicate; +import net.fabricmc.fabric.api.gametest.v1.GameTest; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.HolderSet; +import net.minecraft.core.registries.Registries; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.RegistryOps; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext; +import net.minecraft.world.level.storage.LevelResource; +import net.minecraft.world.level.storage.loot.LootTable; +import net.minecraft.world.phys.AABB; + +/** Opt-in native proof confined to the new generation17 starter island in a disposable development world. */ +public final class Ruins17WorldGameTests { + private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST}; + + @GameTest(maxTicks = 12000) + public void forgottenNetworksPersistNatively(GameTestHelper helper) { + helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator, + "The forgotten-networks proof requires a Sanctuary generator"); + var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator(); + helper.assertTrue(generator.rootGeneration() == 17, "Use a fresh generation17 test world; do not convert old saves"); + new Scenario(helper, generator, Boolean.getBoolean("sanctuary.test.expansionReload")).advance(); + } + + private static final class Scenario { + final GameTestHelper helper; + final ServerLevel level; + final SanctuaryChunkGenerator generator; + final RuinsPlan17 plan; + final boolean reload; + final Map report = new LinkedHashMap<>(); + final List chunks = new ArrayList<>(); + final List starts = new ArrayList<>(); + JsonObject previous; + BlockPos gold; + int nextChunk; + boolean prepared; + + Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator, boolean reload) { + this.helper = helper; this.level = helper.getLevel(); this.generator = generator; this.reload = reload; + this.plan = generator.ruinsPlan17(); + report.put("seed", level.getSeed()); report.put("size", generator.initialDiameter()); + report.put("process_id", ProcessHandle.current().pid()); report.put("world_directory", world(level).toString()); + report.put("experimental_structures", generator.experimentalStructures()); + report.put("structures_enabled", level.structureManager().shouldGenerateStructures()); + report.put("scope", "Native starts, references, room blocks, player-sized routes and unopened loot on the starter island only. " + + "No expansion is created and no personal save is opened. A single outer-wall gold edit in this disposable world " + + "is saved and checked in a second JVM. Open hatches are restored after traversal; no free-fluid simulation is claimed."); + } + void advance() { + try { + if (!prepared) { + prepare(); prepared = true; + if (plan.isEmpty()) { finishDisabled(); return; } + } + if (nextChunk < chunks.size()) { + var pos = chunks.get(nextChunk++); + var loaded = level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true); + helper.assertTrue(loaded instanceof LevelChunk, "Every room footprint must reach native FULL: " + pos); + if (nextChunk % 10 == 0) SanctuaryMod.LOGGER.info("Forgotten networks17: loaded {}/{} footprint chunks", nextChunk, chunks.size()); + helper.runAtTickTime(helper.getTick() + 1, this::advance); return; + } + verifyPlaced(); + report.put("passed", true); + if (!reload) { + helper.assertTrue(gold != null && level.getBlockState(gold).isCollisionShapeFullBlock(level, gold), + "Gameplay witness must replace a solid outer wall away from every doorway"); + level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3); + report.put("gold_witness", coords(gold)); + helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native structures, deferred loot and the gold edit"); + report.put("world_flushed_before_shutdown", true); + } else { + helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Reload replaced the existing wall edit"); + report.put("gold_witness", coords(gold)); report.put("gold_witness_preserved", true); + } + write(level, report, reload); helper.succeed(); + } catch (Exception failure) { + report.put("passed", false); report.put("failure", failure.toString()); + try { write(level, report, reload); } catch (IOException ignored) { } + throw new IllegalStateException("Forgotten networks17 native " + (reload ? "reload" : "creation") + " failed", failure); + } + } + void prepare() throws IOException { + var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()); + var encoded = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow(); + var decoded = ChunkGenerator.CODEC.parse(ops, encoded).getOrThrow(); + helper.assertTrue(decoded instanceof SanctuaryChunkGenerator copy && copy.rootGeneration() == 17 + && copy.experimentalStructures() == generator.experimentalStructures(), "The generation17 codec must preserve its experimental switch"); + report.put("generator_codec", encoded); report.put("plan_sha256", planHash(plan)); report.put("planning_samples", plan.samples()); + helper.assertTrue(plan.samples() <= RuinsPlan17.MAX_SAMPLES, "Forgotten-network planning exceeded its budget"); + if (reload) { + previous = JsonParser.parseString(Files.readString(proof(level))).getAsJsonObject(); + helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(), + "Reload requires the successful, flushed creation proof"); + helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must run in a separate JVM"); + helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed() + && previous.get("size").getAsInt() == generator.initialDiameter() + && previous.get("world_directory").getAsString().equals(world(level).toString()), "Reload used another world, seed or size"); + helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("plan_sha256").getAsString().equals(planHash(plan)), + "Saved switch or planned generation17 room identities changed on reload"); + if (previous.has("gold_witness")) gold = readPos(previous.getAsJsonArray("gold_witness")); + } + boolean enabled = generator.experimentalStructures() && level.structureManager().shouldGenerateStructures(); + if (!enabled) { + helper.assertTrue(plan.isEmpty(), "Experimental=false or generateStructures=false must publish no forgotten network"); + return; + } + helper.assertTrue(!plan.isEmpty(), "Enabled reference island has no experimental network"); + var families = EnumSet.noneOf(RuinsPlan17.Family.class); + plan.networks().forEach(n -> families.add(n.family())); + report.put("families", families.stream().map(Enum::name).toList()); report.put("networks", plan.networks().size()); + if (level.getSeed() == 0) helper.assertTrue(families.equals(EnumSet.allOf(RuinsPlan17.Family.class)), + "Seed0 reference must demonstrate all three experimental families; found " + families); + var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY); + var origins = new HashSet(); var uniqueChunks = new HashSet(); + for (var network : plan.networks()) { + long origin = new ChunkPos(network.startChunkX(), network.startChunkZ()).pack(); + if (!origins.add(origin)) continue; + var chunk = level.getChunk(network.startChunkX(), network.startChunkZ(), ChunkStatus.STRUCTURE_STARTS, true); + var start = chunk.getStartForStructure(structure.value()); + helper.assertTrue(start != null && start.isValid(), "Accepted experimental network has no native start: " + network.id()); + starts.add(start); + for (var piece : start.getPieces()) { + helper.assertTrue(piece instanceof RuinsPiece17, "Forgotten network loaded a foreign or historical piece type"); + piece.getBoundingBox().intersectingChunks().forEach(uniqueChunks::add); + } + } + helper.assertTrue(uniqueChunks.size() <= 225, "The bounded root-only proof unexpectedly covers more than225 chunks"); + chunks.addAll(uniqueChunks.stream().sorted(java.util.Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList()); + } + void finishDisabled() throws IOException { + var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY); + var chunk = level.getChunk(0, 0, ChunkStatus.STRUCTURE_STARTS, true); + var start = chunk.getStartForStructure(structure.value()); + helper.assertTrue(start == null || !start.isValid(), "Disabled experimental generation emitted a native start"); + var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false); + helper.assertTrue(located == null, "Disabled experimental structures must not be located or prepared"); + report.put("empty_plan_verified", true); report.put("passed", true); + if (!reload) { + helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save the disabled experiment setting"); + report.put("world_flushed_before_shutdown", true); + } + write(level, report, reload); helper.succeed(); + } + void verifyPlaced() { + var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY); + var context = StructurePieceSerializationContext.fromLevel(level); + var sites = new ArrayList>(); + int blocks = 0, supports = 0, chests = 0, pieces = 0; + for (var start : starts) { + var origin = start.getChunkPos(); var saved = start.createTag(context, origin); + var copy = StructureStart.loadStaticStart(context, saved, level.getSeed()); + helper.assertTrue(copy != null && copy.isValid() && copy.createTag(context, origin).equals(saved), "The native start changed on an NBT roundtrip"); + var expectedRooms = plan.networks().stream().filter(n -> n.startChunkX() == origin.x() && n.startChunkZ() == origin.z()) + .flatMap(n -> n.rooms().stream()).toList(); + var actualRooms = start.getPieces().stream().map(RuinsPiece17.class::cast).map(RuinsPiece17::room).toList(); + helper.assertTrue(actualRooms.size() == expectedRooms.size() && new HashSet<>(actualRooms).equals(new HashSet<>(expectedRooms)), + "Native start did not persist every planned room exactly once"); + String nativeHash = hash(saved.toString()); + if (reload) { + JsonObject prior = null; + for (var value : previous.getAsJsonArray("starts")) { + var site = value.getAsJsonObject(); var at = site.getAsJsonArray("start_chunk"); + if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = site; + } + helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(nativeHash), + "Reload altered native room parameters, supports or reference count"); + } + var loot = new ArrayList>(); + for (var piece : start.getPieces()) { + var roomPiece = (RuinsPiece17) piece; var room = roomPiece.room(); pieces++; + piece.getBoundingBox().intersectingChunks().forEach(at -> { + var full = level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true); + helper.assertTrue(full.getReferencesForStructure(structure.value()).contains(origin.pack()), "A saved FULL chunk lost the native structure reference"); + }); + var roomTag = roomPiece.createTag(context); + var roundtrip = new RuinsPiece17(context, roomTag); + helper.assertTrue(roundtrip.room().equals(room) && roundtrip.createTag(context).equals(roomTag), "The native room failed its exact saved-field roundtrip"); + var corrupt = roomTag.copy(); var payload = corrupt.getCompound("SanctuaryRoom").orElseThrow(); payload.putInt("Schema", 99); + boolean rejected = false; + try { new RuinsPiece17(context, corrupt); } catch (IllegalArgumentException expected) { rejected = true; } + helper.assertTrue(rejected, "An unknown generation17 piece schema must be refused"); + var expected = rendered(room); blocks += expected.blocks().size(); + for (var entry : expected.blocks().entrySet()) { + var at = entry.getKey(); var wanted = entry.getValue(); + helper.assertTrue(RuinsPiece17.bounds(room).isInside(at), "Renderer escaped its saved room/support bounds"); + if (reload && at.equals(gold)) continue; + helper.assertTrue(level.getBlockState(at).is(wanted.getBlock()), + "Native clipping or later decoration changed " + room.kind() + " at " + at + ": expected " + wanted + " got " + level.getBlockState(at)); + helper.assertTrue(!level.getBlockState(at).is(Blocks.SPAWNER), "Experimental architecture must not create a spawner"); + } + for (var support : room.supports()) { + supports++; + var below = new BlockPos(support.x(), support.minY() - 1, support.z()); + helper.assertTrue(level.getBlockState(below).isSolid() && level.getFluidState(below).isEmpty(), "Room footing has lost its native rock anchor"); + } + for (var chest : expected.chests()) { + helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Deferred native chest block entity is missing"); + var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position()); + helper.assertTrue(chest.table().equals(entity.getLootTable()) && entity.getLootTableSeed() == chest.seed(), + "Native chest loot must remain deferred with its exact table and seed"); + loot.add(Map.of("position", coords(chest.position()), "table", chest.table().identifier().toString(), "seed", chest.seed())); chests++; + } + if (!reload && gold == null && room.kind() != RuinsPlan17.Kind.ACCESS && room.kind() != RuinsPlan17.Kind.CORRIDOR) + gold = new BlockPos(room.minX(), room.floorY() + 1, room.minZ() + 1); + } + sites.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", nativeHash, + "pieces", start.getPieces().size(), "unopened_chests", loot)); + } + var traversal = new ArrayList>(); + for (var network : plan.networks()) traversal.add(verifyAccess(helper, network)); + var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false); + helper.assertTrue(located != null && located.getSecond().is(RuinsStructures17.KEY), "Native locate cannot find a saved forgotten network"); + helper.assertTrue(plan.networks().stream().flatMap(n -> n.rooms().stream()).filter(r -> r.kind() == RuinsPlan17.Kind.ACCESS) + .anyMatch(r -> RuinsPiece17.locator(r).equals(located.getFirst())), "Locate must lead to a surface entrance rather than an underground floor"); + report.put("starts", sites); report.put("native_pieces", pieces); report.put("native_blocks_checked", blocks); + report.put("full_chunks_checked", chunks.size()); report.put("rock_supports_checked", supports); + report.put("unopened_deferred_chests", chests); report.put("native_routes", traversal); report.put("locate", coords(located.getFirst())); + } + } + private record Chest(BlockPos position, ResourceKey table, long seed) {} + private record Rendered(Map blocks, List chests) {} + private static Rendered rendered(RuinsPlan17.Room room) { + var blocks = new HashMap(); var chests = new ArrayList(); + RuinsRenderer17.render(room, new LostCityRenderer.BlockWriter() { + @Override public void set(int x, int y, int z, BlockState state) { blocks.put(at(room, x, y, z), state); } + @Override public void chest(int x, int y, int z, ResourceKey table, long seed) { + var position = at(room, x, y, z); blocks.put(position, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(position, table, RandomSource.create(seed).nextLong())); + } + }); + return new Rendered(blocks, chests); + } + private static Map verifyAccess(GameTestHelper helper, RuinsPlan17.Network network) { + var level = helper.getLevel(); var access = network.rooms().stream().filter(r -> r.kind() == RuinsPlan17.Kind.ACCESS).findFirst().orElseThrow(); + var hatches = new HashMap(); + rendered(access).blocks().forEach((at, state) -> { if (state.is(Blocks.DARK_OAK_TRAPDOOR)) hatches.put(at, level.getBlockState(at)); }); + helper.assertTrue(hatches.size() == 1, "Each network must have one usable wooden surface hatch"); + hatches.forEach((at, state) -> { + helper.assertTrue(state.is(Blocks.DARK_OAK_TRAPDOOR) && state.canSurvive(level, at), "Surface hatch is missing its native support"); + level.setBlock(at, state.setValue(BlockStateProperties.OPEN, true), 2); + }); + try { + Predicate inside = pos -> network.rooms().stream().anyMatch(r -> r.contains(pos.getX(), pos.getY(), pos.getZ())); + var start = RuinsPiece17.locator(access); var reached = walk(helper, start, inside); + int targets = 0; + for (var room : network.rooms()) for (var target : RuinsRenderer17.targets(room)) { + var point = at(room, target.x(), target.y(), target.z()); + helper.assertTrue(reached.contains(point), "Native network " + network.id() + " cannot reach " + room.kind() + "/" + target.name() + " at " + point); + targets++; + } + for (var hatch : hatches.keySet()) helper.assertTrue(reached.contains(hatch), "Surface approach cannot enter its ladder shaft"); + return Map.of("id", network.id(), "family", network.family().name(), "rooms", network.rooms().size(), + "entrance", coords(start), "walkable_targets", targets, "standing_cells", reached.size()); + } finally { hatches.forEach((at, state) -> level.setBlock(at, state, 2)); } + } + private static Set walk(GameTestHelper helper, BlockPos start, Predicate inside) { + var level = helper.getLevel(); helper.assertTrue(inside.test(start) && standable(level, start), "Blocked native surface approach: " + start); + var seen = new HashSet(); var queue = new ArrayDeque(); seen.add(start); queue.add(start); + while (!queue.isEmpty()) { + var pos = queue.removeFirst(); + for (var direction : HORIZONTAL) for (int dy = -1; dy <= 1; dy++) { + var next = pos.relative(direction).offset(0, dy, 0); + if (!inside.test(next) || dy > 0 && !clearBody(level, pos.above())) continue; + if (standable(level, next) && seen.add(next)) queue.add(next); + } + for (int dy : new int[] {-1, 1}) { + var next = pos.offset(0, dy, 0); if (!inside.test(next)) continue; + if ((level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(next).is(Blocks.LADDER) + || level.getBlockState(pos.below()).is(Blocks.LADDER)) && standable(level, next) && seen.add(next)) queue.add(next); + } + helper.assertTrue(seen.size() <= 50_000, "Native traversal exceeded its room bounds"); + } + return seen; + } + private static boolean standable(ServerLevel level, BlockPos pos) { + if (!clearBody(level, pos)) return false; + if (level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(pos.below()).is(Blocks.LADDER)) return true; + var below = pos.below(); + return level.getBlockState(below).getCollisionShape(level, below).toAabbs().stream() + .anyMatch(b -> b.maxY >= .5 && b.minX <= .5 && b.maxX >= .5 && b.minZ <= .5 && b.maxZ >= .5); + } + private static boolean clearBody(ServerLevel level, BlockPos pos) { + var body = new AABB(pos.getX() + .2, pos.getY(), pos.getZ() + .2, pos.getX() + .8, pos.getY() + 1.8, pos.getZ() + .8); + for (var at : new BlockPos[] {pos, pos.above()}) { + if (!level.getFluidState(at).isEmpty()) return false; + for (var shape : level.getBlockState(at).getCollisionShape(level, at).toAabbs()) + if (shape.move(at.getX(), at.getY(), at.getZ()).intersects(body)) return false; + } + return true; + } + private static BlockPos at(RuinsPlan17.Room room, int x, int y, int z) { return new BlockPos(room.minX() + x, room.minY() + y, room.minZ() + z); } + private static List coords(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); } + private static BlockPos readPos(com.google.gson.JsonArray value) { return new BlockPos(value.get(0).getAsInt(), value.get(1).getAsInt(), value.get(2).getAsInt()); } + private static Path world(ServerLevel level) { return level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); } + private static Path proof(ServerLevel level) { return world(level).resolve("ruins17-create-proof.json"); } + private static String planHash(RuinsPlan17 plan) { return hash(plan.networks().toString()); } + private static void write(ServerLevel level, Map report, boolean reload) throws IOException { + var path = Path.of("diagnostics", "ruins17-" + (reload ? "reload" : "create") + ".json"); Files.createDirectories(path.getParent()); + var value = new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n"; + Files.writeString(path, value); + if (!reload) Files.writeString(proof(level), value); + } + private static String hash(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); } + } +} 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 d53084a..07c51e8 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 @@ -42,8 +42,11 @@ final class UnifiedWorldSettingsDiagnostics { var historical15 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v15"))).value().createWorldDimensions(); helper.assertTrue(historical15.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 15 && old.source().generation() == 15, "Historical alpha.15 keeps its own biome codec and city generator"); + var historical16 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v16"))).value().createWorldDimensions(); + helper.assertTrue(historical16.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 16 + && old.source().generation() == 16 && !old.experimentalStructures(), "Historical16 never gains experimental structures"); var generator = (SanctuaryChunkGenerator) standard.overworld(); - helper.assertTrue(generator.rootGeneration() == 16 && generator.source().generation() == 16, "The public preset uses generation16"); + helper.assertTrue(generator.rootGeneration() == 17 && generator.source().generation() == 17 && generator.experimentalStructures(), "The public preset uses generation17 with its new-world option"); helper.assertTrue(generator.initialDiameter() == 724, "The public preset defaults to Medium / 724 blocks"); for (var tag : List.of(WorldPresetTags.NORMAL, WorldPresetTags.EXTENDED)) { var visible = presets.getOrThrow(tag).stream().map(holder -> holder.getRegisteredName()) @@ -53,8 +56,13 @@ final class UnifiedWorldSettingsDiagnostics { } var ops = RegistryOps.create(JsonOps.INSTANCE, access); - var activeCodec = generator.rootGeneration() == 16 ? SanctuaryChunkGenerator.CITY16_CODEC : generator.rootGeneration() == 15 ? SanctuaryChunkGenerator.CITY15_CODEC : generator.rootGeneration() == 14 ? SanctuaryChunkGenerator.CITY_CODEC : SanctuaryChunkGenerator.CODEC; + var activeCodec = generator.rootGeneration() == 17 ? SanctuaryChunkGenerator.RUINS17_CODEC : generator.rootGeneration() == 16 ? SanctuaryChunkGenerator.CITY16_CODEC : generator.rootGeneration() == 15 ? SanctuaryChunkGenerator.CITY15_CODEC : generator.rootGeneration() == 14 ? SanctuaryChunkGenerator.CITY_CODEC : SanctuaryChunkGenerator.CODEC; var serialized = activeCodec.codec().encodeStart(ops, generator).getOrThrow().getAsJsonObject(); + serialized.remove("experimental_structures"); + helper.assertTrue(activeCodec.codec().parse(ops, serialized).getOrThrow().experimentalStructures(), + "Generation17 omitted experimental option defaults to enabled"); + helper.assertTrue(!((SanctuaryChunkGenerator) historical16.overworld()).withExperimentalStructures(true).experimentalStructures(), + "The new-world switch cannot activate experimental generation in a historical16 generator"); serialized.remove("initial_diameter"); helper.assertTrue(activeCodec.codec().parse(ops, serialized).getOrThrow().initialDiameter() == 724, "An omitted new-generation size defaults to Medium"); @@ -67,8 +75,8 @@ final class UnifiedWorldSettingsDiagnostics { Files.createDirectories(directory); var storage = LevelStorageSource.createDefault(directory); List> results = new ArrayList<>(); - for (int diameter : new int[]{512, 724, 1024}) { - var chosen = generator.withInitialDiameter(diameter); + for (int diameter : new int[]{512, 724, 1024}) for (boolean experimental : new boolean[]{false, true}) { + var chosen = generator.withInitialDiameter(diameter).withExperimentalStructures(experimental); helper.assertTrue(generator.initialDiameter() == 724 && chosen.source() != generator.source(), "Customizing a new world must copy the generator and its mutable biome source"); var dimensions = standard.replaceOverworldGenerator(access, chosen); @@ -77,17 +85,17 @@ final class UnifiedWorldSettingsDiagnostics { "Customizing the island preserves the other dimensions"); } var original = new WorldGenSettings(new WorldOptions(helper.getLevel().getSeed(), false, true), dimensions); - String name = "diameter-" + diameter; + String name = "diameter-" + diameter + "-experimental-" + experimental; try (var save = storage.createAccess(name)) { LevelStorageSource.writeWorldGenSettings(access, storage.getLevelPath(name), original); var restored = LevelStorageSource.readExistingSavedData(save, access, WorldGenSettings.TYPE).getOrThrow(); helper.assertTrue(restored.dimensions().overworld() instanceof SanctuaryChunkGenerator reloaded - && reloaded.initialDiameter() == diameter && reloaded.rootGeneration() == generator.rootGeneration(), + && reloaded.initialDiameter() == diameter && reloaded.rootGeneration() == generator.rootGeneration() && reloaded.experimentalStructures() == experimental, "The selected diameter must survive real world-settings storage"); helper.assertTrue(WorldGenSettings.CODEC.encodeStart(ops, restored).getOrThrow() .equals(WorldGenSettings.CODEC.encodeStart(ops, original).getOrThrow()), "Seed, generation options and all dimensions survive the disk round trip"); - results.add(Map.of("diameter", diameter, "disk_round_trip", true)); + results.add(Map.of("diameter", diameter, "experimental_structures", experimental, "disk_round_trip", true)); } } Files.writeString(directory.resolve("round-trip.json"), new GsonBuilder().setPrettyPrinting().create().toJson(Map.of( 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 e5dfda6..5db152e 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.expansion") || Boolean.getBoolean("sanctuary.test.lostCity") + if (Boolean.getBoolean("sanctuary.test.ruins17") || Boolean.getBoolean("sanctuary.test.expansion") || Boolean.getBoolean("sanctuary.test.lostCity") || Boolean.getBoolean("sanctuary.test.lostCity16") || Boolean.getBoolean("sanctuary.test.lostCity15") || Boolean.getBoolean("sanctuary.test.voidReuse")) { long until = System.nanoTime() + 20_000_000L; long remaining; @@ -46,9 +46,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", 16); - if (generation != 13 && generation != 14 && generation != 15 && generation != 16) throw new IllegalArgumentException("Unsupported test generation"); - return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(generation == 16 ? "sanctuary" : "sanctuary_v" + generation)); + int generation = Integer.getInteger("sanctuary.test.generation", 17); + if (generation != 13 && generation != 14 && generation != 15 && generation != 16 && generation != 17) throw new IllegalArgumentException("Unsupported test generation"); + return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(generation == 17 ? "sanctuary" : "sanctuary_v" + generation)); } if (Boolean.getBoolean("sanctuary.test.expansion")) return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("expansion_lab")); int players = Integer.getInteger("sanctuary.test.players", 10); @@ -70,7 +70,8 @@ public abstract class SanctuaryGameTestServerMixin { if (Boolean.getBoolean("sanctuary.test.unified")) { var generator = (fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator) dimensions.overworld(); return dimensions.replaceOverworldGenerator(context.datapackWorldRegistries(), - generator.withInitialDiameter(Integer.getInteger("sanctuary.test.diameter", 724))); + generator.withInitialDiameter(Integer.getInteger("sanctuary.test.diameter", 724)) + .withExperimentalStructures(Boolean.parseBoolean(System.getProperty("sanctuary.test.experimentalStructures", "true")))); } return dimensions; } 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 3867a79..5138d61 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java @@ -57,7 +57,10 @@ public final class SanctuaryMod implements ModInitializer { Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v15"), SanctuaryBiomeSource.CITY15_CODEC); Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v16"), SanctuaryChunkGenerator.CITY16_CODEC); Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v16"), SanctuaryBiomeSource.CITY16_CODEC); + Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v17"), SanctuaryChunkGenerator.RUINS17_CODEC); + Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v17"), SanctuaryBiomeSource.RUINS17_CODEC); fr.koka.sanctuary.worldgen.city.LostCityStructures.register(); + fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.register(); Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("population_island"), PopulationIslandDensity.CODEC); Registry.register(BuiltInRegistries.BIOME_SOURCE, id("population_island"), PopulationIslandBiomeSource.CODEC); Registry.register(BuiltInRegistries.FEATURE_TYPE, id("population_groves"), PopulationGrovesFeature.CODEC); 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 07d96e9..1a99ccb 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 @@ -12,12 +12,13 @@ import net.minecraft.client.gui.screens.worldselection.WorldCreationContext; import net.minecraft.network.chat.CommonComponents; import net.minecraft.network.chat.Component; -/** Edits only the pending new world's island size. Cancel never changes its dimensions. */ +/** Edits size and the saved experiment switch of a pending new world. Cancel applies neither. */ public final class SanctuaryWorldOptionsScreen extends Screen { private final CreateWorldScreen parent; private final SanctuaryChunkGenerator generator; private final HeaderAndFooterLayout layout = new HeaderAndFooterLayout(this); private IslandSize selectedSize; + private boolean experimental; private MultiLineTextWidget diameterHint; public SanctuaryWorldOptionsScreen(CreateWorldScreen parent, WorldCreationContext context) { @@ -28,6 +29,7 @@ public final class SanctuaryWorldOptionsScreen extends Screen { } generator = sanctuary; selectedSize = IslandSize.fromDiameter(generator.initialDiameter()); + experimental = generator.experimentalStructures(); } @Override @@ -49,6 +51,13 @@ public final class SanctuaryWorldOptionsScreen extends Screen { contents.addChild(sizeButton); diameterHint = contents.addChild(new MultiLineTextWidget(diameterHint(), font) .setMaxWidth(310).setCentered(true)); + if (generator.rootGeneration() == 17) { + contents.addChild(CycleButton.builder((Boolean enabled) -> enabled ? CommonComponents.OPTION_ON : CommonComponents.OPTION_OFF, experimental) + .withValues(true, false).create(0, 0, 280, 20, Component.translatable("sanctuary.world_options.experimental"), + (button, value) -> experimental = value)); + contents.addChild(new MultiLineTextWidget(Component.translatable("sanctuary.world_options.experimental_hint"), font) + .setMaxWidth(310).setCentered(true)); + } layout.addToContents(contents); LinearLayout actions = LinearLayout.horizontal().spacing(8); @@ -65,7 +74,7 @@ public final class SanctuaryWorldOptionsScreen extends Screen { private void applyAndClose() { parent.getUiState().updateDimensions((registries, dimensions) -> dimensions.replaceOverworldGenerator( - registries, generator.withInitialDiameter(selectedSize.diameter))); + registries, generator.withInitialDiameter(selectedSize.diameter).withExperimentalStructures(experimental))); onClose(); } 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 3a2867f..b796454 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 @@ -121,6 +121,7 @@ public class ExpansionChunkGenerator extends ChunkGenerator { @Override public void createStructures(RegistryAccess registries, ChunkGeneratorStructureState state, StructureManager structures, ChunkAccess chunk, StructureTemplateManager templates, ResourceKey dimension) { fr.koka.sanctuary.worldgen.city.LostCityStructures.generate(this, registries, state, structures, chunk, templates, dimension); + fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.generate(this, registries, state, structures, chunk, templates, dimension); 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 f4e89d0..2f979d3 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 @@ -41,7 +41,7 @@ public record ExpansionIsland(String id, String parent, String direction, String if (!((spatialGeneration(generation) ? DIAMETERS : LEGACY_DIAMETERS).contains(diameter) || (id.equals("sanctuary") && (diameter == 724 || (generation == 13 || spatialGeneration(generation)) && diameter == 1024)))) throw new IllegalArgumentException("Unsupported nominal diameter"); if (Math.abs((long) centerX) > 100_000 || Math.abs((long) centerZ) > 100_000 || (centerX & 15) != 0 || (centerZ & 15) != 0) throw new IllegalArgumentException("Invalid island centre"); if (!List.of("reserved", "ready").contains(status) || (generation != GENERATION && !spatialGeneration(generation) && !(generation == 13 && id.equals("sanctuary")))) throw new IllegalArgumentException("Unsupported island state or generation"); - if (climate.equals("lost_city") && (!spatialGeneration(generation) || id.equals("sanctuary"))) throw new IllegalArgumentException("Lost City requires a generation 14, 15 or 16 expansion"); + if (climate.equals("lost_city") && (!spatialGeneration(generation) || id.equals("sanctuary"))) throw new IllegalArgumentException("Lost City requires a generation 14, 15, 16 or 17 expansion"); if (reuseToken == null || !reuseToken.isEmpty() && (!spatialGeneration(generation) || id.equals("sanctuary") || !reuseToken.matches("[0-9a-f]{64}"))) throw new IllegalArgumentException("Invalid void reuse certificate token"); } @@ -53,7 +53,7 @@ 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; } + public static boolean spatialGeneration(int generation) { return generation == 14 || generation == 15 || generation == 16 || generation == 17; } 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/ExpansionStructures.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/expansion/ExpansionStructures.java index a8bc724..8058fc1 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 @@ -139,6 +139,9 @@ public final class ExpansionStructures { if (fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime.protects(binding.generator(), binding.random(), new BlockPos(centreX, total.minY(), centreZ), radius, total.getYSpan() + 16, 26)) return "protected-hydrology"; } + if (generator instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator city + && city.intersectsExperimentalStructures(total.minX() - 1, total.minY() - (temple ? 24 : 12), total.minZ() - 1, + total.maxX() + 1, total.maxY() + 4, total.maxZ() + 1)) return "experimental-structure"; if (!inside(island, total.minX(), total.minZ(), 4) || !inside(island, total.maxX(), total.minZ(), 4) || !inside(island, total.minX(), total.maxZ(), 4) || !inside(island, total.maxX(), total.maxZ(), 4)) return "outside-island"; if (total.minY() < chunk.getMinY() || total.maxY() > chunk.getMaxY() - 4) return "outside-height"; 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 8a2f398..96ab164 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 @@ -24,7 +24,8 @@ public abstract class UnifiedDecorationProtectionMixin { var owner = generator.islandForChunk(center.x(), center.z()); var destination = generator.islandForChunk(pos.getX() >> 4, pos.getZ() >> 4); if (owner == null || destination == null || !owner.id().equals(destination.id()) - || !fr.koka.sanctuary.worldgen.city.LostCityStructures.isPlacing() && generator.protectsCityDecoration(pos)) + || !fr.koka.sanctuary.worldgen.city.LostCityStructures.isPlacing() + && !fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.isPlacing() && generator.protectsCityDecoration(pos)) callback.setReturnValue(false); } @@ -39,7 +40,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 + if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17) && 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 afee12c..63528ee 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 @@ -38,6 +38,10 @@ public final class SanctuaryBiomeSource extends ExpansionBiomeSource { Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes) ).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 16))); + public static final MapCodec RUINS17_CODEC = RecordCodecBuilder.mapCodec(i -> i.group( + Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes) + ).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 17))); + private final List> allBiomes; private final PopulationIslandBiomeSource population; private final boolean cities; @@ -71,7 +75,7 @@ public final class SanctuaryBiomeSource extends ExpansionBiomeSource { cityPlans = java.util.Map.copyOf(plans); } - @Override protected MapCodec codec() { return generation == 16 ? CITY16_CODEC : generation == 15 ? CITY15_CODEC : cities ? CITY_CODEC : CODEC; } + @Override protected MapCodec codec() { return 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 a814042..f86b45c 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 @@ -39,6 +39,15 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter) ).apply(i, (source, diameter) -> new SanctuaryChunkGenerator(source, diameter, 16))); + public static final MapCodec RUINS17_CODEC = RecordCodecBuilder.mapCodec(i -> i.group( + SanctuaryBiomeSource.RUINS17_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, 17, experimental))); + + private final boolean experimentalStructures; + private volatile fr.koka.sanctuary.worldgen.ruins.RuinsPlan17 ruinsPlan17 = fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.EMPTY; + private boolean ruinsPrepared; private volatile Context originContext; private final int generation; private net.minecraft.world.level.LevelHeightAccessor heightAccessor; @@ -70,14 +79,26 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { this(source, initialDiameter, 13); } private SanctuaryChunkGenerator(SanctuaryBiomeSource source, int initialDiameter, int generation) { + this(source, initialDiameter, generation, false); + } + private SanctuaryChunkGenerator(SanctuaryBiomeSource source, int initialDiameter, int generation, boolean experimental) { super(source, initialDiameter, true); + this.experimentalStructures = generation == 17 && 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 == 16 ? CITY16_CODEC : generation == 15 ? CITY15_CODEC : generation == 14 ? CITY_CODEC : CODEC; } + @Override protected MapCodec codec() { return 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); + return new SanctuaryChunkGenerator(source().copy(), diameter, generation, experimentalStructures); + } + public boolean experimentalStructures() { return experimentalStructures; } + public SanctuaryChunkGenerator withExperimentalStructures(boolean enabled) { + return new SanctuaryChunkGenerator(source().copy(), initialDiameter(), generation, enabled); + } + public fr.koka.sanctuary.worldgen.ruins.RuinsPlan17 ruinsPlan17() { return ruinsPlan17; } + public boolean intersectsExperimentalStructures(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + return generation == 17 && ruinsPlan17.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ); } public IslandCapacity capacity() { return switch (initialDiameter()) { @@ -112,10 +133,12 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { // Publish one root context before any generation workers use it. context(islands.getFirst()); prepareCities(islands); + prepareRuins(islands); } @Override public void setIslands(List islands) { // Complete and publish city geometry before workers can observe the new owner list. prepareCities(islands); + prepareRuins(islands); super.setIslands(islands); } private void prepareCities(List islands) { @@ -126,7 +149,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { source().setCityPlans(java.util.Map.of()); return; } - if (generation == 16 && !cityStructuresEnabled) { + if ((generation == 16 || generation == 17) && !cityStructuresEnabled) { cityProtection16 = fr.koka.sanctuary.worldgen.city.LostCityProtection16.EMPTY; activeCityPlans = java.util.Map.of(); source().setCityPlans(java.util.Map.of()); @@ -142,7 +165,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { } cityProtection15 = protection.build(); } - if (generation == 16) { + if (generation == 16 || generation == 17) { var protection = new fr.koka.sanctuary.worldgen.city.LostCityProtection16.Builder(); for (var island : islands) { protection.add(published.get(island.id()), undergroundPlan16(island)); @@ -156,6 +179,19 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { activeCityPlans = java.util.Map.copyOf(published); source().setCityPlans(published); } + private void prepareRuins(List islands) { + if (generation != 17 || !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 -> protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ())); + long start = System.nanoTime(); + var plan = fr.koka.sanctuary.worldgen.ruins.RuinsPlanner17.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled); + ruinsPlan17 = plan; + ruinsPrepared = true; + SanctuaryMod.LOGGER.info("Experimental ruins17 seed {}: {} networks, {} probes, {} noise columns in {} ms", + root.seed(), plan.networks().size(), plan.samples(), sampled.columnsUsed(), (System.nanoTime() - start) / 1_000_000); + } private static ExpansionIsland cityIdentity(ExpansionIsland island) { // State and receipt do not change the terrain geometry used by a cached plan. var ready = island.status().equals("ready") ? island : island.ready(); @@ -181,7 +217,7 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { return cityPlans.computeIfAbsent(cityIdentity(island), ignored -> { long start = System.nanoTime(); var terrain = context(island); - if (generation == 16) { + if (generation == 16 || generation == 17) { var sampled = new fr.koka.sanctuary.worldgen.city.LostCityTerrain16(island, terrain.generator(), terrain.random(), heightAccessor, pos -> island.origin() && protectsDecoration(pos)); var placement = LostCityPlanner16.plan(island.seed(), island.centerX(), island.centerZ(), @@ -252,7 +288,8 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { }); } public boolean protectsCityDecoration(net.minecraft.core.BlockPos pos) { - if (generation == 16) return cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()); + if (generation == 16 || generation == 17) return cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()) + || generation == 17 && ruinsPlan17.protects(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()); @@ -274,20 +311,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 && cityProtection16.intersects(minX, minY, minZ, maxX, maxY, maxZ); + return (generation == 16 || generation == 17) && cityProtection16.intersects(minX, minY, minZ, maxX, maxY, maxZ); } public fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan undergroundPlan16(ExpansionIsland island) { - if (generation != 16) return new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan(List.of(), 0); + if (generation != 16 && generation != 17) 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) return List.of(); + if (generation != 16 && generation != 17) return List.of(); lostCityPlan(island); return citySupports16.getOrDefault(cityIdentity(island), List.of()); } public LostCityPlanner16.Fallback cityReservation16(ExpansionIsland island) { - if (generation != 16) return null; + if (generation != 16 && generation != 17) return null; lostCityPlan(island); return cityReservations16.get(cityIdentity(island)); } @@ -299,6 +336,8 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator { net.minecraft.core.BlockPos origin, int radius, boolean 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 && 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); return super.findNearestMapStructure(level, structures, origin, radius, skipReferenced); } @Override protected Context context(ExpansionIsland island) { @@ -326,7 +365,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 && cityStructuresEnabled) { + if ((generation == 16 || generation == 17) && 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/city/LostCityStructure.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/city/LostCityStructure.java index f6f58a9..49537b9 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,17 +29,17 @@ 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.cityReservation16(island) != null) + if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17) && generator.cityReservation16(island) != null) 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) for (var support : generator.citySupports16(island)) { + if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17)) 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() == 16 ? new LostCityPiece16(parcel) : generator.rootGeneration() == 15 + district.parcels().forEach(parcel -> pieces.addPiece((generator.rootGeneration() == 16 || generator.rootGeneration() == 17) ? 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; @@ -49,7 +49,7 @@ 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) for (var network : generator.undergroundPlan16(island).networks()) { + if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17)) 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) diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsPiece17.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsPiece17.java new file mode 100644 index 0000000..f5a034c --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsPiece17.java @@ -0,0 +1,114 @@ +package fr.koka.sanctuary.worldgen.ruins; + +import java.util.ArrayList; +import java.util.Set; +import fr.koka.sanctuary.worldgen.city.LostCityRenderer; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.resources.ResourceKey; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.WorldGenLevel; +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; + +/** Saved generation17 room geometry is rendered without replanning or reading neighboring chunks. */ +public final class RuinsPiece17 extends StructurePiece { + private static final Set ROOM_KEYS = Set.of("Schema", "Kind", "MinX", "MinY", "MinZ", "Width", + "Height", "Depth", "Openings", "RoomSeed", "Supports"); + private static final Set SUPPORT_KEYS = Set.of("X", "MinY", "Z", "MaxY"); + private final RuinsPlan17.Room room; + + public RuinsPiece17(RuinsPlan17.Room room) { + super(RuinsStructures17.PIECE, 0, bounds(room)); + this.room = room; + setOrientation(null); + } + public RuinsPiece17(StructurePieceSerializationContext context, CompoundTag tag) { + super(RuinsStructures17.PIECE, tag); + room = decodeRoom(tag.getCompound("SanctuaryRoom") + .orElseThrow(() -> new IllegalArgumentException("Missing saved forgotten room"))); + if (!getBoundingBox().equals(bounds(room))) + throw new IllegalArgumentException("Forgotten room disagrees with its saved native bounding box"); + setOrientation(null); + } + public RuinsPlan17.Room room() { return room; } + @Override public BlockPos getLocatorPosition() { return locator(room); } + public static BlockPos locator(RuinsPlan17.Room room) { + return new BlockPos(room.minX() + room.width() / 2, + room.kind() == RuinsPlan17.Kind.ACCESS ? room.minY() + room.height() - 2 : room.floorY() + 1, + room.minZ() + room.depth() / 2); + } + @Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) { + tag.put("SanctuaryRoom", encodeRoom(room)); + } + public static CompoundTag encodeRoom(RuinsPlan17.Room room) { + var tag = new CompoundTag(); tag.putInt("Schema", 1); tag.putString("Kind", room.kind().name()); + tag.putInt("MinX", room.minX()); tag.putInt("MinY", room.minY()); tag.putInt("MinZ", room.minZ()); + tag.putInt("Width", room.width()); tag.putInt("Height", room.height()); tag.putInt("Depth", room.depth()); + tag.putInt("Openings", room.openings()); tag.putLong("RoomSeed", room.seed()); + var supports = new ListTag(); + for (var support : room.supports()) { + var value = new CompoundTag(); value.putInt("X", support.x()); value.putInt("MinY", support.minY()); + value.putInt("Z", support.z()); value.putInt("MaxY", support.maxY()); supports.add(value); + } + tag.put("Supports", supports); + return tag; + } + public static RuinsPlan17.Room decodeRoom(CompoundTag tag) { + if (!tag.keySet().equals(ROOM_KEYS)) throw new IllegalArgumentException("Incomplete or unknown forgotten room fields"); + if (integer(tag, "Schema") != 1) throw new IllegalArgumentException("Unsupported forgotten room schema"); + var kind = RuinsPlan17.Kind.valueOf(tag.getString("Kind").orElseThrow(() -> new IllegalArgumentException("Missing room kind"))); + type(tag, "RoomSeed", Tag.TAG_LONG); type(tag, "Supports", Tag.TAG_LIST); + var saved = tag.getList("Supports").orElseThrow(); + if (saved.size() > 64) throw new IllegalArgumentException("Too many saved forgotten room supports"); + var supports = new ArrayList(); + for (var entry : saved) { + if (!(entry instanceof CompoundTag value) || !value.keySet().equals(SUPPORT_KEYS)) + throw new IllegalArgumentException("Invalid forgotten room support fields"); + supports.add(new RuinsPlan17.Support(integer(value, "X"), integer(value, "MinY"), integer(value, "Z"), integer(value, "MaxY"))); + } + return new RuinsPlan17.Room(kind, integer(tag, "MinX"), integer(tag, "MinY"), integer(tag, "MinZ"), + integer(tag, "Width"), integer(tag, "Height"), integer(tag, "Depth"), integer(tag, "Openings"), + tag.getLong("RoomSeed").orElseThrow(), supports); + } + private static int integer(CompoundTag tag, String key) { + type(tag, key, Tag.TAG_INT); + return tag.getInt(key).orElseThrow(); + } + private static void type(CompoundTag tag, String key, byte id) { + if (tag.get(key) == null || tag.get(key).getId() != id) + throw new IllegalArgumentException("Invalid saved forgotten room field: " + key); + } + @Override public void postProcess(WorldGenLevel level, StructureManager manager, ChunkGenerator generator, + RandomSource random, BoundingBox clip, ChunkPos chunk, BlockPos pivot) { + RuinsStructures17.placing(() -> RuinsRenderer17.render(room, new LostCityRenderer.BlockWriter() { + private BlockPos world(int x, int y, int z) { return new BlockPos(room.minX() + x, room.minY() + y, room.minZ() + z); } + @Override public void set(int x, int y, int z, BlockState block) { + var position = world(x, y, z); + if (getBoundingBox().isInside(position) && clip.isInside(position)) + placeBlock(level, block, position.getX(), position.getY(), position.getZ(), clip); + } + @Override public void chest(int x, int y, int z, ResourceKey loot, long seed) { + var position = world(x, y, z); + if (getBoundingBox().isInside(position) && clip.isInside(position)) + createChest(level, clip, RandomSource.create(seed), position, loot, null); + } + })); + } + public static BoundingBox bounds(RuinsPlan17.Room room) { + int minY = room.minY(), maxY = Math.addExact(room.minY(), room.height() - 1); + for (var support : room.supports()) { + minY = Math.min(minY, support.minY()); maxY = Math.max(maxY, support.maxY()); + } + return new BoundingBox(room.minX(), minY, room.minZ(), Math.addExact(room.minX(), room.width() - 1), + maxY, Math.addExact(room.minZ(), room.depth() - 1)); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsPlan17.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsPlan17.java new file mode 100644 index 0000000..c8d35f3 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsPlan17.java @@ -0,0 +1,123 @@ +package fr.koka.sanctuary.worldgen.ruins; + +import java.util.HashSet; +import java.util.List; + +/** Immutable generation17 experiment. Native pieces persist every room and its bounded footing. */ +public record RuinsPlan17(List networks, int samples) { + public static final int MAX_SAMPLES = 250_000; + public static final RuinsPlan17 EMPTY = new RuinsPlan17(List.of(), 0); + public enum Family { MINE, STRANGE, INFRASTRUCTURE } + public enum Kind { ACCESS, CORRIDOR, MINE_GALLERY, WORKSHOP, STORE, + TILED_HALL, ARCHIVE, SERVICE_HALL, PUMP_HALL, CONTROL_ROOM } + + /** One-block architectural pier. Its immediately lower block remains native solid rock. */ + public record Support(int x, int minY, int z, int maxY) { + public Support { + if (minY < 9 || maxY >= 380 || minY > maxY || maxY - minY >= 12) + throw new IllegalArgumentException("Invalid generation17 ruins support"); + } + } + + /** Openings N=1,E=2,S=4,W=8: centered three wide, local y2..4. Floor is local y1. + * Adjacent bodies never overlap, including their opened boundary walls. */ + public record Room(Kind kind, int minX, int minY, int minZ, int width, int height, int depth, + int openings, long seed, List supports) { + public Room { + supports = List.copyOf(supports); + if (kind == null || width < 5 || width > 17 || depth < 5 || depth > 17 + || (width & 1) == 0 || (depth & 1) == 0 || height < 6 || height > 40 + || minY < 10 || minY + height > 383 || openings < 0 || openings > 15 || supports.size() > 9) + throw new IllegalArgumentException("Invalid generation17 ruins room"); + if (kind == Kind.ACCESS && (width != 5 || depth != 5)) + throw new IllegalArgumentException("Ruins access must be five by five"); + if (kind != Kind.ACCESS && kind != Kind.CORRIDOR && (width < 9 || depth < 9 || height < 7)) + throw new IllegalArgumentException("Thematic ruins rooms need space for their central passage and furnishings"); + var positions = new HashSet(); + for (var support : supports) if (support.x() < minX || support.x() >= minX + width + || support.z() < minZ || support.z() >= minZ + depth || support.maxY() != minY - 1 + || !positions.add(key(support.x(), support.z()))) + throw new IllegalArgumentException("Ruins support does not meet its own room floor"); + } + public int maxX() { return minX + width - 1; } + public int maxY() { return minY + height - 1; } + public int maxZ() { return minZ + depth - 1; } + public int centerX() { return minX + width / 2; } + public int centerZ() { return minZ + depth / 2; } + public int floorY() { return minY + 1; } + public int boundsMinY() { return supports.stream().mapToInt(Support::minY).min().orElse(minY); } + public int boundsMaxY() { return maxY(); } + public boolean contains(int x, int y, int z) { + if (x < minX || x > maxX() || z < minZ || z > maxZ()) return false; + if (y >= minY && y <= maxY()) return true; + for (var support : supports) if (support.x() == x && support.z() == z && y >= support.minY() && y <= support.maxY()) return true; + return false; + } + public boolean intersects(int x0, int y0, int z0, int x1, int y1, int z1) { + if (x0 > maxX() || x1 < minX || z0 > maxZ() || z1 < minZ) return false; + if (y0 <= maxY() && y1 >= minY) return true; + for (var support : supports) if (support.x() >= x0 && support.x() <= x1 && support.z() >= z0 && support.z() <= z1 + && support.minY() <= y1 && support.maxY() >= y0) return true; + return false; + } + public Room withOpening(int direction) { + if (direction < 0 || direction > 3) throw new IllegalArgumentException("Invalid opening direction"); + return new Room(kind, minX, minY, minZ, width, height, depth, openings | 1 << direction, seed, supports); + } + public Room withSupports(List values) { + return new Room(kind, minX, minY, minZ, width, height, depth, openings, seed, values); + } + } + + public record Network(String id, Family family, int startChunkX, int startChunkZ, List rooms) { + public Network { + rooms = List.copyOf(rooms); + if (id == null || !id.matches("[a-z][a-z0-9_]{0,31}") || family == null || rooms.size() < 3 || rooms.size() > 6 + || rooms.stream().filter(room -> room.kind() == Kind.ACCESS).count() != 1) + throw new IllegalArgumentException("Invalid generation17 ruins network"); + for (var room : rooms) if (Math.abs((room.minX() >> 4) - startChunkX) > 7 + || Math.abs((room.maxX() >> 4) - startChunkX) > 7 + || Math.abs((room.minZ() >> 4) - startChunkZ) > 7 || Math.abs((room.maxZ() >> 4) - startChunkZ) > 7) + throw new IllegalArgumentException("Ruins network exceeds native reference range"); + } + } + + public RuinsPlan17 { + networks = List.copyOf(networks); + if (networks.size() > 3 || samples < 0 || samples > MAX_SAMPLES) + throw new IllegalArgumentException("Invalid generation17 ruins planning budget"); + var ids = new HashSet(); + for (var network : networks) if (!ids.add(network.id())) throw new IllegalArgumentException("Duplicate ruins network id"); + } + public boolean isEmpty() { return networks.isEmpty(); } + public boolean contains(int x, int y, int z) { + for (var network : networks) for (var room : network.rooms()) if (room.contains(x, y, z)) return true; + return false; + } + public boolean intersects(int x0, int y0, int z0, int x1, int y1, int z1) { + if (x0 > x1 || y0 > y1 || z0 > z1) throw new IllegalArgumentException("Reversed ruins bounds"); + for (var network : networks) for (var room : network.rooms()) if (room.intersects(x0, y0, z0, x1, y1, z1)) return true; + return false; + } + /** Construction plus the unpainted native rock which carries its floors and short piers. */ + public boolean protects(int x, int y, int z) { + if (contains(x, y, z)) return true; + for (var network : networks) for (var room : network.rooms()) { + if (x >= room.minX() && x <= room.maxX() && z >= room.minZ() && z <= room.maxZ() + && y >= room.minY() - 2 && y < room.minY()) return true; + for (var support : room.supports()) if (x == support.x() && z == support.z() && y == support.minY() - 1) return true; + } + return false; + } + public boolean intersectsProtection(int x0, int y0, int z0, int x1, int y1, int z1) { + if (intersects(x0, y0, z0, x1, y1, z1)) return true; + for (var network : networks) for (var room : network.rooms()) { + if (x0 <= room.maxX() && x1 >= room.minX() && z0 <= room.maxZ() && z1 >= room.minZ() + && y0 < room.minY() && y1 >= room.minY() - 2) return true; + for (var support : room.supports()) if (support.x() >= x0 && support.x() <= x1 && support.z() >= z0 && support.z() <= z1 + && support.minY() - 1 >= y0 && support.minY() - 1 <= y1) return true; + } + return false; + } + private static long key(int x, int z) { return (long) x << 32 ^ z & 0xffffffffL; } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsPlanner17.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsPlanner17.java new file mode 100644 index 0000000..7492b30 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsPlanner17.java @@ -0,0 +1,217 @@ +package fr.koka.sanctuary.worldgen.ruins; + +import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.Family; +import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.Kind; +import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.Network; +import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.Room; +import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.Support; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** Opt-in generation17 prototype. Three independently budgeted families have discoverable + * surface shafts and compact, connected interiors. No existing chunk or world state is read. + * Families remain separate in this prototype; no speculative cross-network door is published. */ +public final class RuinsPlanner17 { + public static final int MAX_COLUMNS = 5_000; + public static final int MAX_SAMPLES = RuinsPlan17.MAX_SAMPLES; + private static final int INVALID = Integer.MIN_VALUE; + private static final int[] DX = {0, 1, 0, -1}, DZ = {-1, 0, 1, 0}; + private RuinsPlanner17() {} + + public interface Terrain { + boolean owns(int x, int z); + boolean solid(int x, int y, int z); + boolean protectedAt(int x, int y, int z); + /** Highest dry solid block; -1 when the column has no accessible surface. */ + int surfaceY(int x, int z); + } + + public static RuinsPlan17 plan(long seed, int centerX, int centerZ, int diameter, Terrain terrain) { + if (diameter < 64 || diameter > 1024) throw new IllegalArgumentException("Unsupported ruins island size"); + var probe = new Probe(terrain, centerX, centerZ, diameter); + var networks = new ArrayList(); + for (var family : Family.values()) { + int remaining = Family.values().length - family.ordinal() - 1; + probe.columnLimit = MAX_COLUMNS - remaining * (MAX_COLUMNS / 3); + probe.sampleLimit = MAX_SAMPLES - remaining * (MAX_SAMPLES / 3); + Network best = null; + try { + search: for (int attempt = 0; attempt < 6; attempt++) { + Site site = site(seed, family, attempt, centerX, centerZ, diameter, probe); + if (site == null) continue; + for (int drop : new int[] {14, 22, 30}) { + Network candidate = network(seed, family, site, drop, networks, probe); + if (candidate != null && (best == null || candidate.rooms().size() > best.rooms().size())) best = candidate; + if (best != null && best.rooms().size() == 5) break search; + } + } + } catch (BudgetExceeded exhausted) { probe.reject(family.name().toLowerCase(Locale.ROOT) + "-budget"); } + if (best != null) networks.add(best); + } + if (Boolean.getBoolean("sanctuary.test.ruins17") || Boolean.getBoolean("sanctuary.ruins.plannerDiagnostics")) + System.getLogger(RuinsPlanner17.class.getName()).log(System.Logger.Level.INFO, + "Ruins17 seed=" + seed + " diameter=" + diameter + " networks=" + + networks.stream().map(n -> n.family() + ":" + n.rooms().size()).toList() + + " columns=" + probe.columns.size() + " samples=" + probe.samples + " rejected=" + probe.rejections); + return new RuinsPlan17(networks, probe.samples); + } + + private static Site site(long seed, Family family, int attempt, int cx, int cz, int diameter, Probe probe) { + long salt = mix(seed ^ family.ordinal() * 0x9E3779B97F4A7C15L ^ attempt * 0xD1342543DE82EF95L ^ 0x1700L); + double base = unit(mix(seed ^ 0x17BA5EL)) * Math.PI * 2 + family.ordinal() * Math.PI * 2 / 3; + double angle = base + new double[] {0, .30, -.30, .48, -.48, .72}[attempt]; + double radius = Math.min(diameter * .27, 216) * (attempt == 3 || attempt == 4 ? .72 : 1); + int x = cx + (int) Math.round(Math.cos(angle) * radius), z = cz + (int) Math.round(Math.sin(angle) * radius); + int low = 384, high = -1; + // Five coarse witnesses reject cliffs, water and unowned corners before a volume scan. + for (int[] offset : new int[][] {{-2, -2}, {-2, 2}, {2, -2}, {2, 2}, {0, 0}}) { + int px = x + offset[0], pz = z + offset[1], top = probe.surface(px, pz); + if (top < 54 || top > 376 || !probe.solid(px, top, pz) || probe.protectedAt(px, top, pz)) { + probe.reject("coarse-entrance"); return null; + } + low = Math.min(low, top); high = Math.max(high, top); + } + if (high - low > 4) { probe.reject("entrance-cliff"); return null; } + int direction = Math.abs(x - cx) >= Math.abs(z - cz) ? x < cx ? 1 : 3 : z < cz ? 2 : 0; + if ((salt & 1) != 0) direction = (direction + (attempt % 2 == 0 ? 1 : 3)) & 3; + return new Site(x, z, high, direction, salt); + } + + private static Network network(long seed, Family family, Site site, int drop, List previous, Probe probe) { + int top = site.top(); + for (int z = site.z() - 2; z <= site.z() + 2; z++) for (int x = site.x() - 2; x <= site.x() + 2; x++) { + int height = probe.surface(x, z); + if (height < site.top() - 4 || height > site.top() + 4) { probe.reject("entrance-surface"); return null; } + top = Math.max(top, height); + } + int floor = top - drop; + Room access = new Room(Kind.ACCESS, site.x() - 2, floor - 1, site.z() - 2, + 5, drop + 4, 5, 0, mix(seed ^ site.seed() ^ drop), List.of()); + var rooms = new ArrayList(); + access = certify(access, rooms, previous, probe); + if (access == null) return null; + rooms.add(access); + if (!attach(rooms, 0, Kind.CORRIDOR, site.direction(), mix(site.seed() ^ drop ^ 0xC0L), previous, probe)) return null; + Kind[] kinds = switch (family) { + case MINE -> new Kind[] {Kind.MINE_GALLERY, Kind.WORKSHOP, Kind.STORE}; + case STRANGE -> new Kind[] {Kind.TILED_HALL, Kind.ARCHIVE, Kind.STORE}; + case INFRASTRUCTURE -> new Kind[] {Kind.SERVICE_HALL, Kind.PUMP_HALL, Kind.CONTROL_ROOM}; + }; + if (!attach(rooms, 1, kinds[0], site.direction(), mix(site.seed() ^ drop ^ 0xA1L), previous, probe)) return null; + // Keep already connected rooms when an optional branch encounters protected terrain. + int side = (site.direction() + ((site.seed() & 2) == 0 ? 1 : 3)) & 3; + try { + attach(rooms, 2, kinds[1], side, mix(site.seed() ^ drop ^ 0xB2L), previous, probe); + attach(rooms, 2, kinds[2], site.direction(), mix(site.seed() ^ drop ^ 0xC3L), previous, probe); + } catch (BudgetExceeded exhausted) { probe.reject("optional-branch-budget"); } + return new Network(family.name().toLowerCase(Locale.ROOT), family, site.x() >> 4, site.z() >> 4, rooms); + } + + private static boolean attach(List rooms, int parentIndex, Kind kind, int direction, long seed, + List previous, Probe probe) { + Room parent = rooms.get(parentIndex); + int width = kind == Kind.CORRIDOR ? 5 : kind == Kind.MINE_GALLERY ? 13 : 9; + int depth = kind == Kind.CORRIDOR ? 5 : 9, height = kind == Kind.CORRIDOR ? 6 : 7; + int x = parent.centerX() - width / 2, z = parent.centerZ() - depth / 2; + if (direction == 0) z = parent.minZ() - depth; + if (direction == 1) x = parent.maxX() + 1; + if (direction == 2) z = parent.maxZ() + 1; + if (direction == 3) x = parent.minX() - width; + Room room = new Room(kind, x, parent.minY(), z, width, height, depth, 1 << ((direction + 2) & 3), seed, List.of()); + room = certify(room, rooms, previous, probe); + if (room == null) return false; + rooms.set(parentIndex, parent.withOpening(direction)); rooms.add(room); + return true; + } + + private static Room certify(Room draft, List siblings, List previous, Probe probe) { + for (Room room : siblings) if (conflicts(room, draft)) { probe.reject("sibling-overlap"); return null; } + for (Network network : previous) for (Room room : network.rooms()) if (conflicts(room, draft)) { + probe.reject("network-overlap"); return null; + } + for (int z = draft.minZ(); z <= draft.maxZ(); z++) for (int x = draft.minX(); x <= draft.maxX(); x++) { + int top = probe.surface(x, z); + if (top == INVALID || top < draft.minY() + 5 || draft.kind() != Kind.ACCESS && top < draft.maxY() + 1) { + probe.reject("room-exposed"); return null; + } + for (int y = draft.minY(); y <= draft.maxY(); y++) if (probe.protectedAt(x, y, z)) { + probe.reject("room-protected"); return null; + } + } + var supports = new ArrayList(); + for (int z : new int[] {draft.minZ() + 1, draft.centerZ(), draft.maxZ() - 1}) + for (int x : new int[] {draft.minX() + 1, draft.centerX(), draft.maxX() - 1}) { + int bottom = draft.minY() - 1; + if (probe.solid(x, bottom, z) && probe.solid(x, bottom - 1, z)) { + if (probe.protectedAt(x, bottom, z) || probe.protectedAt(x, bottom - 1, z)) { probe.reject("footing-protected"); return null; } + continue; + } + int rock = -1; + for (int y = bottom - 1; y >= Math.max(9, draft.minY() - 13); y--) + if (probe.solid(x, y, z) && probe.solid(x, y - 1, z)) { rock = y; break; } + if (rock < 0) { probe.reject("footing-void"); return null; } + for (int y = rock; y <= bottom; y++) if (probe.protectedAt(x, y, z)) { probe.reject("pier-protected"); return null; } + supports.add(new Support(x, rock + 1, z, bottom)); + } + Room result = draft.withSupports(supports); + for (Room sibling : siblings) if (conflicts(sibling, result)) { probe.reject("pier-sibling"); return null; } + for (Network network : previous) for (Room room : network.rooms()) if (conflicts(room, result)) { + probe.reject("pier-network"); return null; + } + return result; + } + + private static boolean conflicts(Room a, Room b) { + if (protectsBounds(a, b.minX(), b.minY(), b.minZ(), b.maxX(), b.maxY(), b.maxZ()) + || protectsBounds(b, a.minX(), a.minY(), a.minZ(), a.maxX(), a.maxY(), a.maxZ())) return true; + for (var support : b.supports()) if (protectsBounds(a, support.x(), support.minY(), support.z(), support.x(), support.maxY(), support.z())) return true; + for (var support : a.supports()) if (protectsBounds(b, support.x(), support.minY(), support.z(), support.x(), support.maxY(), support.z())) return true; + return false; + } + private static boolean protectsBounds(Room room, int x0, int y0, int z0, int x1, int y1, int z1) { + if (room.intersects(x0, y0, z0, x1, y1, z1)) return true; + if (x0 <= room.maxX() && x1 >= room.minX() && z0 <= room.maxZ() && z1 >= room.minZ() + && y0 < room.minY() && y1 >= room.minY() - 2) return true; + for (var support : room.supports()) if (support.x() >= x0 && support.x() <= x1 && support.z() >= z0 && support.z() <= z1 + && support.minY() - 1 >= y0 && support.minY() - 1 <= y1) return true; + return false; + } + private record Site(int x, int z, int top, int direction, long seed) {} + private static final class BudgetExceeded extends RuntimeException {} + private static final class Probe { + final Terrain terrain; final int centerX, centerZ; final double radius; + final Map columns = new HashMap<>(); + final Map rejections = new java.util.TreeMap<>(); + int samples, columnLimit, sampleLimit; + Probe(Terrain terrain, int centerX, int centerZ, int diameter) { + this.terrain = java.util.Objects.requireNonNull(terrain); this.centerX = centerX; this.centerZ = centerZ; + radius = diameter / 2.0 - 3; + } + int surface(int x, int z) { + if (Math.hypot((double) x - centerX, (double) z - centerZ) > radius || !terrain.owns(x, z)) return INVALID; + long key = (long) x << 32 ^ z & 0xffffffffL; + var cached = columns.get(key); if (cached != null) return cached; + if (columns.size() >= columnLimit) throw new BudgetExceeded(); + count(); int top = terrain.surfaceY(x, z); columns.put(key, top); return top; + } + boolean solid(int x, int y, int z) { + if (y < 0 || y >= 384 || surface(x, z) == INVALID) return false; + count(); return terrain.solid(x, y, z); + } + boolean protectedAt(int x, int y, int z) { + if (y < 0 || y >= 384 || surface(x, z) == INVALID) return true; + count(); return terrain.protectedAt(x, y, z); + } + void count() { if (samples >= sampleLimit) throw new BudgetExceeded(); samples++; } + void reject(String reason) { rejections.merge(reason, 1, Integer::sum); } + } + private static double unit(long value) { return (value >>> 11) * 0x1.0p-53; } + private static long mix(long value) { + value = (value ^ value >>> 30) * 0xbf58476d1ce4e5b9L; + value = (value ^ value >>> 27) * 0x94d049bb133111ebL; + return value ^ value >>> 31; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsRenderer17.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsRenderer17.java new file mode 100644 index 0000000..3a2e198 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsRenderer17.java @@ -0,0 +1,277 @@ +package fr.koka.sanctuary.worldgen.ruins; + +import fr.koka.sanctuary.worldgen.city.LostCityPalette; +import fr.koka.sanctuary.worldgen.city.LostCityRenderer; +import java.util.ArrayList; +import java.util.List; +import net.minecraft.core.Direction; +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.AttachFace; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.storage.loot.BuiltInLootTables; + +/** Generation17 experiments: original, physical rooms reconstructed solely from their saved inputs. + * Coordinates given to the writer are local to the saved room. There are no terrain reads, entities, + * active machines or free fluids. Openings are restored last so connected pieces share dry thresholds. */ +public final class RuinsRenderer17 { + private static final Direction[] DIRECTIONS = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST}; + private RuinsRenderer17() {} + + /** Local standing positions for native path checks, including approaches to useful fixtures. */ + public record Target(String name, int x, int y, int z) {} + + public static List targets(RuinsPlan17.Room room) { + var targets = new ArrayList(); + if (room.kind() == RuinsPlan17.Kind.ACCESS) { + targets.add(new Target("surface_hatch_approach", room.width() / 2, room.height() - 2, room.depth() / 2)); + targets.add(new Target("shaft_bottom", room.width() / 2, 2, room.depth() / 2)); + } else { + targets.add(new Target("central_crossing", room.width() / 2, 2, room.depth() / 2)); + switch (room.kind()) { + case WORKSHOP -> { + targets.add(decorTarget(room, "workbench", 2, 2, 2)); + targets.add(decorTarget(room, "unfinished_work_platform", room.width() - 3, 4, room.depth() - 3)); + } + case STORE -> targets.add(decorTarget(room, "supply_chest", room.width() - 3, 2, room.depth() - 2)); + case ARCHIVE -> targets.add(decorTarget(room, "reading_desk", 2, 2, 2)); + case PUMP_HALL, CONTROL_ROOM -> targets.add(decorTarget(room, "controls", 2, 2, 2)); + default -> { } + } + } + for (int direction = 0; direction < 4; direction++) if ((room.openings() & 1 << direction) != 0) { + int x = direction == 1 ? room.width() - 1 : direction == 3 ? 0 : room.width() / 2; + int z = direction == 2 ? room.depth() - 1 : direction == 0 ? 0 : room.depth() / 2; + targets.add(new Target("opening_" + DIRECTIONS[direction].getName(), x, 2, z)); + } + return List.copyOf(targets); + } + + public static void render(RuinsPlan17.Room room, LostCityRenderer.BlockWriter output) { + var b = new Builder(room, output); + b.shell(); + switch (room.kind()) { + case ACCESS -> b.access(); + case CORRIDOR -> b.corridor(); + case MINE_GALLERY -> b.mine(); + case WORKSHOP -> b.workshop(); + case STORE -> b.store(); + case TILED_HALL -> b.tiledHall(); + case ARCHIVE -> b.archive(); + case SERVICE_HALL -> b.service(); + case PUMP_HALL -> b.pumps(); + case CONTROL_ROOM -> b.controls(); + } + if (room.kind() != RuinsPlan17.Kind.ACCESS) b.lights(); + b.openings(); + for (var support : room.supports()) for (int y = support.minY(); y <= support.maxY(); y++) + output.set(support.x() - room.minX(), y - room.minY(), support.z() - room.minZ(), + y == support.minY() ? Blocks.SMOOTH_STONE.defaultBlockState() : Blocks.STONE_BRICKS.defaultBlockState()); + } + + private static boolean turn(RuinsPlan17.Room room) { return (room.seed() & 1L) != 0; } + private static Target decorTarget(RuinsPlan17.Room room, String name, int x, int y, int z) { + return new Target(name, turn(room) ? room.width() - 1 - x : x, y, turn(room) ? room.depth() - 1 - z : z); + } + + private static final class Builder { + final RuinsPlan17.Room room; + final LostCityRenderer.BlockWriter out; + final int w, h, d, cx, cz; + final boolean reverse; + Builder(RuinsPlan17.Room room, LostCityRenderer.BlockWriter out) { + this.room = room; this.out = out; w = room.width(); h = room.height(); d = room.depth(); + cx = w / 2; cz = d / 2; reverse = turn(room); + } + void set(int x, int y, int z, BlockState state) { out.set(x, y, z, state); } + void set(int x, int y, int z, net.minecraft.world.level.block.Block block) { set(x, y, z, block.defaultBlockState()); } + void decor(int x, int y, int z, BlockState state) { + out.set(reverse ? w - 1 - x : x, y, reverse ? d - 1 - z : z, + reverse ? state.rotate(Rotation.CLOCKWISE_180) : state); + } + void decor(int x, int y, int z, net.minecraft.world.level.block.Block block) { decor(x, y, z, block.defaultBlockState()); } + boolean crossing(int x, int z) { return Math.abs(x - cx) <= 1 || Math.abs(z - cz) <= 1; } + + void shell() { + for (int y = 0; y < h; y++) for (int z = 0; z < d; z++) for (int x = 0; x < w; x++) { + BlockState state; + boolean wall = x == 0 || x == w - 1 || z == 0 || z == d - 1; + if (y == 0) state = Blocks.STONE.defaultBlockState(); + else if (y == 1) state = floor(x, z); + else if (wall || y == h - 1) state = LostCityPalette.masonry(room.seed(), x, y, z); + else state = Blocks.AIR.defaultBlockState(); + set(x, y, z, state); + } + // A continuous ceiling cornice and stone thresholds remain intact despite the weathering. + for (int x = 0; x < w; x++) for (int z = 0; z < d; z++) if (x == 0 || x == w - 1 || z == 0 || z == d - 1) + set(x, h - 2, z, Blocks.SMOOTH_STONE); + } + BlockState floor(int x, int z) { + return switch (room.kind()) { + case MINE_GALLERY -> (x == cx ? Blocks.DARK_OAK_PLANKS : (x + z) % 5 == 0 ? Blocks.ANDESITE : Blocks.STONE).defaultBlockState(); + case WORKSHOP, STORE -> (x == 1 || x == w - 2 || z == 1 || z == d - 2 + ? Blocks.DARK_OAK_PLANKS : Blocks.SMOOTH_STONE).defaultBlockState(); + case TILED_HALL, ARCHIVE -> ((x / 2 + z / 2) % 2 == 0 ? Blocks.CALCITE : Blocks.POLISHED_ANDESITE).defaultBlockState(); + case SERVICE_HALL, PUMP_HALL, CONTROL_ROOM -> (x == cx || z == cz ? Blocks.POLISHED_ANDESITE : Blocks.SMOOTH_STONE).defaultBlockState(); + default -> LostCityPalette.pavement(room.seed(), x, z); + }; + } + + void access() { + int exit = Integer.numberOfTrailingZeros(room.openings() == 0 ? 1 : room.openings()); + int wall = (exit + 2) & 3; + int x = wall == 1 ? w - 2 : wall == 3 ? 1 : cx; + int z = wall == 2 ? d - 2 : wall == 0 ? 1 : cz; + Direction facing = DIRECTIONS[wall].getOpposite(); + if ((room.openings() & 1 << wall) != 0) { + // A branching shaft cannot attach its lowest ladder to another open doorway. + // This corner post leaves all three doorway cells and the central crossing usable. + x = 1; z = 2; facing = Direction.SOUTH; + for (int y = 2; y <= h - 3; y++) set(1, y, 1, Blocks.STONE_BRICKS); + } + BlockState ladder = Blocks.LADDER.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, facing); + for (int y = 2; y <= h - 3; y++) set(x, y, z, ladder); + for (int zz = 0; zz < d; zz++) for (int xx = 0; xx < w; xx++) { + set(xx, h - 3, zz, xx == x && zz == z ? ladder : Blocks.SMOOTH_STONE.defaultBlockState()); + set(xx, h - 2, zz, Blocks.AIR); set(xx, h - 1, zz, Blocks.AIR); + } + set(x, h - 2, z, Blocks.DARK_OAK_TRAPDOOR.defaultBlockState() + .setValue(BlockStateProperties.HORIZONTAL_FACING, facing)); + } + void corridor() { + boolean alongX = w > d; + int length = alongX ? w : d; + for (int n = 2; n < length - 1; n += 4) { + if (alongX) { + for (int y = 2; y <= h - 1; y++) { set(n, y, 0, Blocks.POLISHED_ANDESITE); set(n, y, d - 1, Blocks.POLISHED_ANDESITE); } + } else for (int y = 2; y <= h - 1; y++) { set(0, y, n, Blocks.POLISHED_ANDESITE); set(w - 1, y, n, Blocks.POLISHED_ANDESITE); } + } + } + void mine() { + for (int z : new int[] {2, d - 3}) { + for (int x : new int[] {2, w - 3}) for (int y = 2; y <= h - 2; y++) decor(x, y, z, Blocks.STRIPPED_OAK_LOG); + for (int x = 2; x <= w - 3; x++) decor(x, h - 2, z, Blocks.STRIPPED_OAK_LOG.defaultBlockState() + .setValue(BlockStateProperties.AXIS, Direction.Axis.X)); + } + for (int z = 1; z < d - 1; z++) set(cx, 2, z, Blocks.RAIL); + // A short second siding ends at a buffer; the main track and cross passage remain open. + if (w >= 13) { + for (int z = 3; z < d - 2; z++) decor(w - 5, 2, z, Blocks.RAIL); + decor(w - 5, 2, d - 2, Blocks.DARK_OAK_FENCE); + } + decor(1, 2, 1, Blocks.BARREL); + decor(w - 2, 2, d - 2, Blocks.ANDESITE); + if (LostCityPalette.roll(room.seed(), 1, 0, 0, 2) == 0) decor(w - 2, 3, d - 2, Blocks.COBBLESTONE); + } + void workshop() { + decor(1, 2, 1, Blocks.CRAFTING_TABLE); + decor(2, 2, 1, Blocks.FURNACE.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.SOUTH)); + decor(1, 2, d - 3, Blocks.GRINDSTONE.defaultBlockState() + .setValue(BlockStateProperties.ATTACH_FACE, AttachFace.FLOOR) + .setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.EAST)); + decor(w - 2, 2, 1, Blocks.STONECUTTER.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.WEST)); + decor(1, 2, d - 2, Blocks.CAULDRON); + decor(2, 2, d - 2, Blocks.ANVIL); + // Two grounded posts and a small planked deck preserve the trace of unfinished repairs. + for (int x = w - 3; x <= w - 2; x++) { + decor(x, 2, d - 3, Blocks.STRIPPED_OAK_LOG); + for (int z = d - 3; z <= d - 2; z++) decor(x, 3, z, Blocks.DARK_OAK_PLANKS); + } + for (int y = 2; y <= 3; y++) decor(w - 3, y, d - 4, Blocks.LADDER.defaultBlockState() + .setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.NORTH)); + decor(w - 2, 2, d - 2, Blocks.BARREL); + } + void store() { + for (int z = 1; z < d - 1; z++) if (!crossing(1, z)) { + decor(1, 2, z, Blocks.BARREL); + if ((z & 1) == 0) decor(1, 3, z, Blocks.BARREL); + decor(1, 4, z, Blocks.DARK_OAK_SLAB); + } + for (int x = cx + 2; x <= w - 2; x++) decor(x, 2, 1, Blocks.DARK_OAK_PLANKS); + decor(w - 2, 3, 1, Blocks.BARREL); + loot(w - 2, 2, d - 2, BuiltInLootTables.VILLAGE_PLAINS_HOUSE); + } + void tiledHall() { + // Repeated columns are interrupted by complete, centered cross passages. + for (int x : new int[] {2, w - 3}) for (int z : new int[] {2, d - 3}) + for (int y = 2; y < h - 1; y++) decor(x, y, z, y == 2 || y == h - 2 ? Blocks.SMOOTH_STONE : Blocks.CALCITE); + if (w >= 13) for (int x : new int[] {3, w - 4}) for (int z = 1; z <= d - 2; z++) + if (Math.abs(z - cz) > 1) for (int y = 2; y < h - 1; y++) + decor(x, y, z, y == h - 2 ? Blocks.POLISHED_ANDESITE : Blocks.SMOOTH_STONE); + for (int x = cx - 1; x <= cx + 1; x++) { + decor(x, h - 2, 1, Blocks.POLISHED_ANDESITE); + decor(x, h - 2, d - 2, Blocks.POLISHED_ANDESITE); + } + // Mismatched surviving benches give these physically reachable side niches a former use. + decor(1, 2, 1, seat(Direction.WEST)); + decor(w - 2, 2, 1, seat(Direction.EAST)); + } + void archive() { + for (int z = 1; z <= d - 2; z++) if (Math.abs(z - cz) > 1) + for (int y = 2; y <= 4; y++) decor(w - 2, y, z, + y == 4 && (z & 1) == 0 ? Blocks.DARK_OAK_PLANKS : Blocks.BOOKSHELF); + decor(1, 2, 1, Blocks.BOOKSHELF); + decor(1, 2, 2, Blocks.LECTERN.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.EAST)); + decor(1, 2, d - 2, Blocks.CARTOGRAPHY_TABLE); + decor(2, 2, d - 3, Blocks.BARREL); + loot(1, 2, d - 3, BuiltInLootTables.VILLAGE_CARTOGRAPHER); + } + void service() { + for (int z = 1; z < d - 1; z++) if (Math.abs(z - cz) > 1) { + decor(1, 2, z, Blocks.POLISHED_ANDESITE); + decor(1, 3, z, Blocks.IRON_BARS); + } + for (int z : new int[] {1, d - 2}) for (int x = 1; x < w - 1; x++) + decor(x, h - 2, z, Blocks.STRIPPED_OAK_LOG.defaultBlockState().setValue(BlockStateProperties.AXIS, Direction.Axis.X)); + decor(w - 2, 2, 1, Blocks.BARREL); + decor(w - 2, 2, d - 2, Blocks.CRAFTING_TABLE); + decor(2, 2, d - 2, seat(Direction.SOUTH)); + } + void pumps() { + for (int z : new int[] {1, d - 2}) { + decor(1, 2, z, Blocks.DISPENSER.defaultBlockState().setValue(BlockStateProperties.FACING, Direction.EAST)); + decor(2, 2, z, Blocks.WATER_CAULDRON.defaultBlockState().setValue(BlockStateProperties.LEVEL_CAULDRON, 3)); + decor(1, 3, z, Blocks.POLISHED_ANDESITE); + decor(1, 4, z, Blocks.IRON_BARS); + decor(w - 2, 2, z, Blocks.CAULDRON); + } + for (int z = 1; z <= d - 2; z++) decor(1, h - 2, z, Blocks.POLISHED_ANDESITE); + decor(w - 2, 2, 2, Blocks.SMOOTH_STONE); + decor(w - 2, 3, 2, Blocks.LEVER.defaultBlockState().setValue(BlockStateProperties.ATTACH_FACE, AttachFace.FLOOR) + .setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.WEST)); + } + void controls() { + for (int x = 1; x <= cx - 2; x++) { + decor(x, 2, 1, Blocks.SMOOTH_STONE); + decor(x, 3, 1, Blocks.LEVER.defaultBlockState().setValue(BlockStateProperties.ATTACH_FACE, AttachFace.FLOOR) + .setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.SOUTH)); + decor(x, 3, 0, Blocks.REDSTONE_LAMP); + } + decor(1, 2, 2, seat(Direction.WEST)); + decor(w - 2, 2, 1, Blocks.CRAFTING_TABLE); + decor(w - 2, 2, d - 2, Blocks.BARREL); + // An inspection window remains backed by a complete wall: there is no opening into unverified rock or water. + for (int z = cz + 2; z < d - 1; z++) decor(0, 3, z, Blocks.GLASS); + decor(1, 2, d - 2, Blocks.CARTOGRAPHY_TABLE); + } + BlockState seat(Direction back) { return Blocks.DARK_OAK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, back); } + void lights() { + // Every lantern hangs directly from the full masonry ceiling; never from a bottom slab. + for (int[] p : new int[][] {{1, 1}, {w - 2, d - 2}}) + set(p[0], h - 2, p[1], Blocks.LANTERN.defaultBlockState().setValue(BlockStateProperties.HANGING, true)); + } + void openings() { + for (int direction = 0; direction < 4; direction++) if ((room.openings() & 1 << direction) != 0) + for (int side = -1; side <= 1; side++) { + int x = direction == 1 ? w - 1 : direction == 3 ? 0 : cx + side; + int z = direction == 2 ? d - 1 : direction == 0 ? 0 : cz + side; + set(x, 1, z, Blocks.SMOOTH_STONE); + for (int y = 2; y <= 4; y++) set(x, y, z, Blocks.AIR); + } + } + void loot(int x, int y, int z, net.minecraft.resources.ResourceKey table) { + out.chest(reverse ? w - 1 - x : x, y, reverse ? d - 1 - z : z, table, room.seed() ^ 0x17A6C41L); + } + } +} 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 new file mode 100644 index 0000000..0b0338c --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructure17.java @@ -0,0 +1,39 @@ +package fr.koka.sanctuary.worldgen.ruins; + +import com.mojang.serialization.MapCodec; +import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator; +import java.util.Optional; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureType; + +/** One start contains every planned experimental network sharing its origin chunk. */ +public final class RuinsStructure17 extends Structure { + public static final MapCodec CODEC = simpleCodec(RuinsStructure17::new); + public RuinsStructure17(StructureSettings settings) { super(settings); } + + @Override protected Optional findGenerationPoint(GenerationContext context) { + if (!(context.chunkGenerator() instanceof SanctuaryChunkGenerator generator) + || generator.rootGeneration() != 17 || !generator.experimentalStructures()) return Optional.empty(); + var island = generator.islandForChunk(context.chunkPos().x(), context.chunkPos().z()); + if (island == null || !island.origin() || island.generation() != 17) return Optional.empty(); + var networks = generator.ruinsPlan17().networks().stream() + .filter(n -> n.startChunkX() == context.chunkPos().x() && n.startChunkZ() == context.chunkPos().z()).toList(); + if (networks.isEmpty()) return Optional.empty(); + for (var network : networks) for (var room : network.rooms()) { + var bounds = RuinsPiece17.bounds(room); + if (Math.abs((long) Math.floorDiv(bounds.minX(), 16) - network.startChunkX()) > 8 + || Math.abs((long) Math.floorDiv(bounds.maxX(), 16) - network.startChunkX()) > 8 + || Math.abs((long) Math.floorDiv(bounds.minZ(), 16) - network.startChunkZ()) > 8 + || Math.abs((long) Math.floorDiv(bounds.maxZ(), 16) - network.startChunkZ()) > 8) + throw new IllegalStateException("Forgotten network exceeds native reference range: " + network.id()); + } + 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(new RuinsPiece17(r)))); + networks.forEach(n -> n.rooms().stream().filter(r -> r.kind() == RuinsPlan17.Kind.ACCESS) + .forEach(r -> pieces.addPiece(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 new file mode 100644 index 0000000..4ee6620 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsStructures17.java @@ -0,0 +1,111 @@ +package fr.koka.sanctuary.worldgen.ruins; + +import com.mojang.datafixers.util.Pair; +import fr.koka.sanctuary.SanctuaryMod; +import fr.koka.sanctuary.expansion.ExpansionChunkGenerator; +import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +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.server.level.ServerLevel; +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.chunk.status.ChunkStatus; +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; + +/** Experimental generation17 uses independent native starts, references and saved room pieces. */ +public final class RuinsStructures17 { + public static final ResourceKey KEY = ResourceKey.create(Registries.STRUCTURE, SanctuaryMod.id("forgotten_networks")); + public static final StructureType TYPE = Registry.register(BuiltInRegistries.STRUCTURE_TYPE, + SanctuaryMod.id("forgotten_networks_v17"), () -> RuinsStructure17.CODEC); + public static final StructurePieceType PIECE = Registry.register(BuiltInRegistries.STRUCTURE_PIECE, + SanctuaryMod.id("forgotten_room_v17"), RuinsPiece17::new); + private static final ThreadLocal PLACEMENT_DEPTH = ThreadLocal.withInitial(() -> 0); + + private RuinsStructures17() {} + public static void register() { /* Initialize serializers before the dynamic structure registry is read. */ } + public static boolean isPlacing() { return PLACEMENT_DEPTH.get() > 0; } + static void placing(Runnable renderer) { + int previous = PLACEMENT_DEPTH.get(); + PLACEMENT_DEPTH.set(previous + 1); + try { renderer.run(); } + finally { + if (previous == 0) PLACEMENT_DEPTH.remove(); + else PLACEMENT_DEPTH.set(previous); + } + } + 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() != 17 + || !sanctuary.experimentalStructures()) return; + var island = generator.islandForChunk(chunk.getPos().x(), chunk.getPos().z()); + if (island == null || !island.origin() || island.generation() != 17) return; + var plan = sanctuary.ruinsPlan17(); + if (plan.networks().stream().noneMatch(n -> n.startChunkX() == chunk.getPos().x() + && n.startChunkZ() == chunk.getPos().z())) return; + Holder holder = registries.lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY); + Structure structure = holder.value(); + StructureStart previous = manager.getStartForStructure(structure, chunk); + if (previous != null && previous.isValid()) return; + var random = generator.randomState(island); + var sampler = random.createClimateSampler(SamplerContext.builder().enableCaches().build()); + var start = structure.generate(holder, level, registries, generator, generator.getBiomeSource(), sampler, + random, templates, island.seed(), chunk.getPos(), previous == null ? 0 : previous.getReferences(), + chunk, structure.biomes()::contains); + if (start.isValid()) manager.setStartForStructure(structure, start, chunk); + } + /** Starts are loaded at STRUCTURE_STARTS; locating never creates an expansion or rebuilds an existing start. */ + public static Pair> findNearest(ServerLevel level, HolderSet structures, + BlockPos origin, int radius, boolean skipReferenced) { + if (!Level.OVERWORLD.equals(level.dimension()) + || !(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator) + || generator.rootGeneration() != 17 || !generator.experimentalStructures() + || !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.ruinsPlan17().networks()) { + var island = generator.islandForChunk(network.startChunkX(), network.startChunkZ()); + if (island == null || !island.origin() || island.generation() != 17 || !island.status().equals("ready")) continue; + if (Math.abs((long) network.startChunkX() - originChunkX) <= radius + && Math.abs((long) network.startChunkZ() - originChunkZ) <= radius) candidates.add(network); + } + candidates.sort(Comparator.comparingDouble(n -> origin.distSqr(locator(n)))); + var visited = new HashSet(); + for (var network : candidates) { + long key = ((long) network.startChunkX() << 32) ^ (network.startChunkZ() & 0xffffffffL); + if (!visited.add(key)) continue; + var chunk = level.getChunk(network.startChunkX(), network.startChunkZ(), ChunkStatus.STRUCTURE_STARTS, true); + StructureStart start = level.structureManager().getStartForStructure(holder.value(), chunk); + if (start == null || !start.isValid() || skipReferenced && !start.canBeReferenced()) continue; + if (skipReferenced) level.structureManager().addReference(start); + var entrance = start.getPieces().stream().filter(p -> p instanceof RuinsPiece17 room + && room.room().kind() == RuinsPlan17.Kind.ACCESS).findFirst().orElse(start.getPieces().getFirst()); + return Pair.of(entrance.getLocatorPosition(), holder); + } + return null; + } + static BlockPos locator(RuinsPlan17.Network network) { + var room = network.rooms().stream().filter(r -> r.kind() == RuinsPlan17.Kind.ACCESS).findFirst() + .orElseGet(() -> network.rooms().getFirst()); + return RuinsPiece17.locator(room); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsTerrain17.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsTerrain17.java new file mode 100644 index 0000000..024d1bd --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsTerrain17.java @@ -0,0 +1,60 @@ +package fr.koka.sanctuary.worldgen.ruins; + +import fr.koka.sanctuary.expansion.ExpansionIsland; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Predicate; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.NoiseColumn; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; +import net.minecraft.world.level.levelgen.RandomState; + +/** Read-only, bounded native-noise cache. Unknown columns always reject construction. */ +public final class RuinsTerrain17 implements RuinsPlanner17.Terrain { + public static final int MAX_COLUMNS = 5_000; + private final ExpansionIsland island; + private final NoiseBasedChunkGenerator generator; + private final RandomState random; + private final LevelHeightAccessor height; + private final Predicate protection; + private final Map columns = new HashMap<>(); + + public RuinsTerrain17(ExpansionIsland island, NoiseBasedChunkGenerator generator, RandomState random, + LevelHeightAccessor height, Predicate protection) { + this.island = island; this.generator = generator; this.random = random; + this.height = height; this.protection = protection; + } + private NoiseColumn column(int x, int z) { + if (!owns(x, z)) return null; + long key = ((long) x << 32) ^ (z & 0xffffffffL); + var found = columns.get(key); + if (found != null) return found; + if (columns.size() >= MAX_COLUMNS) return null; + var result = generator.getBaseColumn(x, z, height, random); + columns.put(key, result); + return result; + } + @Override public boolean owns(int x, int z) { return island.contains(x, z); } + @Override public boolean solid(int x, int y, int z) { + if (y < 0 || y >= 384) return false; + var column = column(x, z); + return column != null && column.getBlock(y).isSolid() && column.getBlock(y).getFluidState().isEmpty(); + } + @Override public boolean protectedAt(int x, int y, int z) { + if (y < 0 || y >= 384) return true; + var column = column(x, z); + return column == null || !column.getBlock(y).getFluidState().isEmpty() + || protection.test(new BlockPos(x, y, z)); + } + @Override public int surfaceY(int x, int z) { + var column = column(x, z); + if (column == null) return -1; + for (int y = 380; y >= 1; y--) { + var state = column.getBlock(y); + if (!state.isAir()) return state.isSolid() && state.getFluidState().isEmpty() ? y : -1; + } + return -1; + } + public int columnsUsed() { return columns.size(); } +} 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 402ec6a..af9bed6 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 @@ -124,5 +124,8 @@ "sanctuary.expansion.error.city_generation_required": "This profile or size requires a generation 14 or 15 Sanctuary world. This save keeps its original generation.", "sanctuary.expansion.reuse_reload": "Expansion %s reserved in previously generated void. Close and reopen the world to begin preparation.", "sanctuary.expansion.error.reload_required": "This expansion is waiting for the world to reopen before reusing verified void chunks.", - "sanctuary.expansion.error.void_reuse_incomplete": "Void reuse is incomplete. The reservation is retained; check the server log." + "sanctuary.expansion.error.void_reuse_incomplete": "Void reuse is incomplete. The reservation is retained; check the server log.", + "sanctuary.world_options.experimental": "Experimental structures", + "sanctuary.world_options.experimental_hint": "Mines, strange rooms and abandoned facilities. This choice is saved with the new world.", + "structure.sanctuary.forgotten_networks": "Forgotten networks" } 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 c512f1d..b9d6a71 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 @@ -124,5 +124,8 @@ "sanctuary.expansion.error.city_generation_required": "Ce profil ou cette taille demande un monde Sanctuary de génération 14 ou 15. Cette sauvegarde conserve sa génération.", "sanctuary.expansion.reuse_reload": "Expansion %s réservée dans du vide déjà généré. Fermez puis rouvrez le monde pour démarrer sa préparation.", "sanctuary.expansion.error.reload_required": "Cette expansion attend la réouverture du monde pour réutiliser les chunks de vide vérifiés.", - "sanctuary.expansion.error.void_reuse_incomplete": "La réutilisation du vide est incomplète. La réservation est conservée ; consultez le journal du serveur." + "sanctuary.expansion.error.void_reuse_incomplete": "La réutilisation du vide est incomplète. La réservation est conservée ; consultez le journal du serveur.", + "sanctuary.world_options.experimental": "Structures expérimentales", + "sanctuary.world_options.experimental_hint": "Mines, salles étranges et installations abandonnées. Ce choix reste attaché au nouveau monde.", + "structure.sanctuary.forgotten_networks": "Réseaux oubliés" } diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/tags/worldgen/biome/has_structure/forgotten_networks.json b/mods/sanctuary/src/main/resources/data/sanctuary/tags/worldgen/biome/has_structure/forgotten_networks.json new file mode 100644 index 0000000..4468af2 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/tags/worldgen/biome/has_structure/forgotten_networks.json @@ -0,0 +1,55 @@ +{ + "replace": false, + "values": [ + "#minecraft:is_overworld", + "sanctuary:cavern_bamboo_grove", + "sanctuary:cavern_birch_forest", + "sanctuary:cavern_clearing", + "sanctuary:cavern_dark_grove", + "sanctuary:cavern_dripstone_caves", + "sanctuary:cavern_dry_woodland", + "sanctuary:cavern_lush_caves", + "sanctuary:cavern_oak_forest", + "sanctuary:cavern_rocky_heath", + "sanctuary:cavern_sulfur_depths", + "sanctuary:layered_flower_meadow", + "sanctuary:layered_lower_grove", + "sanctuary:layered_meadow", + "sanctuary:layered_mossy_terraces", + "sanctuary:layered_rocky_terraces", + "sanctuary:lost_city", + "sanctuary:population_bamboo_grove", + "sanctuary:population_birch_forest", + "sanctuary:population_clearing", + "sanctuary:population_dark_grove", + "sanctuary:population_dripstone_caves", + "sanctuary:population_dry_woodland", + "sanctuary:population_lush_caves", + "sanctuary:population_oak_forest", + "sanctuary:population_rocky_heath", + "sanctuary:population_sulfur_depths", + "sanctuary:rift_bamboo_grove", + "sanctuary:rift_birch_forest", + "sanctuary:rift_clearing", + "sanctuary:rift_dark_grove", + "sanctuary:rift_dry_woodland", + "sanctuary:rift_oak_forest", + "sanctuary:rift_rocky_heath", + "sanctuary:rift_sulfur_depths", + "sanctuary:starter_forest", + "sanctuary:starter_forest_hydrology", + "sanctuary:temperate_birch_forest", + "sanctuary:temperate_dappled_forest", + "sanctuary:temperate_flower_forest", + "sanctuary:temperate_forest", + "sanctuary:temperate_plains", + "sanctuary:woodland_bamboo_grove", + "sanctuary:woodland_birch_forest", + "sanctuary:woodland_clearing", + "sanctuary:woodland_dark_grove", + "sanctuary:woodland_dry_woodland", + "sanctuary:woodland_oak_forest", + "sanctuary:woodland_rocky_heath", + "sanctuary:woodland_sulfur_depths" + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/forgotten_networks.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/forgotten_networks.json new file mode 100644 index 0000000..c8b8c70 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/structure/forgotten_networks.json @@ -0,0 +1,7 @@ +{ + "type": "sanctuary:forgotten_networks_v17", + "biomes": "#sanctuary:has_structure/forgotten_networks", + "step": "underground_structures", + "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 8414a9f..fc4b652 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_v16", + "type": "sanctuary:island_v17", "biome_source": { "biomes": [ "minecraft:the_void", diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_v16.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_v16.json new file mode 100644 index 0000000..8414a9f --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_v16.json @@ -0,0 +1,81 @@ +{ + "dimensions": { + "minecraft:overworld": { + "type": "sanctuary:sanctuary_384", + "generator": { + "type": "sanctuary:island_v16", + "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" + ] + }, + "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/expansion/ExpansionJournalSmoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/expansion/ExpansionJournalSmoke.java index fde2ea0..6b1518d 100644 --- a/mods/sanctuary/src/test/java/fr/koka/sanctuary/expansion/ExpansionJournalSmoke.java +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/expansion/ExpansionJournalSmoke.java @@ -143,6 +143,21 @@ public final class ExpansionJournalSmoke { require(Arrays.equals(ruinsBytes, Files.readAllBytes(ruinsDirectory.resolve(ExpansionJournal.FILE_NAME))) && Arrays.equals(detailBytes, Files.readAllBytes(detailDirectory.resolve(ExpansionJournal.FILE_NAME))), "Rejected migrations preserve both published15 and16 bytes"); + var experimentRoot = ExpansionIsland.origin(724, SEED, 17); + var experimentDirectory = scratch.resolve("experiment-17"); + var experimentJournal = new ExpansionJournal(experimentDirectory, SEED, 724, 17); + var experimentChild = ExpansionPlacement.candidates(SEED, List.of(experimentRoot), "experiment", "sanctuary", "east", "lost_city", 128, "natural", 0).getFirst() + .withReuseToken("e".repeat(64)); + experimentJournal.commit(List.of(experimentRoot, experimentChild)); + require(new ExpansionJournal(experimentDirectory, SEED, 724, 17).islands().equals(List.of(experimentRoot, experimentChild)), + "Generation17 journal and child preserve their independent identity"); + byte[] experimentBytes = Files.readAllBytes(experimentDirectory.resolve(ExpansionJournal.FILE_NAME)); + reject(() -> new ExpansionJournal(experimentDirectory, SEED, 724, 16), "No implicit downgrade from17 to16"); + reject(() -> new ExpansionJournal(ruinsDirectory, SEED, 724, 17), "No implicit upgrade from16 to17"); + reject(() -> experimentJournal.commit(List.of(experimentRoot, ruinsChild)), "No generation16 child in17"); + require(Arrays.equals(experimentBytes, Files.readAllBytes(experimentDirectory.resolve(ExpansionJournal.FILE_NAME))) + && Arrays.equals(ruinsBytes, Files.readAllBytes(ruinsDirectory.resolve(ExpansionJournal.FILE_NAME))), + "Rejected cross-generation changes preserve experiment and published16 bytes"); try { ExpansionPlacement.candidates(SEED, List.of(ExpansionIsland.origin(724, SEED, 13)), "city", "sanctuary", "east", "lost_city", 256, "natural", 0); throw new AssertionError("Lost City must not silently activate in a saved alpha.13 world"); diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/ruins/RuinsPiece17Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/ruins/RuinsPiece17Smoke.java new file mode 100644 index 0000000..571b94f --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/ruins/RuinsPiece17Smoke.java @@ -0,0 +1,55 @@ +package fr.koka.sanctuary.worldgen.ruins; + +import java.util.ArrayList; +import java.util.List; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; + +/** Generation17's independent saved payload: exact roundtrips and fail-closed malformed geometry. */ +public final class RuinsPiece17Smoke { + public static void main(String[] args) { + SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); run(); + System.out.println("RuinsPiece17Smoke: all room kinds, strict schema/types, supports, bounds and invalid geometry passed."); + } + public static void run() { + for (var kind : RuinsPlan17.Kind.values()) { + int size = kind == RuinsPlan17.Kind.ACCESS ? 5 : 17; + var room = new RuinsPlan17.Room(kind, -32, 120, 40, size, kind == RuinsPlan17.Kind.ACCESS ? 18 : 9, size, + 10, Long.MIN_VALUE + kind.ordinal(), List.of(new RuinsPlan17.Support(-31, 114, 41, 119))); + var saved = RuinsPiece17.encodeRoom(room); + var decoded = RuinsPiece17.decodeRoom(saved); + require(room.equals(decoded) && RuinsPiece17.encodeRoom(decoded).equals(saved), "Saved room fields changed for " + kind); + var bounds = RuinsPiece17.bounds(room); + require(bounds.minX() == -32 && bounds.minZ() == 40 && bounds.minY() == 114 + && bounds.maxX() == room.maxX() && bounds.maxZ() == room.maxZ() && bounds.maxY() == room.maxY(), "Bounds excluded a saved footing"); + if (kind == RuinsPlan17.Kind.ACCESS) require(RuinsPiece17.locator(room).getY() == room.maxY() - 1, + "Locate points beneath the surface hatch"); + var missing = saved.copy(); missing.remove("Openings"); rejects(missing); + var version = saved.copy(); version.putInt("Schema", 2); rejects(version); + var coercion = saved.copy(); coercion.putDouble("MinY", 120.0); rejects(coercion); + var seedType = saved.copy(); seedType.putInt("RoomSeed", 1); rejects(seedType); + var unknown = saved.copy(); unknown.putInt("FutureField", 1); rejects(unknown); + var geometry = saved.copy(); geometry.putInt("Width", 18); rejects(geometry); + var opening = saved.copy(); opening.putInt("Openings", 16); rejects(opening); + var footings = new ListTag(); var bad = new CompoundTag(); + bad.putInt("X", room.maxX() + 1); bad.putInt("MinY", 114); bad.putInt("Z", 41); bad.putInt("MaxY", 119); + footings.add(bad); var outside = saved.copy(); outside.put("Supports", footings); rejects(outside); + bad.putInt("X", -31); bad.putInt("MinY", 100); rejects(outside); // Overlong support. + bad.putInt("MinY", 114); bad.putInt("MaxY", 118); rejects(outside); // Detached support. + bad.putInt("MaxY", 119); footings.add(bad.copy()); rejects(outside); // Duplicate anchor column. + } + var supportList = new ArrayList(); + supportList.add(new RuinsPlan17.Support(1, 114, 1, 119)); + var room = new RuinsPlan17.Room(RuinsPlan17.Kind.ACCESS, 0, 120, 0, 5, 18, 5, 2, 42, supportList); + supportList.clear(); + require(RuinsPiece17.decodeRoom(RuinsPiece17.encodeRoom(room)).supports().size() == 1, "External list mutation changed saved support inputs"); + } + private static void rejects(CompoundTag tag) { + try { RuinsPiece17.decodeRoom(tag); } + catch (IllegalArgumentException expected) { return; } + throw new AssertionError("Malformed forgotten room payload was accepted: " + tag); + } + private static void require(boolean value, String message) { if (!value) throw new AssertionError(message); } +} diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/ruins/RuinsPlanner17Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/ruins/RuinsPlanner17Smoke.java new file mode 100644 index 0000000..5b8b0ed --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/ruins/RuinsPlanner17Smoke.java @@ -0,0 +1,174 @@ +package fr.koka.sanctuary.worldgen.ruins; + +import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.Family; +import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.Kind; +import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.Room; +import java.util.ArrayDeque; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.IntBinaryOperator; + +/** Pure terrain-contract tests: entrances, exact doorway joins, independent budgets and short supports. */ +public final class RuinsPlanner17Smoke { + private static final int[] DX = {0, 1, 0, -1}, DZ = {-1, 0, 1, 0}; + public static void main(String[] args) { + Set kinds = EnumSet.noneOf(Kind.class); + for (int diameter : new int[] {512, 724, 1024}) for (long seed : new long[] {0, 1, 42, -1}) { + Fixture terrain = new Fixture((x, z) -> 220); + var plan = RuinsPlanner17.plan(seed, 0, 0, diameter, terrain); + check(plan, terrain); + require(plan.networks().size() == 3, "Unobstructed starter does not receive all three families"); + require(plan.networks().stream().map(RuinsPlan17.Network::family).distinct().count() == 3, "A family was duplicated"); + require(plan.networks().stream().allMatch(n -> n.rooms().size() == 5), "Flat terrain loses a certified branch"); + plan.networks().forEach(n -> n.rooms().forEach(r -> kinds.add(r.kind()))); + require(plan.equals(RuinsPlanner17.plan(seed, 0, 0, diameter, new Fixture((x, z) -> 220))), "Ruins depend on planning order"); + } + require(kinds.equals(EnumSet.allOf(Kind.class)), "A requested thematic kind is never generated"); + var flat = RuinsPlanner17.plan(0, 0, 0, 724, new Fixture((x, z) -> 220)); + require(!flat.equals(RuinsPlanner17.plan(1, 0, 0, 724, new Fixture((x, z) -> 220))), "Network locations ignore the seed"); + Fixture shifted = new Fixture((x, z) -> 220); + var translated = RuinsPlanner17.plan(42, -1024, 512, 724, shifted); + require(translated.networks().size() == 3, "Negative world coordinates lose a network"); check(translated, shifted); + + Fixture wet = new Fixture((x, z) -> 220); wet.protection = (x, y, z) -> true; + var refusedWater = RuinsPlanner17.plan(0, 0, 0, 724, wet); + check(refusedWater, wet); require(refusedWater.isEmpty(), "Ruins overwrite protected water or a city"); + Fixture absent = new Fixture((x, z) -> -1); + var refusedVoid = RuinsPlanner17.plan(0, 0, 0, 724, absent); + check(refusedVoid, absent); require(refusedVoid.isEmpty(), "Void receives an artificial entry platform"); + Fixture thin = new Fixture((x, z) -> 220); thin.rock = (x, y, z) -> y >= 218 && y <= 220; + var refusedThin = RuinsPlanner17.plan(0, 0, 0, 724, thin); + check(refusedThin, thin); require(refusedThin.isEmpty(), "A shaft descends from a shelf with no reachable bearing rock"); + + Fixture cavity = new Fixture((x, z) -> 220); + cavity.rock = (x, y, z) -> y >= 8 && y <= 220 && (y < 200 || y > 204); + var supported = RuinsPlanner17.plan(0, 0, 0, 724, cavity); + check(supported, cavity); + require(supported.networks().size() == 3 && supported.networks().stream().flatMap(n -> n.rooms().stream()) + .anyMatch(r -> !r.supports().isEmpty()), "Short cave gaps cannot receive certified architectural supports"); + + // The first family's sector is obstructed. Its retries must not eliminate the other two. + var first = flat.networks().getFirst().rooms().getFirst(); + double radiusSquared = (double) first.centerX() * first.centerX() + (double) first.centerZ() * first.centerZ(); + Fixture obstructed = new Fixture((x, z) -> 220); + obstructed.protection = (x, y, z) -> (double) x * first.centerX() + (double) z * first.centerZ() > radiusSquared * .5; + var partial = RuinsPlanner17.plan(0, 0, 0, 724, obstructed); + check(partial, obstructed); + require(partial.networks().stream().noneMatch(n -> n.family() == Family.MINE), "Obstructed family escaped its own candidate sector"); + require(partial.networks().stream().anyMatch(n -> n.family() == Family.STRANGE) + && partial.networks().stream().anyMatch(n -> n.family() == Family.INFRASTRUCTURE), "One family starved all later families"); + for (int diameter : new int[] {64, 128, 256}) { + Fixture small = new Fixture((x, z) -> 220); + check(RuinsPlanner17.plan(42, 0, 0, diameter, small), small); + } + boolean rejected = false; + try { new Room(Kind.WORKSHOP, 0, 100, 0, 5, 7, 5, 0, 0, List.of()); } + catch (IllegalArgumentException expected) { rejected = true; } + require(rejected, "Thematic room accepts dimensions too small for its central passage"); + System.out.println("RuinsPlanner17Smoke passed: three independent families, discoverable shafts, exact connected doorways, protected-volume rejection, short native footings and bounded per-family searches"); + } + + private static void check(RuinsPlan17 plan, Fixture terrain) { + require(plan.samples() == terrain.samples && plan.samples() <= RuinsPlanner17.MAX_SAMPLES, "Probe budget drifted"); + require(terrain.columns.size() <= RuinsPlanner17.MAX_COLUMNS, "Column budget exceeded"); + Set starts = new HashSet<>(); Set written = new HashSet<>(); + for (var network : plan.networks()) { + require(starts.add(key(network.startChunkX(), network.startChunkZ())), "Two networks collide at one native start"); + require(network.rooms().size() >= 3 && network.rooms().size() <= 6, "Network size is unbounded"); + require(network.rooms().stream().filter(r -> r.kind() == Kind.ACCESS).count() == 1, "Network has no unique surface shaft"); + Map walk = new HashMap<>(); + for (int index = 0; index < network.rooms().size(); index++) { + Room room = network.rooms().get(index); + require(Math.abs((room.minX() >> 4) - network.startChunkX()) <= 7 + && Math.abs((room.maxX() >> 4) - network.startChunkX()) <= 7 + && Math.abs((room.minZ() >> 4) - network.startChunkZ()) <= 7 + && Math.abs((room.maxZ() >> 4) - network.startChunkZ()) <= 7, "Native piece reference exceeds seven chunks"); + int highest = -1; + for (int z = room.minZ(); z <= room.maxZ(); z++) for (int x = room.minX(); x <= room.maxX(); x++) { + highest = Math.max(highest, terrain.height.applyAsInt(x, z)); + for (int y = room.minY(); y <= room.maxY(); y++) { + require(written.add(new Point(x, y, z)), "Independent rooms write the same voxel"); + require(!terrain.protection.test(x, y, z), "Room overwrites protected terrain or water"); + require(plan.contains(x, y, z) && plan.protects(x, y, z), "Room is absent from the construction mask"); + } + require(plan.protects(x, room.minY() - 2, z), "Native floor bearing is exposed to decoration"); + if (passage(room, x, z)) require(walk.put(key(x, z), index) == null, "Room walking planes overlap"); + } + if (room.kind() == Kind.ACCESS) require(room.maxY() == highest + 2, "Entry is buried below the sampled surface"); + for (int z : new int[] {room.minZ() + 1, room.centerZ(), room.maxZ() - 1}) + for (int x : new int[] {room.minX() + 1, room.centerX(), room.maxX() - 1}) { + var support = room.supports().stream().filter(s -> s.x() == x && s.z() == z).findFirst(); + if (support.isEmpty()) require(terrain.rock.test(x, room.minY() - 1, z) && terrain.rock.test(x, room.minY() - 2, z), + "Floor has neither a native rock footing nor a short pier"); + } + for (var support : room.supports()) { + require(support.maxY() == room.minY() - 1 && support.maxY() - support.minY() < 12, + "Support misses the floor or descends too far"); + require(terrain.rock.test(support.x(), support.minY() - 1, support.z()) + && terrain.rock.test(support.x(), support.minY() - 2, support.z()), "Support does not end on certified native rock"); + require(plan.protects(support.x(), support.minY() - 1, support.z()) + && plan.intersectsProtection(support.x(), support.minY() - 1, support.z(), support.x(), support.minY() - 1, support.z()), + "Support bearing is missing from the decoration/structure protection masks"); + for (int y = support.minY(); y <= support.maxY(); y++) { + require(written.add(new Point(support.x(), y, support.z())), "Support collides with another room or support"); + require(!terrain.protection.test(support.x(), y, support.z()), "Support overwrites protected water"); + require(plan.contains(support.x(), y, support.z()), "Support is missing from the exact volume mask"); + } + } + for (int direction = 0; direction < 4; direction++) if ((room.openings() & 1 << direction) != 0) { + Room neighbor = neighbor(room, direction, network.rooms()); + require(neighbor != null, "A published doorway opens into unplanned rock"); + require(neighbor.floorY() == room.floorY() && (neighbor.openings() & 1 << ((direction + 2) & 3)) != 0, + "Doorways disagree on height or reciprocal opening"); + } + } + require(!walk.isEmpty(), "Network has no walkable central passage"); + Set reached = new HashSet<>(); var queue = new ArrayDeque(); + Room access = network.rooms().stream().filter(r -> r.kind() == Kind.ACCESS).findFirst().orElseThrow(); + long origin = key(access.centerX(), access.centerZ()); reached.add(origin); queue.add(origin); + while (!queue.isEmpty()) { + long position = queue.removeFirst(); int x = (int) (position >> 32), z = (int) position; + for (int direction = 0; direction < 4; direction++) { + long next = key(x + DX[direction], z + DZ[direction]); + if (walk.containsKey(next) && reached.add(next)) queue.addLast(next); + } + } + require(reached.equals(walk.keySet()), "A thematic room is disconnected from its discoverable entrance"); + } + require(!plan.contains(100_000, 200, 100_000) && !plan.intersectsProtection(100_000, 0, 100_000, 100_005, 383, 100_005), + "Ruins protection escapes the accepted networks"); + } + private static boolean passage(Room r, int x, int z) { + if (x == r.minX()) return (r.openings() & 8) != 0 && Math.abs(z - r.centerZ()) <= 1; + if (x == r.maxX()) return (r.openings() & 2) != 0 && Math.abs(z - r.centerZ()) <= 1; + if (z == r.minZ()) return (r.openings() & 1) != 0 && Math.abs(x - r.centerX()) <= 1; + if (z == r.maxZ()) return (r.openings() & 4) != 0 && Math.abs(x - r.centerX()) <= 1; + return Math.abs(x - r.centerX()) <= 1 || Math.abs(z - r.centerZ()) <= 1; + } + private static Room neighbor(Room room, int direction, List rooms) { + int x = direction == 1 ? room.maxX() + 1 : direction == 3 ? room.minX() - 1 : room.centerX(); + int z = direction == 2 ? room.maxZ() + 1 : direction == 0 ? room.minZ() - 1 : room.centerZ(); + return rooms.stream().filter(r -> r != room && x >= r.minX() && x <= r.maxX() && z >= r.minZ() && z <= r.maxZ() + && (DX[direction] == 0 ? r.centerX() == room.centerX() : r.centerZ() == room.centerZ())).findFirst().orElse(null); + } + @FunctionalInterface private interface Volume { boolean test(int x, int y, int z); } + private static final class Fixture implements RuinsPlanner17.Terrain { + final IntBinaryOperator height; final Set columns = new HashSet<>(); int samples; + Volume protection = (x, y, z) -> false; + Volume rock; + Fixture(IntBinaryOperator height) { this.height = height; rock = (x, y, z) -> y >= 8 && y <= height.applyAsInt(x, z); } + @Override public boolean owns(int x, int z) { return true; } + @Override public boolean solid(int x, int y, int z) { samples++; return rock.test(x, y, z); } + @Override public boolean protectedAt(int x, int y, int z) { samples++; return protection.test(x, y, z); } + @Override public int surfaceY(int x, int z) { + samples++; require(columns.add(key(x, z)), "Column noise was sampled more than once"); return height.applyAsInt(x, z); + } + } + private record Point(int x, int y, int z) {} + private static long key(int x, int z) { return (long) x << 32 ^ z & 0xffffffffL; } + private static void require(boolean valid, String message) { if (!valid) throw new AssertionError(message); } +} diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/ruins/RuinsRenderer17Smoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/ruins/RuinsRenderer17Smoke.java new file mode 100644 index 0000000..667addb --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/ruins/RuinsRenderer17Smoke.java @@ -0,0 +1,219 @@ +package fr.koka.sanctuary.worldgen.ruins; + +import fr.koka.sanctuary.worldgen.city.LostCityRenderer; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.RailBlock; +import net.minecraft.world.level.block.StairBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.storage.loot.LootTable; + +/** Exercises the final native block states, saved bounds and meaningful circulation of all prototypes. */ +public final class RuinsRenderer17Smoke { + private RuinsRenderer17Smoke() {} + public static void main(String[] args) { + SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); run(); + System.out.println("RuinsRenderer17Smoke passed: ten physical room types, saved seed variation, dry connected openings, usable work platform, native fixture supports, deferred loot and chunk-order reconstruction"); + } + public static void run() { + Set> kinds = new HashSet<>(); + for (var kind : RuinsPlan17.Kind.values()) { + for (int size = 0; size < 3; size++) for (int openings : new int[] {1, 2, 4, 8, 5, 10, 15}) + for (long seed : new long[] {0, 1}) { + var room = room(kind, size, openings, seed); + var c = render(room); var repeated = render(room); + require(c.blocks.equals(repeated.blocks) && c.loot.equals(repeated.loot), label(room) + " is not deterministic"); + if (size == 0 && openings == 15 && seed == 0) kinds.add(c.blocks); + var start = RuinsRenderer17.targets(room).getFirst(); + Set reached = walk(c, position(start)); + for (var target : RuinsRenderer17.targets(room)) require(reached.contains(position(target)), + label(room) + " cannot reach " + target.name() + " at " + position(target)); + openings(c, reached); + supports(c); + fixtures(c, reached); + identity(c); + for (var chest : c.loot.keySet()) require(approachable(chest, reached), label(room) + " chest is inaccessible at " + chest); + require(c.blocks.values().stream().noneMatch(b -> b.is(Blocks.SPAWNER) || !b.getFluidState().isEmpty()), + label(room) + " introduced a spawner or uncontrolled fluid"); + } + var a = render(room(kind, 1, 15, 0)); var b = render(room(kind, 1, 15, 1)); + require(!a.blocks.equals(b.blocks), kind + " ignores its saved variation seed"); + if (kind != RuinsPlan17.Kind.ACCESS && kind != RuinsPlan17.Kind.CORRIDOR) + require(!furniture(a).equals(furniture(b)), kind + " varies only stone weathering, not physical use or furniture placement"); + } + require(kinds.size() == RuinsPlan17.Kind.values().length, "Room names disguise identical rendered interiors"); + nativeChunkPartition(); + } + + private static RuinsPlan17.Room room(RuinsPlan17.Kind kind, int size, int openings, long seed) { + int width = size == 0 ? 9 : size == 1 ? 13 : 17; + int depth = size < 2 ? 9 : 13, height = size < 2 ? 7 : 9; + if (kind == RuinsPlan17.Kind.ACCESS) { width = depth = 5; height = new int[] {14, 27, 40}[size]; } + if (kind == RuinsPlan17.Kind.CORRIDOR) { width = size == 2 ? 17 : 5; depth = size == 1 ? 13 : 5; height = 6; } + int x = -43, y = 150, z = 59; + return new RuinsPlan17.Room(kind, x, y, z, width, height, depth, openings, seed, + List.of(new RuinsPlan17.Support(x, y - 5, z, y - 1), + new RuinsPlan17.Support(x + width - 1, y - 12, z + depth - 1, y - 1))); + } + private static String label(RuinsPlan17.Room r) { + return r.kind() + " " + r.width() + "x" + r.height() + "x" + r.depth() + " openings=" + r.openings() + " seed=" + r.seed(); + } + private static BlockPos position(RuinsRenderer17.Target target) { return new BlockPos(target.x(), target.y(), target.z()); } + private static Capture render(RuinsPlan17.Room room) { + var result = new Capture(room); RuinsRenderer17.render(room, result); return result; + } + private static void openings(Capture c, Set reached) { + var r = c.room; + for (int direction = 0; direction < 4; direction++) for (int side = -1; side <= 1; side++) { + int x = direction == 1 ? r.width() - 1 : direction == 3 ? 0 : r.width() / 2 + side; + int z = direction == 2 ? r.depth() - 1 : direction == 0 ? 0 : r.depth() / 2 + side; + var threshold = new BlockPos(x, 1, z); + require(solid(c.block(threshold)), label(r) + " has no dry threshold at " + threshold); + if ((r.openings() & 1 << direction) == 0) { + require(!c.block(threshold.above()).isAir(), label(r) + " opens an unconnected wall"); + continue; + } + for (int y = 2; y <= 4; y++) require(c.block(new BlockPos(x, y, z)).isAir(), + label(r) + " obstructs its three-wide opening at " + x + "/" + z); + require(reached.contains(threshold.above()), label(r) + " opening edge is not connected to the room"); + } + } + private static void supports(Capture c) { + var r = c.room; + for (var support : r.supports()) { + for (int y = support.minY(); y <= support.maxY(); y++) require(solid(c.block(new BlockPos( + support.x() - r.minX(), y - r.minY(), support.z() - r.minZ()))), label(r) + " interrupts its saved footing"); + require(solid(c.block(new BlockPos(support.x() - r.minX(), 0, support.z() - r.minZ()))), + label(r) + " footing does not meet the structural floor"); + } + for (int x = 0; x < r.width(); x++) for (int z = 0; z < r.depth(); z++) + require(solid(c.block(new BlockPos(x, 0, z))), label(r) + " has a perforated structural floor"); + } + private static void fixtures(Capture c, Set reached) { + for (var entry : c.blocks.entrySet()) { + var p = entry.getKey(); var block = entry.getValue(); + if (block.is(Blocks.LANTERN)) { + require(block.getValue(BlockStateProperties.HANGING), "Lantern must hang from the ceiling"); + require(solid(c.block(p.above())), label(c.room) + " floating lantern at " + p); + } + if (block.is(Blocks.LADDER)) { + Direction facing = block.getValue(BlockStateProperties.HORIZONTAL_FACING); + require(solid(c.block(p.relative(facing.getOpposite()))), label(c.room) + " ladder lacks a back wall at " + p); + } + if (block.is(Blocks.LEVER) || block.is(Blocks.GRINDSTONE)) require(solid(c.block(p.below())), + label(c.room) + " unsupported usable fixture at " + p); + if (block.is(Blocks.DARK_OAK_STAIRS)) { + Direction back = block.getValue(BlockStateProperties.HORIZONTAL_FACING); + require(solid(c.block(p.below())), label(c.room) + " seat has no floor"); + require(reached.contains(p.relative(back.getOpposite())), label(c.room) + " seat faces an obstruction at " + p); + } + if (block.is(Blocks.CRAFTING_TABLE) || block.is(Blocks.FURNACE) || block.is(Blocks.GRINDSTONE) + || block.is(Blocks.STONECUTTER) || block.is(Blocks.CARTOGRAPHY_TABLE) || block.is(Blocks.LECTERN)) + require(approachable(p, reached), label(c.room) + " useful furniture cannot be approached at " + p); + } + } + private static void identity(Capture c) { + int expectedLoot = c.room.kind() == RuinsPlan17.Kind.STORE || c.room.kind() == RuinsPlan17.Kind.ARCHIVE ? 1 : 0; + require(c.loot.size() == expectedLoot, label(c.room) + " has unbounded or missing deferred loot"); + switch (c.room.kind()) { + case ACCESS -> { has(c, Blocks.LADDER); has(c, Blocks.DARK_OAK_TRAPDOOR); } + case CORRIDOR -> has(c, Blocks.POLISHED_ANDESITE); + case MINE_GALLERY -> { has(c, Blocks.RAIL); has(c, Blocks.STRIPPED_OAK_LOG); has(c, Blocks.BARREL); } + case WORKSHOP -> { has(c, Blocks.CRAFTING_TABLE); has(c, Blocks.FURNACE); has(c, Blocks.GRINDSTONE); has(c, Blocks.STONECUTTER); has(c, Blocks.ANVIL); has(c, Blocks.LADDER); } + case STORE -> { has(c, Blocks.BARREL); has(c, Blocks.DARK_OAK_SLAB); } + case TILED_HALL -> { has(c, Blocks.CALCITE); has(c, Blocks.POLISHED_ANDESITE); has(c, Blocks.DARK_OAK_STAIRS); } + case ARCHIVE -> { has(c, Blocks.BOOKSHELF); has(c, Blocks.LECTERN); has(c, Blocks.CARTOGRAPHY_TABLE); } + case SERVICE_HALL -> { has(c, Blocks.IRON_BARS); has(c, Blocks.CRAFTING_TABLE); } + case PUMP_HALL -> { has(c, Blocks.WATER_CAULDRON); has(c, Blocks.DISPENSER); has(c, Blocks.LEVER); } + case CONTROL_ROOM -> { has(c, Blocks.LEVER); has(c, Blocks.REDSTONE_LAMP); has(c, Blocks.CARTOGRAPHY_TABLE); } + } + } + private static void has(Capture c, Block block) { + require(c.blocks.values().stream().anyMatch(b -> b.is(block)), label(c.room) + " lost its recognizable " + block); + } + private static Map furniture(Capture c) { + var result = new HashMap(); + c.blocks.forEach((p, b) -> { + if (p.getY() >= 2 && p.getY() < c.room.height() - 2 && !b.isAir() + && !b.is(Blocks.STONE_BRICKS) && !b.is(Blocks.CRACKED_STONE_BRICKS) + && !b.is(Blocks.MOSSY_STONE_BRICKS) && !b.is(Blocks.SMOOTH_STONE)) result.put(p, b); + }); + return result; + } + private static boolean approachable(BlockPos p, Set reached) { + for (Direction d : Direction.Plane.HORIZONTAL) if (reached.contains(p.relative(d))) return true; + return false; + } + private static Set walk(Capture c, BlockPos start) { + require(stand(c, start), label(c.room) + " starts without two-block standing space at " + start); + var seen = new HashSet(); var queue = new ArrayDeque(); seen.add(start); queue.add(start); + while (!queue.isEmpty()) { + var p = queue.removeFirst(); + for (Direction direction : Direction.Plane.HORIZONTAL) for (int dy : new int[] {0, 1, -1}) { + var q = p.relative(direction).offset(0, dy, 0); + if (stand(c, q) && seen.add(q)) queue.add(q); + } + for (int dy : new int[] {-1, 1}) { + var q = p.offset(0, dy, 0); + if ((climb(c.block(p)) || climb(c.block(q)) || climb(c.block(p.below()))) + && stand(c, q) && seen.add(q)) queue.add(q); + } + } + return seen; + } + private static boolean stand(Capture c, BlockPos p) { + if (p.getX() < 0 || p.getX() >= c.room.width() || p.getZ() < 0 || p.getZ() >= c.room.depth() + || p.getY() < 2 || p.getY() >= c.room.height() - 1) return false; + return passable(c.block(p)) && passable(c.block(p.above())) + && (solid(c.block(p.below())) || c.block(p.below()).getBlock() instanceof StairBlock + || climb(c.block(p)) || climb(c.block(p.below()))); + } + private static boolean climb(BlockState b) { return b.is(Blocks.LADDER) || b.is(Blocks.DARK_OAK_TRAPDOOR); } + private static boolean passable(BlockState b) { return b.isAir() || climb(b) || b.getBlock() instanceof RailBlock || b.is(Blocks.LEVER); } + private static boolean solid(BlockState b) { return b.canOcclude() && b.getFluidState().isEmpty(); } + + private static void nativeChunkPartition() { + var r = room(RuinsPlan17.Kind.WORKSHOP, 2, 15, 1); var all = render(r); var chunks = new Capture(r); + for (int cz = r.maxZ() >> 4; cz >= r.minZ() >> 4; cz--) for (int cx = r.maxX() >> 4; cx >= r.minX() >> 4; cx--) { + final int chunkX = cx, chunkZ = cz; + RuinsRenderer17.render(r, new LostCityRenderer.BlockWriter() { + private boolean belongs(int x, int z) { return ((r.minX() + x) >> 4) == chunkX && ((r.minZ() + z) >> 4) == chunkZ; } + @Override public void set(int x, int y, int z, BlockState state) { if (belongs(x, z)) chunks.set(x, y, z, state); } + @Override public void chest(int x, int y, int z, ResourceKey table, long seed) { if (belongs(x, z)) chunks.chest(x, y, z, table, seed); } + }); + } + require(chunks.blocks.equals(all.blocks) && chunks.loot.equals(all.loot), "Chunk order changes saved room rendering"); + } + private record Loot(ResourceKey table, long seed) {} + private static final class Capture implements LostCityRenderer.BlockWriter { + final RuinsPlan17.Room room; + final Map blocks = new HashMap<>(); + final Map loot = new HashMap<>(); + Capture(RuinsPlan17.Room room) { this.room = room; } + @Override public void set(int x, int y, int z, BlockState state) { + int wx = room.minX() + x, wy = room.minY() + y, wz = room.minZ() + z; + require(room.contains(wx, wy, wz), label(room) + " wrote outside its saved exact body/support mask: " + x + "/" + y + "/" + z); + require(wy >= room.boundsMinY() && wy <= room.boundsMaxY(), "Write escapes native vertical bounds"); + blocks.put(new BlockPos(x, y, z), state); + } + @Override public void chest(int x, int y, int z, ResourceKey table, long seed) { + var pos = new BlockPos(x, y, z); + require(loot.put(pos, new Loot(table, seed)) == null, "Duplicate deferred chest"); + set(x, y, z, Blocks.CHEST.defaultBlockState()); + } + BlockState block(BlockPos p) { return blocks.getOrDefault(p, Blocks.AIR.defaultBlockState()); } + } + private static void require(boolean test, String message) { if (!test) throw new AssertionError(message); } +} diff --git a/packwiz/pack.toml b/packwiz/pack.toml index 64f1365..a2ce956 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "0.1.0-alpha.16" +version = "0.1.0-alpha.17" pack-format = "packwiz:1.1.0" [index]