Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c576d655ae | ||
|
|
1c5c2c2c7e | ||
|
|
3a6a895874 | ||
|
|
b19e93dd21 | ||
|
|
3569cf1f5f |
+48
-1
@@ -1,5 +1,50 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0-alpha.14 — 2026-09-09
|
||||
|
||||
- Biome Lost City et quartiers procéduraux contemporains : poches sur l’île
|
||||
initiale et profil `lost_city` pour les nouvelles expansions. Dix-sept types
|
||||
de parcelles, dont quatre parcs, ponts, parkings souterrains conditionnels,
|
||||
fouilles, piscines, casernes, halls, hôtels, bibliothèques et cuisines.
|
||||
- Palette dominée par la pierre, la pierre lisse et les briques de pierre,
|
||||
intérieurs accessibles, ruines déterministes et coffres à butin vanilla
|
||||
différé. Zombies vanilla par spawn naturel ; aucun spawner ni zombie spécial.
|
||||
- Nouveau codec public `sanctuary:island_v14` et journal propre de génération
|
||||
14. Les générations 12/13 restent chargeables sans conversion. Les nouveautés
|
||||
demandent un nouveau monde ; aucune sauvegarde personnelle n’est modifiée.
|
||||
- Expansions 64/128/256/512/1 024 ; `quick` sans taille tire selon les poids
|
||||
40/30/18/9/3, avec résultat stable pour la graine et l’identifiant réservés.
|
||||
Recherche proche dans le secteur demandé, séparation circulaire des emprises
|
||||
de terrain et de décoration, sans réserver tout le voisinage de lecture.
|
||||
- Contrat de reprise différée du vide : uniquement les chunks FULL prouvés
|
||||
vierges et vides d’un nouveau monde 14, certificat lié à un jeton immuable du
|
||||
journal, puis réouverture et nouvelle vérification. Une présence de terrain,
|
||||
d’entité, de POI, de ticks ou une preuve manquante arrête cette reprise.
|
||||
|
||||
`check build assemblePack`, les huit tests moteur, la création et la réouverture
|
||||
de Lost City passent. La reprise du vide conserve 154 chunks FULL éclairés
|
||||
correctement et un bloc témoin après réouvertures. JAR, MRpack et amorçage Prism
|
||||
publiés et vérifiés, canal packwiz avancé. Deux synchronisations isolées puis
|
||||
deux dans la même instance Prism Mac passent, avec 713 fichiers personnels et
|
||||
réglages suivis conservés. Aucun essai Windows natif effectué. Voir les
|
||||
[preuves et limites alpha.14](docs/testing-alpha14.md).
|
||||
|
||||
## 0.1.0-alpha.13.1 — 2026-09-09
|
||||
|
||||
- Corrige l’arrêt des expansions sous Windows : le journal ne tente plus
|
||||
d’ouvrir son dossier comme un fichier pour le synchroniser. Cette opération
|
||||
reste exigée sur les systèmes de fichiers utilisant le séparateur Unix.
|
||||
- Écriture du fichier synchronisée, remplacement atomique et relecture stricte
|
||||
conservés. Les véritables erreurs de stockage arrêtent toujours les expansions.
|
||||
- Après mise à jour et réouverture du même monde, une réservation valide déjà
|
||||
enregistrée avant l’erreur est reprise automatiquement. Aucun changement de
|
||||
schéma du journal, de génération, de paramètres ou de chunks existants.
|
||||
|
||||
Le smoke du journal passe sur macOS, avec politique Windows simulée, erreurs
|
||||
après remplacement et récupération au rechargement. Aucun essai Windows natif
|
||||
n’a encore été réalisé. Voir le [dépannage](docs/alpha13-worldgen.md#correctif-alpha131--expansions-sous-windows)
|
||||
et les [contrôles ciblés](docs/testing.md#correctif-alpha131--journal-dexpansion-sous-windows).
|
||||
|
||||
## 0.1.0-alpha.13 — 2026-09-09
|
||||
|
||||
- Un seul choix public **Sanctuary**, avec le bouton natif **Personnaliser** :
|
||||
@@ -21,7 +66,9 @@
|
||||
`check build assemblePack`, interface client, trois tailles et rechargement des
|
||||
expansions vérifiés. Moyen : 63,66 s pour créer puis charger 289 chunks FULL
|
||||
autour du spawn sur la graine 0, 7,82 s pour les 80 suivants ; mesure serveur,
|
||||
sans garantie de FPS ni charge multijoueur. La distribution est en préparation.
|
||||
sans garantie de FPS ni charge multijoueur. Release, MRpack et canal packwiz
|
||||
publiés ; même instance Prism synchronisée deux fois, avec 601 fichiers
|
||||
personnels et réglages suivis conservés.
|
||||
Voir le [guide alpha.13](docs/alpha13-worldgen.md) et les [preuves](docs/testing.md).
|
||||
|
||||
## 0.1.0-alpha.12.1 — 2026-09-09 (historique)
|
||||
|
||||
@@ -15,10 +15,71 @@ infrastructures qui permettront progressivement d'ouvrir de nouveaux continents.
|
||||
Ce dépôt démarre la nouvelle base Fabric. La [vision complète](docs/vision.md)
|
||||
conserve les intentions ; le [backlog](docs/backlog.md) prépare les premiers
|
||||
tickets. Les systèmes d’économie, de progression collective et les dimensions
|
||||
décrits dans la vision ne sont pas encore implémentés. L’alpha.13 réunit l’île
|
||||
initiale et les outils d’expansion dans un seul choix de monde Sanctuary.
|
||||
Les trois tailles et l’interface native sont vérifiées. La distribution est en
|
||||
préparation ; les versions précédentes restent décrites dans les sections historiques.
|
||||
décrits dans la vision ne sont pas encore implémentés. **L’alpha.14 est publiée** :
|
||||
Lost City ajoute des quartiers en ruine au monde flottant. Le canal packwiz et
|
||||
la même instance Prism Mac sont à jour ; voir [Distribution](docs/packwiz.md).
|
||||
|
||||
## Alpha.14 — Lost City
|
||||
|
||||
Lost City est un biome de quartiers contemporains abandonnés, composé par du
|
||||
code Sanctuary original. Des poches urbaines peuvent apparaître sur l’île de
|
||||
départ ; le profil d’expansion `lost_city` permet d’en rechercher dans les
|
||||
nouveaux continents. Le relief et ses fondations décident des emplacements :
|
||||
un quartier ou un type de bâtiment n’est pas garanti sur chaque île.
|
||||
|
||||
Les 17 types comprennent rues, ponts, quatre parcs (boisé, jardin, aire de jeux,
|
||||
mémorial), places, marchés, parkings, excavations, piscines abandonnées,
|
||||
immeubles d’appartements, hôtels, bibliothèques, restaurants avec cuisine,
|
||||
casernes et halls. **Pierre, pierre lisse et briques de pierre** forment la
|
||||
palette principale, avec des variantes fissurées ou moussues, du verre et
|
||||
quelques détails de fer et de chêne noir. Les étages disposent d’escaliers et
|
||||
les pièces d’aménagements propres à leur usage.
|
||||
|
||||
Les parkings souterrains, fouilles et piscines demandent une épaisseur de roche
|
||||
vérifiée sur toute leur emprise. Les piscines ont un fond, des parois continues
|
||||
et des marches ; les ponts enjambent de courtes failles entre deux berges
|
||||
supportées. Les monstres naturels du biome sont des zombies vanilla, soumis
|
||||
aux règles de lumière et de difficulté du jeu. Aucun spawner ou zombie spécial
|
||||
n’est ajouté ; les variantes de zombies et la progression restent pour la bêta.
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour la génération 14. Le bouton natif
|
||||
**Personnaliser** conserve Petit 512, Moyen 724 par défaut et Grand 1 024.
|
||||
Les mondes 12/13 gardent leurs codecs, journaux, paramètres et chunks.
|
||||
|
||||
Dans un nouveau monde de génération 14, les outils opérateur acceptent des
|
||||
expansions de 64, 128, 256, 512 ou 1 024 blocs. `quick` sans taille effectue un
|
||||
tirage reproductible favorisant les petites îles. Les emplacements suivent des
|
||||
secteurs directionnels autour du parent, avec séparation des emprises écrites.
|
||||
La reprise éventuelle de chunks de vide déjà explorés exige un certificat
|
||||
strict lié au journal et une réouverture du monde ; aucun terrain occupé n’est
|
||||
admis dans ce mécanisme.
|
||||
|
||||
Le [contrat et protocole alpha.14](docs/testing-alpha14.md) précisent les
|
||||
commandes, la protection des sauvegardes et les vérifications réussies :
|
||||
`check build assemblePack`, création et réouverture des quartiers, reprise du
|
||||
vide certifié et validation des artefacts de distribution.
|
||||
La [release alpha.14](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.14),
|
||||
le MRpack et le canal packwiz sont publiés. Deux installations isolées puis deux
|
||||
synchronisations de la même instance Prism Mac passent ; les 713 fichiers
|
||||
personnels et réglages suivis conservent leurs hashes. Aucun monde personnel
|
||||
n’a été ouvert ou régénéré. La validation native Windows reste à effectuer.
|
||||
|
||||
## Alpha.13.1 — correctif des expansions sous Windows
|
||||
|
||||
Le journal d’expansion n’essaie plus d’ouvrir son dossier comme un fichier sous
|
||||
Windows, ce qui provoquait `AccessDeniedException` et bloquait les expansions.
|
||||
La synchronisation du fichier, son remplacement atomique et sa relecture restent
|
||||
obligatoires. Le format du journal et la génération alpha.13 restent identiques.
|
||||
|
||||
Après installation du correctif, **rouvrir le même monde** : une réservation déjà
|
||||
enregistrée est reprise après vérification du journal. Il ne faut pas supprimer
|
||||
`sanctuary-world-v13` ni recréer le monde pour ce correctif. Voir le
|
||||
[dépannage Windows](docs/alpha13-worldgen.md) et la [validation](docs/testing.md).
|
||||
|
||||
La [release alpha.13.1](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.13.1)
|
||||
et le canal packwiz sont publiés. Deux synchronisations isolées et deux
|
||||
synchronisations de l’instance Mac existante passent ; les 713 fichiers personnels
|
||||
et réglages suivis restent identiques. Aucun accès à l’instance Windows n’a eu lieu.
|
||||
|
||||
## Alpha.13 — un seul monde Sanctuary
|
||||
|
||||
@@ -53,6 +114,10 @@ d’expansion/rechargement passent. Moyen atteint 63,66 secondes pour la créati
|
||||
puis 289 chunks FULL dans le test serveur de référence. Les preuves et leurs
|
||||
limites sont dans [Validation](docs/testing.md).
|
||||
|
||||
À la livraison alpha.13, le canal packwiz, le MRpack et la même instance Prism
|
||||
avaient été mis à jour, avec les 601 fichiers personnels et réglages alors suivis conservés.
|
||||
Voir [Distribution](docs/packwiz.md).
|
||||
|
||||
## Socle livré jusqu’à l’alpha.8
|
||||
|
||||
- Mod `sanctuary` indépendant et pack Sanctuary construit avec packwiz.
|
||||
@@ -267,7 +332,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.13, en préparation |
|
||||
| Sanctuary / pack | 0.1.0-alpha.14, validation et livraison en cours |
|
||||
|
||||
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
|
||||
@@ -296,7 +361,7 @@ décrits dans [Validation](docs/testing.md).
|
||||
|
||||
Résultats :
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.13.jar` : mod à installer avec
|
||||
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.14.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).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Alpha.13 — un monde Sanctuary unifié
|
||||
|
||||
**Implémentée et vérifiée localement.** Cette version réunit l’île de départ complète et les outils
|
||||
d’expansion du laboratoire. La distribution est en préparation ;
|
||||
**Publiée et synchronisée dans la même instance Prism.** Cette version réunit l’île de départ complète et les outils
|
||||
d’expansion du laboratoire.
|
||||
[Validation](testing.md) décrit les preuves disponibles et
|
||||
[Distribution](packwiz.md) indique la version effectivement publiée.
|
||||
|
||||
@@ -120,6 +120,37 @@ temples, cabanes de marais et camps abandonnés dépendent du biome, du diamètr
|
||||
et des fondations ; aucune présence n’est garantie. Les règles de progression
|
||||
futures restent décrites dans [Expansion](expansion.md).
|
||||
|
||||
## Correctif alpha.13.1 — expansions sous Windows
|
||||
|
||||
L’erreur `AccessDeniedException` visant le **dossier**
|
||||
`data/sanctuary-world-v13`, depuis `ExpansionJournal.persist`, vient de la
|
||||
synchronisation du répertoire après l’écriture du journal. Java sous Windows
|
||||
ne permet pas cette ouverture avec `FileChannel` ; ce message ne signifie pas
|
||||
à lui seul que le joueur manque de droits sur sa sauvegarde.
|
||||
|
||||
1. Fermer Minecraft pour arrêter la session dont les expansions sont bloquées.
|
||||
2. Mettre l’instance Prism existante à jour vers **0.1.0-alpha.13.1** via son
|
||||
canal packwiz, puis relancer le jeu.
|
||||
3. Rouvrir **le même monde** et consulter `/sanctuary expansion list`.
|
||||
|
||||
Si l’instance Windows a été importée depuis le MRpack sans auto-update, fermer
|
||||
Minecraft puis remplacer seulement `sanctuary-0.1.0-alpha.13.jar` par le JAR
|
||||
`sanctuary-0.1.0-alpha.13.1.jar` de la release dans son dossier `mods`.
|
||||
Conserver un seul JAR Sanctuary actif ; les autres mods et les sauvegardes
|
||||
restent dans la même instance. Le raccordement au canal stable est décrit dans
|
||||
[Distribution](packwiz.md#raccorder-linstance-prism-existante).
|
||||
|
||||
Le remplacement du journal ayant précédé cette erreur, l’expansion demandée
|
||||
peut déjà être enregistrée comme `reserved`. Au chargement, les réservations
|
||||
valides reprennent automatiquement avec leur identifiant, leur graine et leur
|
||||
position. Il n’est pas nécessaire de recréer le monde ou de supprimer le dossier
|
||||
du journal. La commande `resume` seule ne débloque pas une session dont le
|
||||
journal a été déclaré indisponible : sa relecture exige de rouvrir le monde.
|
||||
|
||||
Le correctif conserve le format et le contenu du journal existant ; il ne
|
||||
migre ni ne régénère les chunks. La [validation ciblée](testing.md#correctif-alpha131--journal-dexpansion-sous-windows)
|
||||
distingue les contrôles locaux de l’essai Windows natif restant à effectuer.
|
||||
|
||||
## Paramètres et sauvegardes
|
||||
|
||||
Le preset public `sanctuary:sanctuary` utilise le nouveau codec de générateur
|
||||
@@ -176,4 +207,6 @@ Elles ne prouvent ni les temps de chargement ni le rendu de cette intégration.
|
||||
Les trois tailles, l’interface native et les expansions/rechargements passent
|
||||
les contrôles alpha.13. Moyen atteint 63,66 s pour créer le monde puis charger
|
||||
289 chunks FULL sur la graine 0 ; voir [Validation](testing.md) pour le protocole
|
||||
et ses limites. La procédure de distribution reste à terminer.
|
||||
et ses limites. La release, le MRpack et le canal packwiz sont publiés ; deux
|
||||
synchronisations dans la même instance Prism conservent les 601 fichiers
|
||||
personnels et réglages suivis. Voir [Distribution](packwiz.md).
|
||||
|
||||
+79
-1
@@ -503,10 +503,88 @@ deux fois, avec 466 fichiers personnels et réglages suivis conservés.
|
||||
moteur par taille sur la graine 42, eau après 1 800 ticks, trois cavités natives,
|
||||
compatibilité historique et rechargement des expansions. Moyen : 63,66 s pour
|
||||
création et 289 chunks FULL dans le protocole serveur de référence.
|
||||
`check build assemblePack` passe. Publication et synchronisation en préparation.
|
||||
`check build assemblePack` passe. Release, MRpack et canal packwiz publiés ;
|
||||
même instance Prism synchronisée deux fois, avec 601 fichiers personnels et
|
||||
réglages suivis conservés.
|
||||
Les limites, dont l’absence de preuve de camp dans l’origine unifiée des graines
|
||||
inspectées, sont documentées dans [Validation](testing.md).
|
||||
|
||||
### WG-16 — Journal d’expansion sous Windows — alpha.13.1
|
||||
|
||||
**Branche :** `codex/windows-expansion-journal`.
|
||||
|
||||
**Problème :** après le remplacement atomique du journal, l’ouverture de son
|
||||
répertoire par `FileChannel` échoue sous Windows avec `AccessDeniedException`.
|
||||
La session arrête alors toute expansion, même si la réservation a déjà été écrite.
|
||||
|
||||
- Écarter uniquement la synchronisation de répertoire non prise en charge par
|
||||
le système de fichiers Windows ; conserver force du fichier, remplacement
|
||||
atomique, relecture stricte et arrêt sur les véritables erreurs de stockage.
|
||||
- Conserver le schéma, les identifiants, la géométrie et les journaux existants.
|
||||
Après mise à jour et rechargement, reprendre les réservations déjà enregistrées
|
||||
avec le mécanisme existant, sans suppression ni reconstruction de l’historique.
|
||||
- Tester le chemin Windows par injection, une panne après remplacement et la
|
||||
reprise, puis le build et les scénarios moteur dans le développement local.
|
||||
- Publier le correctif sur le canal stable. La machine Windows signalée n’est
|
||||
pas accessible depuis cet environnement Mac : ne pas prétendre l’avoir testée.
|
||||
|
||||
**État :** correctif et tests vérifiés en alpha.13.1. `check build assemblePack`
|
||||
passe, ainsi que le smoke du journal et les scénarios de création/rechargement
|
||||
dans deux processus Minecraft distincts. Validation Windows par injection sur
|
||||
macOS ; aucun essai Windows natif. Correctif publié depuis `b19e93d`, canal
|
||||
stable avancé et deux synchronisations isolées puis deux dans la même instance
|
||||
Prism Mac réussies, avec 713 fichiers personnels et réglages suivis conservés.
|
||||
Preuves dans [Validation](testing.md) et [Distribution](packwiz.md).
|
||||
|
||||
### WG-17 — Lost City, territoires urbains en ruine — alpha.14
|
||||
|
||||
**Branche :** `codex/lost-city-biome`.
|
||||
|
||||
**Contrat :** créer un biome Lost City avec quartiers contemporains explorables,
|
||||
rues, ponts, parcs boisés, jardins, aires de jeux, mémoriaux, places, marchés,
|
||||
parkings, excavations, piscines et bâtiments variés : appartements, hôtels,
|
||||
bibliothèques, restaurants/cuisines, casernes et grands halls. Les
|
||||
palettes reposent sur la pierre, la pierre lisse et les briques de pierre avec
|
||||
leurs variantes usées. Les zombies vanilla occupent les lieux par les règles
|
||||
de spawn ; aucun spawner ni nouveau zombie spécial dans cette étape.
|
||||
|
||||
- Des quartiers localisés peuvent apparaître sur l’île initiale ; conserver
|
||||
une majorité de terrain naturel et les systèmes hydrologiques/cavités.
|
||||
- Profil d’expansion `lost_city` pour tester des territoires urbains plus vastes.
|
||||
Bâtiments, circulations et ponts s’appuient sur le relief, sans remplir le vide.
|
||||
- Structures natives persistantes, génération déterministe par graine,
|
||||
décoration bornée par chunk, intérieurs accessibles et butin limité.
|
||||
- Dix-sept types de parcelles aux aménagements distincts. Parkings souterrains,
|
||||
piscines et fouilles conditionnés à une roche continue sur toute l’emprise ;
|
||||
garage en surface ou parc de repli sinon. Parois et marches des piscines,
|
||||
escaliers et barrières des fouilles, ponts sans remplissage du vide.
|
||||
- Expansions 64/128/256/512/1 024 dans les nouveaux mondes 14 ; `quick` sans
|
||||
taille tire selon les poids 40/30/18/9/3, avec résultat reproductible par graine
|
||||
et identifiant. Placement proche dans un secteur directionnel, séparation
|
||||
des emprises circulaires écrites et garde aux coins de chunks.
|
||||
- Nouvelle génération **14**, codec `sanctuary:island_v14`, journal propre
|
||||
`data/sanctuary-world-v14/expansions.json` de schéma 1. Ce contrat s’applique
|
||||
uniquement aux nouveaux mondes et à leurs expansions autorisées par commande.
|
||||
Les codecs 12/13, leurs journaux et les chunks existants gardent leur génération.
|
||||
Aucun monde personnel n’est ouvert, modifié, converti ou régénéré.
|
||||
- Contrat particulier de reprise du vide, limité aux mondes 14 : certificat
|
||||
explicite pour des chunks FULL entièrement vides et vierges, lié au jeton
|
||||
immuable de la réservation. Réouverture obligatoire avant préparation et
|
||||
recontrôle de la preuve ; aucune régénération de terrain occupé. L’absence
|
||||
d’une preuve complète, des entités, POI, ticks ou événements bloc empêchent
|
||||
l’admission. Le [contrat détaillé](testing-alpha14.md) fait partie du ticket.
|
||||
- Vérifier structures et biomes dans le moteur, persistance après redémarrage,
|
||||
fondations, circulation, absence de spawners/villageois, héritage Windows,
|
||||
build et pack. Livrer dans le canal stable et la même instance Prism.
|
||||
|
||||
**État : terminé et livré en alpha.14.** Build et tests moteur réussis,
|
||||
release et canal packwiz publiés, deux synchronisations isolées puis deux dans
|
||||
la même instance Prism Mac réussies, 713 fichiers personnels et réglages suivis
|
||||
conservés. Aucun essai Windows natif effectué. Les résultats sont suivis dans
|
||||
[Validation alpha.14](testing-alpha14.md). Les alpha.15/.16 poursuivront la validation de génération
|
||||
avant la bêta consacrée aux systèmes et à la progression. Les infectés, spitters,
|
||||
boomers, hunters, chargeurs et autres variantes restent réservés à la bêta.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"version": "0.1.0-alpha.13.1",
|
||||
"date": "2026-09-09",
|
||||
"minecraft": "26.3-pre-2",
|
||||
"java": 25,
|
||||
"host_os": "macOS",
|
||||
"windows_native_tested": false,
|
||||
"windows_policy_tested_by_injection": true,
|
||||
"journal_contracts": [
|
||||
"Windows policy skips only directory channel",
|
||||
"file force and atomic replacement and strict read-back remain required",
|
||||
"supported directory AccessDenied propagates after replacement",
|
||||
"failed session refuses further writes",
|
||||
"verified reload recovers persisted reservation",
|
||||
"post-replacement corruption fails closed"
|
||||
],
|
||||
"jar_sha256": "b340dfc58f3d2d1a2a375dc527ac6721c61430600622046db26228e0da46061b",
|
||||
"logs": {
|
||||
"journal-smoke": {
|
||||
"path": "build/alpha131-journal-smoke.log",
|
||||
"sha256": "c02e94713cf3f9a3c76674700cc5c800aecd84b213b2be9ed53629f8eb9cd3d0",
|
||||
"passed": true,
|
||||
"required_game_tests": null
|
||||
},
|
||||
"check": {
|
||||
"path": "build/alpha131-check.log",
|
||||
"sha256": "68f6cb97e2f80669d39e37623076229e030a0d9826c13e9420f51f6ba23885bd",
|
||||
"passed": true,
|
||||
"required_game_tests": 8
|
||||
},
|
||||
"expansion-create": {
|
||||
"path": "build/alpha131-expansion-create.log",
|
||||
"sha256": "61fa4513aef71290a1419c3425a329047c8fe623a561544c6a8d916fe50e226c",
|
||||
"passed": true,
|
||||
"required_game_tests": 2
|
||||
},
|
||||
"expansion-reload": {
|
||||
"path": "build/alpha131-expansion-reload.log",
|
||||
"sha256": "44fafb4636ff90707a068f325a245dbf9820ccb1bdf785c912f7e636fe7cfa43",
|
||||
"passed": true,
|
||||
"required_game_tests": 2
|
||||
}
|
||||
},
|
||||
"binary_comparison": {
|
||||
"base_version": "0.1.0-alpha.13",
|
||||
"version": "0.1.0-alpha.13.1",
|
||||
"added": [
|
||||
"fr/koka/sanctuary/expansion/ExpansionJournal$DirectorySync.class"
|
||||
],
|
||||
"removed": [],
|
||||
"changed": [
|
||||
"fabric.mod.json",
|
||||
"fr/koka/sanctuary/expansion/ExpansionJournal$1.class",
|
||||
"fr/koka/sanctuary/expansion/ExpansionJournal.class"
|
||||
],
|
||||
"synthetic_switch_instructions_identical": true,
|
||||
"all_other_entries_identical": true
|
||||
},
|
||||
"expansion": {
|
||||
"seed": 42,
|
||||
"initial_diameter": 724,
|
||||
"generation": 13,
|
||||
"creation_process": 13148,
|
||||
"reload_process": 13279,
|
||||
"creation_passed": true,
|
||||
"reload_passed": true,
|
||||
"creation_islands": [
|
||||
{
|
||||
"landing_biome": "minecraft:jungle",
|
||||
"landing": [
|
||||
0,
|
||||
202,
|
||||
-1792
|
||||
],
|
||||
"climate": "tropical",
|
||||
"upper_biome": "minecraft:jungle",
|
||||
"diameter": 64,
|
||||
"centre": [
|
||||
0,
|
||||
-1792
|
||||
],
|
||||
"id": "alpha",
|
||||
"parent": "sanctuary"
|
||||
},
|
||||
{
|
||||
"landing_biome": "minecraft:desert",
|
||||
"landing": [
|
||||
2048,
|
||||
198,
|
||||
-1792
|
||||
],
|
||||
"climate": "arid",
|
||||
"upper_biome": "minecraft:desert",
|
||||
"diameter": 64,
|
||||
"centre": [
|
||||
2048,
|
||||
-1792
|
||||
],
|
||||
"id": "beta",
|
||||
"parent": "alpha"
|
||||
}
|
||||
],
|
||||
"reloaded_regions": 3,
|
||||
"saved_generator_codec_decoded": true,
|
||||
"after_reload_expansion": [
|
||||
0,
|
||||
1792
|
||||
],
|
||||
"final_regions": 4,
|
||||
"native_structure_status": "not_observed_after_complete_scan",
|
||||
"native_structure_witness_required": false,
|
||||
"limitation": "Optional native camp witness absent after complete search on seed 42, as in alpha.13; no new structures claim."
|
||||
},
|
||||
"scope": "No generation or journal schema change. Test worlds only; no personal world opened or regenerated. Windows native runtime and power-loss durability not tested."
|
||||
}
|
||||
+90
-1
@@ -13,10 +13,99 @@ JAR Sanctuary sont des pièces jointes de releases Gitea. Aucun JAR de mod ou
|
||||
dépendance téléchargée n'entre dans l'historique Git. Le canal Beta est distinct
|
||||
de l'ancien pack 26.2.
|
||||
|
||||
## Alpha.14 publiée — 9 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.14](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.14)
|
||||
est publiée depuis le commit source `1c5c2c2`. Le canal stable utilise cette
|
||||
version, au commit `dabf57e`. Le JAR, le
|
||||
[MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.14/Sanctuary-0.1.0-alpha.14.mrpack)
|
||||
et le ZIP d’amorçage Prism ont été vérifiés par téléchargement public.
|
||||
SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
1c517a13e1151501c6f4974d1bc00faedf67aaecd9bf58f34ac4a655d09dc6ef
|
||||
```
|
||||
|
||||
`check build assemblePack` et les huit GameTests passent, ainsi que les
|
||||
scénarios natifs Lost City et reprise du vide après réouverture. Deux
|
||||
synchronisations isolées puis deux dans la même instance Prism Mac ont réussi.
|
||||
Le second passage conserve les fichiers gérés à l’identique ; un seul JAR
|
||||
Sanctuary alpha.14 est actif. Les **713 fichiers personnels et réglages suivis**
|
||||
conservent leurs hashes. Aucune sauvegarde personnelle n’a été ouverte,
|
||||
convertie ou régénérée. Le dossier reste `Sanctuary-0.1.0-alpha.1`.
|
||||
|
||||
Sauvegarde ciblée dans cette instance :
|
||||
`sanctuary-backups/before-0.1.0-alpha.14/`. Reçus locaux ignorés :
|
||||
`build/alpha14-isolated-validation.json` et `build/alpha14-prism-validation.json`.
|
||||
Voir le [contrat de génération et les preuves](testing-alpha14.md).
|
||||
La machine Windows n’a pas été accessible pour un essai natif ou une installation.
|
||||
|
||||
La livraison conserve la même instance et ses sauvegardes. Pour les nouveautés
|
||||
Lost City, créer ensuite un **nouveau monde Sanctuary** de génération 14 ;
|
||||
mettre à jour le pack ne convertit pas les mondes 12/13. Dans ce nouveau monde
|
||||
uniquement, une expansion réservée sur du vide certifié peut demander de fermer
|
||||
et rouvrir le monde avant sa préparation. Ce mécanisme ne fait pas partie de
|
||||
l’installateur packwiz et n’autorise pas à supprimer un monde ou son journal.
|
||||
|
||||
## Correctif alpha.13.1 publié — 9 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.13.1](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.13.1)
|
||||
est publié depuis le commit source `b19e93d`. Le canal stable utilisait alors cette
|
||||
version. Le JAR, le [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.13.1/Sanctuary-0.1.0-alpha.13.1.mrpack)
|
||||
et le ZIP d’amorçage ont été vérifiés par téléchargement public. SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
b340dfc58f3d2d1a2a375dc527ac6721c61430600622046db26228e0da46061b
|
||||
```
|
||||
|
||||
Deux synchronisations isolées puis deux dans la même instance Prism Mac ont
|
||||
réussi. Le second passage conserve les fichiers gérés à l’identique ; un seul
|
||||
JAR Sanctuary alpha.13.1 est actif. Les **713 fichiers personnels et réglages
|
||||
suivis** conservent leurs hashes. Aucune sauvegarde personnelle n’a été ouverte,
|
||||
convertie ou régénérée. Dossier de l’instance conservé : `Sanctuary-0.1.0-alpha.1`.
|
||||
Sauvegarde ciblée : `sanctuary-backups/before-0.1.0-alpha.13.1/` ; reçus locaux
|
||||
`build/alpha131-isolated-validation.json` et `build/alpha131-prism-validation.json`.
|
||||
|
||||
Le correctif évite la synchronisation de dossier non prise en charge sous
|
||||
Windows. **Aucun nouveau monde requis :** fermer Minecraft, mettre l’instance
|
||||
à jour puis rouvrir le même monde. Les réservations enregistrées reprennent
|
||||
après vérification. La machine Windows n’a pas été accessible pour installation
|
||||
ou essai natif : le [guide](alpha13-worldgen.md#correctif-alpha131--expansions-sous-windows)
|
||||
décrit aussi le remplacement du seul JAR Sanctuary pour une instance issue du
|
||||
MRpack sans auto-update. Voir les [preuves et limites](testing.md#correctif-alpha131--journal-dexpansion-sous-windows).
|
||||
|
||||
## Alpha.13 publiée — 9 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.13](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.13)
|
||||
est publiée depuis le commit source `78b63be`. Le canal stable utilisait alors
|
||||
cette version. Le JAR, le [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.13/Sanctuary-0.1.0-alpha.13.mrpack)
|
||||
et le ZIP d’amorçage ont été vérifiés par téléchargement public. SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
53eb5d8eda54d322250784203824993bd675de1cb90467e74d67589b15769e76
|
||||
```
|
||||
|
||||
Deux synchronisations isolées puis deux passages dans la même instance Prism
|
||||
ont réussi. Le second passage laisse les fichiers gérés identiques ; un seul
|
||||
JAR Sanctuary alpha.13 est actif. Les **601 fichiers personnels et réglages
|
||||
suivis** conservent leurs hashes, notamment les mondes et mods tiers. Aucune
|
||||
sauvegarde personnelle n’a été ouverte, convertie ou régénérée.
|
||||
|
||||
L’instance conserve son dossier `Sanctuary-0.1.0-alpha.1`, sans réimportation.
|
||||
Sauvegarde ciblée : `sanctuary-backups/before-0.1.0-alpha.13/` dans cette instance.
|
||||
Reçus locaux : `build/alpha13-isolated-validation.json` et
|
||||
`build/alpha13-prism-validation.json`.
|
||||
|
||||
Pour tester : créer un **nouveau monde**, choisir **Sanctuary**, puis le bouton
|
||||
natif **Personnaliser** : Petit 512, Moyen 724 par défaut, Grand 1 024.
|
||||
Le laboratoire n’est plus un choix public ; ses cavités et outils d’expansion
|
||||
rejoignent le nouveau monde. Les anciennes générations restent chargeables.
|
||||
Voir [le guide alpha.13](alpha13-worldgen.md) et [les preuves](testing.md).
|
||||
|
||||
## Correctif alpha.12.1 publié — 9 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.12.1](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.12.1)
|
||||
est publiée depuis le commit source `332d60b`. Le canal packwiz utilise cette
|
||||
est publiée depuis le commit source `332d60b`. Le canal packwiz utilisait cette
|
||||
version. Le JAR, le [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.12.1/Sanctuary-0.1.0-alpha.12.1.mrpack)
|
||||
et le ZIP d’amorçage ont été vérifiés par téléchargement public. SHA-256 du JAR :
|
||||
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
# Alpha.14 — Lost City et expansions
|
||||
|
||||
**État : alpha.14 vérifiée, publiée et installée dans la même instance Prism Mac.**
|
||||
`check build assemblePack` passe sur les sources finales avec les **8 GameTests
|
||||
requis**, le 9 septembre 2026 à **13 h 57 min 48 s**. La création de quartiers,
|
||||
leur réouverture et la reprise différée du vide passent également dans leurs
|
||||
mondes de développement distincts. Le JAR, le MRpack et le ZIP Prism sont
|
||||
vérifiés. La release immuable correspond au commit source `1c5c2c2`, le canal
|
||||
packwiz au commit `dabf57e`. Deux synchronisations isolées puis deux dans la même
|
||||
instance Prism passent, avec **713 fichiers personnels et réglages suivis
|
||||
conservés**. Le [reçu de livraison](packwiz.md#alpha14-publiée--9-septembre-2026)
|
||||
consigne les artefacts, leur empreinte et la sauvegarde ciblée.
|
||||
Les résultats des alpha.12, 13 et 13.1 restent historiques. Ticket [WG-17](backlog.md).
|
||||
|
||||
Les smokes des sources finales passent sous Gradle et Java 25 :
|
||||
`LostCityRendererSmoke` pour les 17 types, dont confinement
|
||||
et accès des piscines dans quatre orientations, parcs distincts et sous-sols ;
|
||||
`ExpansionJournalSmoke`, dont l’immuabilité du jeton de reprise du vide ; et
|
||||
le contrôle de placement spatial de génération 14. `LostCityPlannerSmoke`
|
||||
passe également. Ses
|
||||
32 graines sur terrain contrôlé couvrent tous les types de lots ; un témoin
|
||||
distinct de faille couvre le pont, soit les 17 types. Le test de pente ascendante
|
||||
et descendante protège la sélection des lots contre le rejet asymétrique des
|
||||
terrains montants. Sur l’ancien planificateur compilé, ce nouveau test échouait
|
||||
avec **0 contre 9 lots** ; le planificateur corrigé satisfait l’égalité des
|
||||
deux sens et le minimum de cinq lots demandé par le test. Cette comparaison
|
||||
provient de la sortie d’exécution ; aucun journal séparé n’a été conservé.
|
||||
|
||||
`:sanctuary:expansionVoidCertificateSmoke` passe aussi sous Gradle :
|
||||
champs NBT natifs obligatoires, types stricts, refus des contenus, preuves de
|
||||
chunks absents et stabilité du certificat face aux seuls compteurs volatils.
|
||||
Ces contrôles unitaires sont complétés par les essais moteur consignés plus
|
||||
bas.
|
||||
|
||||
## Périmètre et création d’un monde
|
||||
|
||||
Minecraft **26.3-pre-2**, Fabric et Java **25**, selon les versions épinglées dans
|
||||
`gradle.properties`. La livraison cible `0.1.0-alpha.14` pour le mod et le pack.
|
||||
|
||||
Créer un **nouveau monde**, type **Sanctuary**, puis le bouton natif
|
||||
**Personnaliser** : Petit 512, Moyen 724 par défaut ou Grand 1 024 blocs de
|
||||
diamètre nominal. La hauteur reste de 384 blocs. Le codec public est
|
||||
`sanctuary:island_v14` ; les racines et les extensions de ces mondes portent la
|
||||
génération **14**. Le journal est séparé :
|
||||
`data/sanctuary-world-v14/expansions.json`, schéma 1.
|
||||
|
||||
Les mondes de générations 12 et 13 conservent leurs codecs, paramètres et
|
||||
journaux. Ils ne reçoivent pas Lost City par conversion et ne changent pas de
|
||||
générateur au chargement. Aucune sauvegarde personnelle ne doit être utilisée
|
||||
pour les essais ci-dessous.
|
||||
|
||||
## Lost City
|
||||
|
||||
Les quartiers sont composés par du code Sanctuary original. Une poche urbaine
|
||||
peut être retenue sur l’île initiale ; les extensions de profil `lost_city`
|
||||
recherchent également des emplacements. Le biome suit les parcelles acceptées
|
||||
en surface et laisse les biomes de cavités profonds. Une île peut ne fournir
|
||||
aucun site convenable : la ville et ses différents types ne sont pas garantis.
|
||||
|
||||
| Type | Aménagement distinctif |
|
||||
| --- | --- |
|
||||
| Rue | Chaussée, bordures de pierre lisse et variations de hauteur accessibles |
|
||||
| Pont | Tablier et garde-corps au-dessus d’une courte faille, appuis naturels aux deux extrémités |
|
||||
| Parc boisé | Chemins croisés, arbres et bancs |
|
||||
| Jardin | Haies basses, parterres fleuris et allées |
|
||||
| Aire de jeux | Sable, portique de balançoire, plate-forme et petit toboggan |
|
||||
| Parc mémorial | Cour, monument de pierre et bancs |
|
||||
| Place | Pavage et fontaine sèche |
|
||||
| Marché | Étals, auvents sobres et réserves |
|
||||
| Parking | Places marquées, poteaux, garage souterrain si la roche le permet, sinon en surface |
|
||||
| Excavation | Fouille ouverte, fondations inachevées, barrières et escalier |
|
||||
| Piscine abandonnée | Bassin de trois couches d’eau, marches immergées et plage minérale |
|
||||
| Immeuble d’appartements | Étages, chambres, coin cuisine et escaliers |
|
||||
| Hôtel | Réception, couloir central et chambres séparées |
|
||||
| Bibliothèque | Rayonnages, tables de lecture et pupitre |
|
||||
| Restaurant | Cuisine équipée et tables, espace de service à l’étage |
|
||||
| Caserne | Dortoirs et réserves |
|
||||
| Hall | Grande salle, estrade, rangées de sièges et balcon |
|
||||
|
||||
Pierre, pierre lisse et briques de pierre dominent ; briques fissurées ou
|
||||
moussues, andésite, vitres, détails de fer et intérieurs en chêne noir complètent
|
||||
la palette. L’usure est déterministe. Les accès et les escaliers restent
|
||||
distincts des brèches de façade et de toiture. Le butin des coffres utilise des
|
||||
tables vanilla différées ; aucune génération de spawner ou de villageois.
|
||||
|
||||
Le spawn naturel de monstres du biome et des pièces de structure est limité aux
|
||||
zombies vanilla. Lumière, difficulté, limites de population et distance aux
|
||||
joueurs continuent de s’appliquer ; une ruine n’implique pas la présence immédiate
|
||||
d’un groupe de zombies. Les classes de zombies spécifiques restent pour la bêta.
|
||||
|
||||
Le placement vérifie la roche sous toute l’emprise. Un sous-sol, une piscine ou
|
||||
une excavation exige une continuité jusqu’à huit blocs sous le plancher de
|
||||
référence. À défaut, le parking reste en surface et piscine ou excavation se
|
||||
replient en parc. Les piscines gardent un fond et des parois continus ; les
|
||||
ponts ne construisent pas de piliers jusqu’au vide. Les volumes protégés de
|
||||
l’hydrologie et les sites insuffisamment supportés sont écartés.
|
||||
|
||||
## Commandes d’essai et placement des expansions
|
||||
|
||||
Les commandes restent des outils opérateur, accessibles avec les permissions
|
||||
adéquates. Elles n’utilisent pas encore la progression ou les ressources.
|
||||
|
||||
```text
|
||||
/sanctuary expansion quick east lost_city 256 natural
|
||||
/sanctuary expansion list
|
||||
/sanctuary expansion visit island_1
|
||||
/sanctuary expansion preview quartier sanctuary northeast lost_city 128 natural
|
||||
/sanctuary expansion create quartier sanctuary northeast lost_city 128 natural
|
||||
/locate structure sanctuary:lost_city
|
||||
```
|
||||
|
||||
`island_1` est l’exemple du premier identifiant libre créé par `quick` ; utiliser
|
||||
l’identifiant réellement indiqué par la commande. `visit` attend une île prête.
|
||||
`preview` examine un emplacement sans l’activer. `resume <id>` sert à reprendre
|
||||
une réservation interrompue. `create` choisit aussi son parent explicitement.
|
||||
La recherche exacte `/locate structure sanctuary:lost_city` retrouve les
|
||||
quartiers Sanctuary admissibles ; elle peut échouer si aucun quartier ne se
|
||||
trouve dans sa portée de recherche. Un profil `lost_city` ne garantit pas un
|
||||
terrain suffisamment adapté pour accepter un quartier.
|
||||
|
||||
Les diamètres d’expansion possibles dans les mondes 14 sont **64, 128, 256,
|
||||
512 et 1 024**. Avec `quick east` ou `quick east lost_city`, le diamètre est tiré
|
||||
de manière reproductible à partir de la graine et de l’identifiant :
|
||||
|
||||
| Diamètre | Poids du tirage |
|
||||
| --- | --- |
|
||||
| 64 | 40 % |
|
||||
| 128 | 30 % |
|
||||
| 256 | 18 % |
|
||||
| 512 | 9 % |
|
||||
| 1 024 | 3 % |
|
||||
|
||||
Ces poids décrivent la distribution du tirage, pas un quota de cinq îles à
|
||||
reproduire sur chaque serveur. Une taille explicite remplace le tirage. Les
|
||||
recherches d’emplacement ne changent ni cette taille ni la graine retenue.
|
||||
Les mondes historiques gardent leurs tailles et comportements antérieurs.
|
||||
|
||||
La direction choisit un secteur de 45° autour du parent. La recherche essaie
|
||||
d’abord des positions proches, puis des couronnes plus éloignées, avec un
|
||||
nombre borné de candidats. Les centres restent alignés sur les chunks.
|
||||
Les emprises circulaires incluent le terrain, la marge de décoration et une
|
||||
garde empêchant deux îles de posséder le même chunk de coin. Le voisinage lu
|
||||
par les étapes de génération n’est pas intégralement réservé comme terrain.
|
||||
Une distance minimale entre centres reste possible comme dernier argument.
|
||||
|
||||
## Contrat de reprise différée du vide
|
||||
|
||||
Ce mécanisme concerne **uniquement les nouveaux mondes de génération 14** et
|
||||
une expansion explicitement demandée. Les générations 12/13 gardent leur refus
|
||||
des chunks déjà générés, même vides. Il ne permet pas la régénération générale
|
||||
d’une île, d’un terrain occupé ou d’une construction.
|
||||
|
||||
1. Examiner toute l’emprise d’écriture et produire, pour chaque chunk existant,
|
||||
une preuve stricte qu’il est FULL, entièrement vide et vierge. Les positions
|
||||
encore absentes sont également certifiées, y compris leurs stockages
|
||||
d’entités et de POI. La simple absence de bloc visible ne suffit pas : entités
|
||||
persistées, chargées ou en attente, POI, ticks, événements de blocs et autres
|
||||
indices de contenu font partie des refus.
|
||||
2. Lier le certificat à l’identité exacte de l’expansion et à un jeton immuable
|
||||
dans le journal. Une preuve isolée sans réservation correspondante ne donne
|
||||
aucune autorisation ; un changement de paramètres ou une preuve manquante
|
||||
doit faire échouer la reprise.
|
||||
3. Enregistrer la réservation et demander de **fermer puis rouvrir le monde**.
|
||||
L’expansion reste différée pendant la session ayant établi la preuve.
|
||||
4. Au chargement suivant, vérifier le journal, le certificat et les données
|
||||
actuelles sur toute l’emprise avant toute préparation. Une divergence bloque
|
||||
la reprise avant l’écriture des positions absentes. Seuls les chunks
|
||||
explicitement certifiés peuvent être réutilisés par cette expansion.
|
||||
|
||||
La preuve lit le format natif exact de Minecraft 26.3-pre-2 : l’état d’air sans
|
||||
propriétés s’écrit comme la chaîne `minecraft:air` dans la palette ; sa forme
|
||||
composée autorisée utilise le champ `id`. L’ancien champ `Name` ne suffit pas
|
||||
à certifier un chunk. Les types NBT, sections, palettes, heightmaps et données
|
||||
annexes restent contrôlés strictement ; une métadonnée inconnue provoque un
|
||||
refus. Seuls les compteurs temporels et caches de lumière valides sont exclus
|
||||
de l’empreinte sémantique du vide.
|
||||
|
||||
Il ne faut supprimer ni certificat, ni journal, ni dossier de région pour
|
||||
débloquer une commande. L’absence de preuve est un refus, pas une invitation à
|
||||
reconstruire l’historique. Les sauvegardes personnelles ne sont pas ouvertes
|
||||
par les tests ou par la mise à jour packwiz.
|
||||
|
||||
## Protocole et preuves attendues
|
||||
|
||||
Un seul processus Gradle ou Minecraft de développement à la fois. Les suites
|
||||
de création repartent de mondes ignorés sous `mods/sanctuary/build/run/`.
|
||||
Un scénario de réouverture doit suivre sa création sans intercaler un test qui
|
||||
effacerait ce monde de développement.
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:lostCityPlannerSmoke :sanctuary:lostCityRendererSmoke --console=plain
|
||||
./gradlew check build assemblePack -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryLostCityTests=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryLostCityTests=true -PsanctuaryExpansionReload=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
```
|
||||
|
||||
La suite de reprise du vide repart ensuite d’un **autre monde de développement
|
||||
neuf**. La commande de réouverture est exécutée deux fois de suite :
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryVoidReuseTests=true -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryVoidReuseTests=true -PsanctuaryExpansionReload=true -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryVoidReuseTests=true -PsanctuaryExpansionReload=true -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
```
|
||||
|
||||
### Résultats moteur — graine 0
|
||||
|
||||
Le 9 septembre 2026, la création sur les sources finales passe à **13 h 55 min
|
||||
15 s** dans le processus **19155** (`build/alpha14-city-final-create.log`,
|
||||
deux tests requis réussis, commande terminée en **2 min 24 s**). La réouverture
|
||||
du même monde de développement passe à **13 h 55 min 58 s** dans un second
|
||||
processus, **19372** (`build/alpha14-city-final-reload.log`, deux tests requis
|
||||
réussis, **33 s**).
|
||||
Ces durées sont celles des commandes Gradle complètes, pas des mesures de
|
||||
chargement en jeu ou de performance multijoueur.
|
||||
|
||||
Les rapports locaux ignorés sont
|
||||
`mods/sanctuary/build/run/gameTest/diagnostics/lost-city-alpha14-seed-0.json`
|
||||
et `lost-city-alpha14-reload-seed-0.json` dans le même dossier. Leurs copies
|
||||
sont conservées dans `build/alpha14-evidence/` avant le nettoyage du monde de
|
||||
développement. Le monde racine est de diamètre **724**, graine **0**, codec
|
||||
`sanctuary:island_v14`.
|
||||
|
||||
Trois extensions de diamètre 256 ont été examinées en lecture seule :
|
||||
|
||||
| Candidat | Graine de l’extension | Quartiers admissibles |
|
||||
| --- | --- | --- |
|
||||
| `city_test` | `7890860792428309400` | 0 |
|
||||
| `city_harbor` | `-1594320136210322067` | 0 |
|
||||
| `city_gardens` | `6666275329838981856` | 1 |
|
||||
|
||||
Seule `city_gardens` a ensuite été activée, à l’est, centre **(2032, 624)**.
|
||||
Sa préparation a terminé **418 chunks FULL**. L’inspection urbaine ci-dessous
|
||||
couvre les chunks des quartiers, pas la totalité de cette préparation.
|
||||
|
||||
| Site généré | Parcelles sauvegardées | Chunks FULL inspectés | Coffres non ouverts à butin différé |
|
||||
| --- | --- | --- | --- |
|
||||
| Île initiale | 26 : appartement, hôtel, bibliothèque, marché, parc boisé, mémorial et 20 rues | 26 | 4 |
|
||||
| `city_gardens` | 12 : appartement, parc boisé et 10 rues | 13 | 1 |
|
||||
|
||||
Les deux quartiers possèdent leurs références de structure natives ; la
|
||||
recherche native retrouve respectivement **(-49, 253, 13)** et **(2012, 229,
|
||||
621)**. Les intérieurs traversables et les tables de spawn limitées aux zombies
|
||||
sont contrôlés. Cela ne garantit pas des zombies visibles de jour. La
|
||||
couverture des **17 types dans quatre orientations** provient de 68 rendus
|
||||
témoins indépendants ; elle ne signifie pas que cette graine contient tous les
|
||||
types dans ses quartiers naturels.
|
||||
|
||||
L’inspection native des bâtiments contrôle également qu’aucun minerai ne
|
||||
remplace la maçonnerie dans leurs volumes à partir du plancher. Cette preuve
|
||||
concerne les bâtiments effectivement générés et inspectés ; les minerais du
|
||||
terrain sous les fondations restent possibles.
|
||||
|
||||
Une piscine native isolée, indépendante du tirage des quartiers, conserve
|
||||
**189 cellules d’eau**, ses marches et son confinement après l’exécution d’un
|
||||
tick d’eau. La réouverture conserve les mêmes 189 cellules et les marches ;
|
||||
elle n’exécute pas un second tick témoin.
|
||||
|
||||
Dans le second processus, les **38 pièces natives**, les **5 coffres non
|
||||
ouverts**, le bloc d’or témoin à **(-61, 250, 11)** et le journal sont conservés.
|
||||
L’empreinte du journal contrôlé est
|
||||
`38e4bc1b2e493628d1e5d1775250c0bc48821bf49d025df3c37159fcff5801ba`.
|
||||
Ces essais utilisent uniquement un monde de développement ignoré, sans ouvrir
|
||||
de sauvegarde personnelle.
|
||||
|
||||
### Reprise du vide — création et deux réouvertures
|
||||
|
||||
Les trois exécutions Minecraft, sur la graine **0**, génération **14**, passent
|
||||
le 9 septembre 2026 :
|
||||
|
||||
| Étape | Journal local | Résultat et durée de la commande |
|
||||
| --- | --- | --- |
|
||||
| Preuves et réservations | `build/alpha14-void-create.log` | Deux tests requis réussis à 13 h 43 ; 41 s |
|
||||
| Première réouverture | `build/alpha14-void-reload.log` | Deux tests requis réussis à 13 h 45 ; 1 min |
|
||||
| Deuxième réouverture | `build/alpha14-void-second-reload.log` | Deux tests requis réussis à 13 h 46 ; 29 s |
|
||||
|
||||
Les rapports sont `void-reuse-before-reload.json`,
|
||||
`void-reuse-after-reload.json` et `void-reuse-second-reload.json`, sous
|
||||
`mods/sanctuary/build/run/gameTest/world/`. Ce sont des preuves locales à
|
||||
conserver avant le nettoyage du monde de développement.
|
||||
|
||||
La première expansion, `void_loaded`, couvre **77 chunks FULL vides déjà
|
||||
chargés**. La seconde, `void_saved`, couvre **un chunk FULL vide uniquement
|
||||
stocké et 76 positions absentes**. Ce dernier scénario emploie une copie NBT
|
||||
native de chunk vide, déplacée uniquement pour constituer le témoin dans ce
|
||||
monde jetable. Chaque réservation reçoit son jeton durable propre et reste
|
||||
différée, sans publication dans le générateur pendant la session initiale.
|
||||
|
||||
Quatre témoins indépendants, chacun contrôlé vide avant modification, prouvent
|
||||
le refus d’un **bloc**, d’un **coffre avec son entité de bloc**, d’une **entité
|
||||
vivante enregistrée** et d’un **tick de bloc programmé**. Une métadonnée NBT
|
||||
étrangère ajoutée après certification fait également échouer la vérification
|
||||
globale : l’empreinte SHA-256 du fichier de région et le contenu NBT modifié
|
||||
restent identiques après ce refus. Le test restaure ensuite son témoin vide
|
||||
pour poursuivre le scénario positif.
|
||||
|
||||
À la première réouverture, les deux expansions atteignent **154 chunks FULL**,
|
||||
chacun conservant le marqueur de son jeton exact, avec **128 sections non
|
||||
vides**. Un bloc d’or témoin est alors posé à **(6080, 376, 1264)**. À la
|
||||
deuxième réouverture, il est préservé : les **154 chunks FULL marqués** et
|
||||
**129 sections non vides** sont retrouvés. Ce passage contrôle donc aussi
|
||||
qu’une reprise terminée ne réinitialise pas les blocs ajoutés ensuite.
|
||||
|
||||
Une réouverture supplémentaire passe à **13 h 48**, avec deux tests requis
|
||||
réussis en **37 s** (`build/alpha14-void-final-reload.log`). Elle contrôle
|
||||
explicitement la lumière native terminée (`isLightCorrect`) sur les **154
|
||||
chunks** et **32 échantillons de biomes hors `the_void`** à l’intérieur des
|
||||
extensions. Le bloc d’or et les 129 sections non vides sont toujours conservés.
|
||||
Ce dernier rapport est copié hors du monde de test dans
|
||||
`build/alpha14-evidence/void-reuse-second-reload.json` pour survivre à son
|
||||
nettoyage. Les 32 échantillons ne constituent pas un relevé exhaustif de tous
|
||||
les biomes des extensions.
|
||||
|
||||
### Vérification finale du build et des artefacts
|
||||
|
||||
`./gradlew check build assemblePack` passe en **1 min 34 s** : **40 tâches**, dont
|
||||
33 exécutées et 7 à jour. Le journal est `build/alpha14-check-build-pack.log`.
|
||||
Les huit tests moteur requis réussissent ; le témoin hydrologique de graine 0
|
||||
contrôle **125 chunks FULL**, **5 923 blocs d’eau conservés** et **1 800 ticks de
|
||||
fluides**. La même suite vérifie la sérialisation des tailles **512 / 724 /
|
||||
1 024**, le défaut Moyen et le codec historique de génération **13**.
|
||||
|
||||
Le smoke du journal valide aussi la politique de synchronisation de dossier
|
||||
Windows par simulation, ainsi que les échecs de stockage après remplacement.
|
||||
Ce résultat ne constitue pas une exécution native sous Windows.
|
||||
|
||||
`build/verify-alpha14-artifacts.py` valide les versions alignées, l’index packwiz,
|
||||
le JAR de production sans classes de test, son inclusion dans le MRpack et le
|
||||
ZIP d’amorçage Prism. Le SHA-256 du JAR `sanctuary-0.1.0-alpha.14.jar` est :
|
||||
|
||||
```text
|
||||
1c517a13e1151501c6f4974d1bc00faedf67aaecd9bf58f34ac4a655d09dc6ef
|
||||
```
|
||||
|
||||
Ces vérifications portent sur les artefacts locaux. La publication du canal,
|
||||
les installations packwiz isolées et la synchronisation de l’instance Prism
|
||||
restent à consigner.
|
||||
|
||||
| Contrôle | Preuve attendue | État de livraison |
|
||||
| --- | --- | --- |
|
||||
| Géométrie et fondations | Déterminisme, budget de colonnes, sites protégés, certificat de roche et ponts | Smoke Gradle réussi sur les sources finales |
|
||||
| Rendu des 17 types | Quatre orientations, limites des pièces, parcours vers les étages, lits complets, accès aux sous-sols et piscine contenue | Smoke Gradle réussi sur les sources finales |
|
||||
| Placement des expansions | Cinq tailles, tirage stable, huit secteurs, séparation et garde aux coins | Smoke Gradle réussi sur les sources finales |
|
||||
| Structures dans Minecraft | Quartier initial et extension Lost City réellement générés, références natives et coffres à butin différé | Réussi, graine 0 : 38 pièces et 5 coffres |
|
||||
| Rechargement dans un second processus | Journal et structures identiques, butin non recréé, bloc témoin de joueur conservé | Réussi sur les sources finales, graine 0, processus 19372 |
|
||||
| Protection de la maçonnerie | Aucun minerai dans les volumes des bâtiments à partir de leur plancher | Réussi sur les quartiers natifs inspectés, graine 0 |
|
||||
| Eau après ticks moteur | Piscine retenue et accès après écoulement, sans fuite extérieure | Réussi sur piscine native isolée : 189 cellules et marches conservées après tick puis réouverture |
|
||||
| Certificat de vide | Schéma NBT strict, refus des contenus et preuves de chunks absents | Smoke Gradle réussi, complété par les témoins natifs de création |
|
||||
| Reprise du vide | Refus des contenus et preuves invalides ; reprise bornée après réouverture avec preuve intacte | Réussi après deux réouvertures : 154 chunks FULL marqués, bloc d’or conservé |
|
||||
| Lumière et biomes après reprise | Lumière native terminée et remplacement du biome vide à l’intérieur des extensions | Réussi à 13 h 48 : 154 chunks éclairés correctement, 32 échantillons hors `the_void`, bloc d’or conservé |
|
||||
| Sérialisation et stockage | Trois tailles, codec historique et erreurs de stockage bloquantes | Suite native réussie pour les trois tailles et le codec 13 ; politique Windows simulée réussie |
|
||||
| Hydrologie conservée | Eau retenue après écoulement moteur | Réussi : 125 chunks FULL, 5 923 blocs d’eau, 1 800 ticks de fluides |
|
||||
| Build et pack | `check build assemblePack` réussi avec versions alignées | Réussi à 13 h 57 min 48 s, huit GameTests requis, JAR/MRpack/ZIP Prism vérifiés |
|
||||
| Distribution | Artefacts immuables vérifiés, canal avancé, deux installations isolées et deux synchronisations de la même instance | Réussi : source `1c5c2c2`, canal `dabf57e`, 713 fichiers personnels et réglages suivis conservés |
|
||||
|
||||
Les reçus locaux sont `build/alpha14-isolated-validation.json` et
|
||||
`build/alpha14-prism-validation.json`. Aucun essai Windows natif, mesure de FPS ou test
|
||||
de charge multijoueur n’est attesté par cette liste. Une validation de géométrie
|
||||
en mémoire ne remplace pas la génération, les ticks et la persistance du moteur.
|
||||
|
||||
La publication suit [Distribution packwiz et Prism](packwiz.md). Elle conserve
|
||||
l’instance existante, ses sauvegardes, ses réglages et les mods tiers.
|
||||
@@ -19,6 +19,80 @@ Pour générer de vrais chunks et vérifier le spawn sur d'autres graines :
|
||||
La propriété ne s'applique qu'au mod de test, jamais au mod distribué ni à une
|
||||
sauvegarde de joueur. Chaque commande repart d'un monde de développement neuf.
|
||||
|
||||
## Correctif alpha.13.1 — journal d’expansion sous Windows
|
||||
|
||||
Le 9 septembre 2026, le contrôle ciblé suivant passe sur macOS et Java 25 :
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:expansionJournalSmoke --console=plain
|
||||
```
|
||||
|
||||
Le journal de cette exécution est `build/alpha131-journal-smoke.log` :
|
||||
`BUILD SUCCESSFUL`, en 7 secondes. Le test écrit et relit de vrais fichiers
|
||||
temporaires ; il n’ouvre aucune sauvegarde Minecraft personnelle.
|
||||
|
||||
Les contrôles existants de persistance, de corruption, d’historique immuable
|
||||
et de compatibilité des racines restent actifs. Trois scénarios couvrent le
|
||||
correctif :
|
||||
|
||||
- La politique Windows, sélectionnée par le séparateur `\`, permet la création
|
||||
du journal, une réservation, sa finalisation et leurs rechargements. Le
|
||||
callback de synchronisation du dossier, qui lèverait `AccessDeniedException`,
|
||||
n’est jamais appelé. Les écritures, remplacements atomiques et relectures du
|
||||
fichier ont réellement lieu sur le système de fichiers du Mac.
|
||||
- La politique Unix conserve une erreur `AccessDeniedException` injectée après
|
||||
le remplacement atomique. La session devient indisponible, garde son ancien
|
||||
état en mémoire et refuse toute nouvelle écriture. Le journal sur disque
|
||||
contient la réservation ; une nouvelle instance la relit et peut la terminer.
|
||||
- Une corruption injectée après le remplacement mais avant la relecture
|
||||
empêche la publication du nouveau plan et conserve le fichier endommagé.
|
||||
Le rechargement refuse ce journal au lieu de créer un historique vide.
|
||||
|
||||
**La branche Windows est simulée ; aucun processus Java ou Minecraft Windows
|
||||
natif n’a encore été exécuté pour cette validation.**
|
||||
|
||||
`check build assemblePack` passe sur les sources alpha.13.1, en 1 min 27 s,
|
||||
avec les huit GameTests du monde Moyen de graine 0. Les scénarios d’expansion
|
||||
passent ensuite dans deux processus Minecraft distincts, sur la graine 42 et
|
||||
le diamètre 724 : création (1 min 32 s), puis réouverture et nouvelle expansion
|
||||
(26 s). Deux tests requis passent à chaque étape. Commandes de reproduction :
|
||||
|
||||
```sh
|
||||
./gradlew check build assemblePack -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryExpansionTests=true -PsanctuaryOptionalStructureWitness=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryExpansionTests=true -PsanctuaryExpansionReload=true -PsanctuaryOptionalStructureWitness=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
```
|
||||
|
||||
Lancer les deux scénarios d’expansion consécutivement ; le second réutilise
|
||||
uniquement leur monde de développement. Comme pour l’alpha.13, le témoin de
|
||||
camp naturel est facultatif et absent après recherche complète sur cette
|
||||
graine ; aucune nouvelle preuve de structure n’est revendiquée.
|
||||
Les [résultats alpha.13.1](benchmarks/alpha131-journal.json) conservent les
|
||||
hashes des journaux de test, les identifiants des deux processus et les régions.
|
||||
La comparaison des JAR confirme que seuls le journal, sa classe de stratégie
|
||||
et la version changent ; le switch synthétique ne change que ses numéros de
|
||||
lignes. Toutes les autres classes et ressources restent identiques.
|
||||
Les résultats des trois tailles et de l’interface ci-dessous restent ceux de
|
||||
la livraison alpha.13 ; ils n’ont pas été réexécutés pour ce correctif de stockage.
|
||||
|
||||
La cause est établie par les sources de
|
||||
[WindowsChannelFactory dans OpenJDK 25](https://github.com/openjdk/jdk/blob/jdk-25-ga/src/java.base/windows/classes/sun/nio/fs/WindowsChannelFactory.java#L206-L272) :
|
||||
`FileChannel.open` n’ajoute pas le drapeau `FILE_FLAG_BACKUP_SEMANTICS`, exigé
|
||||
pour ouvrir un répertoire selon la
|
||||
[documentation Microsoft de CreateFile](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew#directories).
|
||||
Seule cette synchronisation du dossier est omise sous Windows ; les exceptions
|
||||
d’écriture, de remplacement ou de relecture restent bloquantes.
|
||||
|
||||
Le fichier temporaire est toujours synchronisé par `force(true)`, puis remplacé
|
||||
atomiquement et vérifié. Cette séquence ne garantit pas sous Windows la même
|
||||
durabilité du renommage en cas de coupure secteur qu’une synchronisation du
|
||||
répertoire sous Unix :
|
||||
[OpenJDK utilise MoveFileEx sans WRITE_THROUGH](https://github.com/openjdk/jdk/blob/jdk-25-ga/src/java.base/windows/classes/sun/nio/fs/WindowsFileCopy.java#L279-L294),
|
||||
et le contrat de
|
||||
[FileChannel.force](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/nio/channels/FileChannel.html#force(boolean))
|
||||
porte sur les modifications du fichier. Aucun changement de format, de graine,
|
||||
de générateur ou de réservation enregistrée n’est introduit.
|
||||
|
||||
## Alpha.13 — génération unifiée
|
||||
|
||||
`check build assemblePack` passe, y compris le monde Moyen de graine 0 et les
|
||||
|
||||
+27
-2
@@ -28,6 +28,11 @@ Le premier incrément porte sur le socle Fabric et la génération du monde : re
|
||||
|
||||
La progression, l'économie, les nouveaux monstres, les interfaces de pack et les autres dimensions restent des intentions futures. Ils ne constituent pas des dépendances du premier générateur. Le [backlog](backlog.md) précise les premiers tickets et leurs critères d'acceptation.
|
||||
|
||||
L’alpha se poursuit vers les versions 15 ou 16 pour éprouver la génération et
|
||||
ses structures. La bêta véritable commencera ensuite les systèmes de Sanctuary,
|
||||
en particulier la progression. Ce cap décrit un ordre de travail : une version
|
||||
n’est pas considérée terminée avant ses essais et sa livraison.
|
||||
|
||||
## Mod, modpack et modules autonomes
|
||||
|
||||
| Ensemble | Rôle souhaité |
|
||||
@@ -170,8 +175,28 @@ L'ambition est de reprendre puis d'étendre la centaine de biomes du catalogue T
|
||||
biome, leur emprise et leurs fondations conviennent au terrain flottant.
|
||||
L’autorisation ne garantit pas leur présence sur chaque île. Le laboratoire
|
||||
commence par les villages, temples, cabanes de marais et camps abandonnés.
|
||||
- **Lost Cities** : des zones traitées comme des biomes, où des bâtiments en ruine remplacent la végétation dominante. Le nom exprime ici un type de territoire souhaité, sans décider encore s'il sera produit par une intégration ou par du code Sanctuary.
|
||||
- **Structures contemporaines** : parcs, parkings et autres vestiges. Les parkings sont souterrains et peuvent s'étendre comme une infestation. Ils n'apparaissent pas dans la dimension des cavernes.
|
||||
- **Lost City** : des quartiers traités comme un biome, où les bâtiments
|
||||
contemporains en ruine prennent la place principale. L’alpha.14 développe une
|
||||
réalisation originale Sanctuary, inspirée du principe de ville abandonnée,
|
||||
sans ajouter le mod The Lost Cities ni reprendre ses assets. Des poches sont
|
||||
souhaitées sur l’île de départ et dans les continents d’expansion, en laissant
|
||||
une majorité naturelle à l’île initiale. La pierre, la pierre lisse et les
|
||||
briques de pierre, notamment fissurées et moussues, donnent une palette sobre.
|
||||
- **Vie urbaine passée** : routes, ponts, places, marchés, appartements,
|
||||
bibliothèques, restaurants et cuisines abandonnées, hôtels et chambres,
|
||||
casernes, halls, excavations et piscines. Les parcs comprennent boisements,
|
||||
jardins à haies et fleurs, aires de jeux et mémoriaux. Chaque usage doit se
|
||||
reconnaître dans les volumes et les aménagements, avec entrées et circulations.
|
||||
- **Parkings et sous-sols** : creuser seulement là où l’épaisseur de roche
|
||||
permet un volume soutenu ; un garage en surface reste possible ailleurs.
|
||||
Les excavations ouvertes et les piscines doivent aussi garder des fondations,
|
||||
des accès et, pour l’eau, un bassin contenu. Les parkings ne font pas partie
|
||||
de la future dimension des cavernes. À terme, ils pourront former des réseaux
|
||||
souterrains étendus ; cette connexion entre parkings reste à construire.
|
||||
- **Occupants de Lost City** : des zombies plutôt que des villageois, sans
|
||||
spawner. L’alpha utilise le spawn naturel des zombies vanilla ; les infectés,
|
||||
spitters, boomers, hunters, chargeurs et autres classes arrivent avec les
|
||||
systèmes de la bêta. La présence de zombies dépend des règles du jeu.
|
||||
- **Structures uniques et donjons** : d'abord sur Sanctuary Island, puis dans les continents. Ils peuvent contenir des clés de chunk et d'autres ressources rares.
|
||||
- **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.
|
||||
|
||||
|
||||
+2
-2
@@ -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.13
|
||||
pack_version=0.1.0-alpha.13
|
||||
mod_version=0.1.0-alpha.14
|
||||
pack_version=0.1.0-alpha.14
|
||||
maven_group=fr.koka.sanctuary
|
||||
|
||||
@@ -41,6 +41,8 @@ tasks.named('runGameTest') {
|
||||
systemProperty('sanctuary.test.diameter', providers.gradleProperty('sanctuaryTestDiameter').getOrElse('724'))
|
||||
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.voidReuse', providers.gradleProperty('sanctuaryVoidReuseTests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.optionalStructureWitness', providers.gradleProperty('sanctuaryOptionalStructureWitness').getOrElse('false'))
|
||||
}
|
||||
|
||||
@@ -51,8 +53,12 @@ tasks.named('processGametestResources') {
|
||||
inputs.property('unified', unified)
|
||||
def expansion = providers.gradleProperty('sanctuaryExpansionTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('expansion', expansion)
|
||||
def lostCity = providers.gradleProperty('sanctuaryLostCityTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('lostCity', lostCity)
|
||||
def voidReuse = providers.gradleProperty('sanctuaryVoidReuseTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('voidReuse', voidReuse)
|
||||
filesMatching('fabric.mod.json') {
|
||||
expand(gametest_entrypoint: expansion ? 'fr.koka.sanctuary.gametest.ExpansionWorldGameTests' : benchmark
|
||||
expand(gametest_entrypoint: voidReuse ? 'fr.koka.sanctuary.gametest.ExpansionVoidReuseGameTests' : 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')
|
||||
@@ -274,3 +280,36 @@ tasks.register('expansionPlacementSmoke', JavaExec) {
|
||||
mainClass = 'fr.koka.sanctuary.expansion.ExpansionPlacementSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('expansionPlacementSmoke') }
|
||||
|
||||
// Alpha.14 planner uses bounded natural support and never queries loaded chunks.
|
||||
tasks.register('lostCityPlannerSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityPlannerSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityPlannerSmoke') }
|
||||
|
||||
tasks.register('lostCityRendererSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityRendererSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityRendererSmoke') }
|
||||
|
||||
tasks.register('expansionSpatial14Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.expansion.ExpansionSpatialPlacement14Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('expansionSpatial14Smoke') }
|
||||
|
||||
tasks.register('expansionVoidCertificateSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.expansion.ExpansionVoidCertificateSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('expansionVoidCertificateSmoke') }
|
||||
|
||||
+2
-2
@@ -117,10 +117,10 @@ public final class ExpansionCommandChecks {
|
||||
helper.assertTrue(session.islands().equals(before), "Rejecting an unsupported diameter must preserve the registry");
|
||||
|
||||
boolean outsideRangeRejected = false;
|
||||
try { dispatcher.execute("sanctuary expansion create command_out_of_range sanctuary east boreal 513 natural", console); }
|
||||
try { dispatcher.execute("sanctuary expansion create command_out_of_range sanctuary east boreal 1025 natural", console); }
|
||||
catch (CommandSyntaxException expected) { outsideRangeRejected = true; }
|
||||
helper.assertTrue(outsideRangeRejected && session.islands().equals(before),
|
||||
"The parser must reject a diameter above 512 before changing the registry");
|
||||
"The parser must reject a diameter above 1024 before changing the registry");
|
||||
} catch (CommandSyntaxException exception) {
|
||||
throw new IOException("Registered expansion command checks failed", exception);
|
||||
}
|
||||
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonParser;
|
||||
import fr.koka.sanctuary.expansion.ExpansionAdmission;
|
||||
import fr.koka.sanctuary.expansion.ExpansionIsland;
|
||||
import fr.koka.sanctuary.expansion.ExpansionRuntime;
|
||||
import fr.koka.sanctuary.expansion.ExpansionVoidReuse;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.EntitySpawnReason;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.chunk.storage.SerializableChunkData;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
|
||||
/** Opt-in, disposable-world proof. Run fresh, then twice with sanctuaryExpansionReload=true. */
|
||||
public final class ExpansionVoidReuseGameTests {
|
||||
private static final String LOADED = "void_loaded", SAVED = "void_saved";
|
||||
@GameTest(maxTicks = 6000)
|
||||
public void certifiedVoidRestartsThroughNativeGeneration(GameTestHelper helper) {
|
||||
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
|
||||
"Void reuse proof needs the unified generator");
|
||||
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
helper.assertTrue(generator.rootGeneration() == 14, "Void reuse is restricted to generation 14");
|
||||
new Scenario(helper, generator).advance();
|
||||
}
|
||||
|
||||
private static final class Scenario {
|
||||
final GameTestHelper helper;
|
||||
final ServerLevel level;
|
||||
final SanctuaryChunkGenerator generator;
|
||||
final ExpansionRuntime.Session session;
|
||||
final boolean reload = Boolean.getBoolean("sanctuary.test.expansionReload");
|
||||
final Map<String, Object> report = new LinkedHashMap<>();
|
||||
ExpansionIsland loaded, saved;
|
||||
final List<ExpansionIsland> rejectionFixtures = new ArrayList<>();
|
||||
int phase;
|
||||
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
this.helper = helper; this.level = helper.getLevel(); this.generator = generator;
|
||||
this.session = ExpansionRuntime.session(level);
|
||||
report.put("world_seed", level.getSeed()); report.put("generation", 14);
|
||||
}
|
||||
void advance() {
|
||||
try {
|
||||
if (reload) { resume(); return; }
|
||||
if (phase == 0) {
|
||||
loaded = session.candidate(LOADED, "sanctuary", "east", "temperate", 64, "natural", 6144);
|
||||
for (var pos : positions(loaded)) {
|
||||
level.setChunkForced(pos.x(), pos.z(), true);
|
||||
level.getChunk(pos.x(), pos.z());
|
||||
}
|
||||
for (int i = 0; i < 4; i++) {
|
||||
var fixture = new ExpansionIsland("reject_" + i, "sanctuary", "north", "temperate", 64,
|
||||
0, -12288 - i * 4096, level.getSeed() + i, "natural", "reserved", 14);
|
||||
rejectionFixtures.add(fixture);
|
||||
for (var pos : positions(fixture)) {
|
||||
level.setChunkForced(pos.x(), pos.z(), true);
|
||||
level.getChunk(pos.x(), pos.z());
|
||||
}
|
||||
}
|
||||
phase = 1;
|
||||
helper.runAtTickTime(helper.getTick() + 40, this::advance);
|
||||
return;
|
||||
}
|
||||
if (phase == 1) {
|
||||
report.put("stage", "loaded_positive_baseline");
|
||||
var proof = ExpansionVoidReuse.inspect(level, loaded, ExpansionAdmission.searchDeadline());
|
||||
helper.assertTrue(proof.certificates().stream().noneMatch(c -> c.absent()), "Loaded fixture needs native FULL void throughout its write scope");
|
||||
report.put("loaded_positive_baseline_passed", true);
|
||||
report.put("stage", "independent_rejections");
|
||||
rejectionChecks(helper, rejectionFixtures);
|
||||
report.put("stage", "loaded_positive_reservation");
|
||||
var source = level.getChunk(loaded.centerX() >> 4, loaded.centerZ() >> 4);
|
||||
CompoundTag snapshot = SerializableChunkData.copyOf(level, source).write();
|
||||
var admitted = session.create(LOADED, "sanctuary", "east", "temperate", 64, "natural", 6144);
|
||||
helper.assertTrue(admitted.centerX() == loaded.centerX() && admitted.centerZ() == loaded.centerZ(), "Inspection must retain the requested candidate");
|
||||
helper.assertTrue(!admitted.reuseToken().isEmpty() && ExpansionVoidReuse.requiresReload(level, LOADED), "Loaded FULL void must be deferred with a durable journal token");
|
||||
helper.assertTrue(session.island(LOADED).status().equals("reserved"), "A live FULL reservation cannot become ready before reopening");
|
||||
helper.assertTrue(generator.source().islands().stream().noneMatch(i -> i.id().equals(LOADED)), "Deferred island must remain invisible to generation");
|
||||
loaded = admitted;
|
||||
|
||||
report.put("stage", "saved_positive_baseline");
|
||||
saved = session.candidate(SAVED, "sanctuary", "west", "temperate", 64, "natural", 6144);
|
||||
var pos = new ChunkPos(saved.centerX() >> 4, saved.centerZ() >> 4);
|
||||
helper.assertTrue(level.getChunkSource().chunkMap.read(pos).get(30, TimeUnit.SECONDS).isEmpty(), "Saved-only fixture starts from absent storage");
|
||||
// One genuine native FULL-void snapshot, relocated only inside this disposable test fixture.
|
||||
snapshot.putInt("xPos", pos.x()); snapshot.putInt("zPos", pos.z());
|
||||
level.getChunkSource().chunkMap.write(pos, snapshot).get(30, TimeUnit.SECONDS);
|
||||
level.getChunkSource().chunkMap.synchronize(true).get(30, TimeUnit.SECONDS);
|
||||
var savedProof = ExpansionVoidReuse.inspect(level, saved, ExpansionAdmission.searchDeadline());
|
||||
helper.assertTrue(savedProof.certificates().stream().filter(c -> !c.absent()).count() == 1
|
||||
&& savedProof.certificates().stream().anyMatch(c -> c.absent()), "Saved-only fixture needs both FULL and certified absent positions");
|
||||
report.put("stage", "external_mutation_preflight");
|
||||
verifyMutationBlocksWholePreflight(helper, generator, savedProof, snapshot, pos);
|
||||
report.put("stage", "saved_positive_reservation");
|
||||
saved = session.create(SAVED, "sanctuary", "west", "temperate", 64, "natural", 6144);
|
||||
helper.assertTrue(!saved.reuseToken().isEmpty() && ExpansionVoidReuse.requiresReload(level, SAVED), "Saved void also needs a reload transaction");
|
||||
for (var loadedPos : positions(loaded)) level.setChunkForced(loadedPos.x(), loadedPos.z(), false);
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save native void and reservations before the first real restart");
|
||||
report.put("loaded_full_chunks", positions(loaded).size());
|
||||
report.put("saved_only_full_chunks", 1);
|
||||
report.put("saved_only_absent_chunks", savedProof.certificates().stream().filter(c -> c.absent()).count());
|
||||
report.put("loaded_token", loaded.reuseToken()); report.put("saved_token", saved.reuseToken());
|
||||
report.put("rejected_block_chest_entity_tick", true); report.put("foreign_mutation_preserved_region_bytes", true);
|
||||
report.put("passed", true); write(level, "void-reuse-before-reload.json", report);
|
||||
helper.succeed();
|
||||
}
|
||||
} catch (Exception error) {
|
||||
report.put("passed", false); report.put("failure", error.toString());
|
||||
try { write(level, "void-reuse-failure.json", report); } catch (IOException ignored) { }
|
||||
throw new IllegalStateException("Native void reuse proof failed", error);
|
||||
}
|
||||
}
|
||||
void resume() throws Exception {
|
||||
loaded = session.island(LOADED); saved = session.island(SAVED);
|
||||
helper.assertTrue(!ExpansionVoidReuse.requiresReload(level, LOADED) && !ExpansionVoidReuse.requiresReload(level, SAVED),
|
||||
"A genuine restart must open durable transactions");
|
||||
if (!loaded.status().equals("ready") || !saved.status().equals("ready")) {
|
||||
helper.assertTrue(ExpansionVoidReuse.failure(level, LOADED) == null && ExpansionVoidReuse.failure(level, SAVED) == null,
|
||||
"Native load must not block either certified transaction");
|
||||
helper.runAtTickTime(helper.getTick() + 20, this::advance);
|
||||
return;
|
||||
}
|
||||
Path witnessFile = path(level, "void-reuse-after-reload.json");
|
||||
boolean secondReload = Files.exists(witnessFile);
|
||||
if (secondReload) {
|
||||
var original = JsonParser.parseString(Files.readString(witnessFile)).getAsJsonObject();
|
||||
var p = original.getAsJsonArray("gold_witness");
|
||||
helper.assertTrue(level.getBlockState(new BlockPos(p.get(0).getAsInt(), p.get(1).getAsInt(), p.get(2).getAsInt())).is(Blocks.GOLD_BLOCK),
|
||||
"A native marked FULL chunk must preserve gameplay blocks on subsequent reloads");
|
||||
}
|
||||
int full = 0, nonAir = 0, rebuiltBiomeSamples = 0;
|
||||
for (var island : List.of(loaded, saved)) for (var pos : positions(island)) {
|
||||
var chunk = level.getChunk(pos.x(), pos.z());
|
||||
helper.assertTrue(chunk.getPersistedStatus() == ChunkStatus.FULL && ExpansionVoidReuse.marker(chunk).equals(island.reuseToken()),
|
||||
"Every claimed position must complete the native pipeline and retain its exact marker: " + pos);
|
||||
helper.assertTrue(chunk.isLightCorrect(), "Every reconstructed FULL chunk must finish native lighting: " + pos);
|
||||
int midX = pos.x() * 16 + 8, midZ = pos.z() * 16 + 8;
|
||||
if (island.contains(midX, midZ)) {
|
||||
helper.assertTrue(!chunk.getNoiseBiome(pos.x() * 4 + 2, 220 / 4, pos.z() * 4 + 2)
|
||||
.is(net.minecraft.world.level.biome.Biomes.THE_VOID),
|
||||
"Native BIOMES must replace the previous void biome inside the expansion: " + pos);
|
||||
rebuiltBiomeSamples++;
|
||||
}
|
||||
full++;
|
||||
for (var section : chunk.getSections()) if (!section.hasOnlyAir()) nonAir++;
|
||||
}
|
||||
helper.assertTrue(nonAir > 0, "A reconstructed expansion must contain actual generated terrain");
|
||||
var witness = new BlockPos(loaded.centerX(), 376, loaded.centerZ());
|
||||
if (!secondReload) level.setBlock(witness, Blocks.GOLD_BLOCK.defaultBlockState(), 3);
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush generated native chunks and trusted markers");
|
||||
report.put("full_marked_chunks", full); report.put("nonempty_sections", nonAir);
|
||||
report.put("light_correct_chunks", full); report.put("rebuilt_biome_samples", rebuiltBiomeSamples);
|
||||
report.put("gold_witness", List.of(witness.getX(), witness.getY(), witness.getZ()));
|
||||
report.put("second_reload_preserved_gameplay", secondReload); report.put("passed", true);
|
||||
write(level, secondReload ? "void-reuse-second-reload.json" : "void-reuse-after-reload.json", report);
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
|
||||
private static void rejectionChecks(GameTestHelper helper, List<ExpansionIsland> fixtures) throws Exception {
|
||||
var level = helper.getLevel();
|
||||
for (int i = 0; i < fixtures.size(); i++) {
|
||||
var island = fixtures.get(i); var p = new BlockPos(island.centerX(), 200, island.centerZ());
|
||||
// Each witness has its own untouched native palette. Never reuse a section edited by a prior witness.
|
||||
var baseline = ExpansionVoidReuse.inspect(level, island, ExpansionAdmission.searchDeadline());
|
||||
helper.assertTrue(baseline.certificates().stream().noneMatch(c -> c.absent()), "Every rejection first proves its own FULL-void baseline");
|
||||
switch (i) {
|
||||
case 0 -> {
|
||||
level.setBlock(p, Blocks.STONE.defaultBlockState(), 3);
|
||||
expectOccupied(helper, island, "block");
|
||||
}
|
||||
case 1 -> {
|
||||
level.setBlock(p, Blocks.CHEST.defaultBlockState(), 3);
|
||||
helper.assertTrue(level.getBlockEntity(p) != null, "Chest witness must contain its native block entity");
|
||||
expectOccupied(helper, island, "chest");
|
||||
}
|
||||
case 2 -> {
|
||||
var entity = EntityTypes.ZOMBIE.create(level, EntitySpawnReason.COMMAND);
|
||||
helper.assertTrue(entity != null, "Entity rejection needs a real entity");
|
||||
entity.setPos(p.getX() + .5, p.getY(), p.getZ() + .5);
|
||||
helper.assertTrue(level.addFreshEntity(entity), "Entity fixture must be registered with the native manager");
|
||||
expectOccupied(helper, island, "entity"); entity.discard();
|
||||
}
|
||||
case 3 -> {
|
||||
level.scheduleTick(p, Blocks.STONE, 1000);
|
||||
expectOccupied(helper, island, "tick");
|
||||
level.getBlockTicks().clearArea(new BoundingBox(p));
|
||||
}
|
||||
default -> throw new IllegalStateException("Unexpected rejection witness");
|
||||
}
|
||||
for (var pos : positions(island)) level.setChunkForced(pos.x(), pos.z(), false);
|
||||
}
|
||||
}
|
||||
private static void expectOccupied(GameTestHelper helper, ExpansionIsland island, String kind) throws Exception {
|
||||
boolean refused = false;
|
||||
try { ExpansionVoidReuse.inspect(helper.getLevel(), island, ExpansionAdmission.searchDeadline()); }
|
||||
catch (ExpansionAdmission.OccupiedException expected) { refused = true; }
|
||||
helper.assertTrue(refused, "Admission must reject " + kind + " even in an otherwise empty void chunk");
|
||||
}
|
||||
|
||||
private static void verifyMutationBlocksWholePreflight(GameTestHelper helper, SanctuaryChunkGenerator generator,
|
||||
ExpansionVoidReuse.Inspection proof, CompoundTag original, ChunkPos pos) throws Exception {
|
||||
var level = helper.getLevel(); var session = ExpansionRuntime.session(level);
|
||||
String token = ExpansionVoidReuse.persist(proof);
|
||||
var mutated = original.copy(); mutated.putString("foreign_mod_metadata", "must survive unchanged");
|
||||
var storage = level.getChunkSource().chunkMap;
|
||||
storage.write(pos, mutated).get(30, TimeUnit.SECONDS); storage.synchronize(true).get(30, TimeUnit.SECONDS);
|
||||
Path region = net.minecraft.world.level.dimension.DimensionType.getStorageFolder(level.dimension(),
|
||||
level.getServer().getWorldPath(LevelResource.ROOT)).resolve("region/r."
|
||||
+ Math.floorDiv(pos.x(), 32) + "." + Math.floorDiv(pos.z(), 32) + ".mca");
|
||||
byte[] before = MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(region));
|
||||
var islands = List.of(session.island("sanctuary"), proof.island().withReuseToken(token));
|
||||
boolean refused = false;
|
||||
try { ExpansionVoidReuse.open(level, generator, islands); } catch (IOException expected) { refused = true; }
|
||||
helper.assertTrue(refused, "External mutation must block startup before any absent chunk is staged");
|
||||
helper.assertTrue(java.util.Arrays.equals(before, MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(region))),
|
||||
"A rejected preflight must preserve the original region file byte for byte");
|
||||
helper.assertTrue(storage.read(pos).get(30, TimeUnit.SECONDS).orElseThrow().equals(mutated), "Foreign NBT must be preserved");
|
||||
storage.write(pos, original).get(30, TimeUnit.SECONDS); storage.synchronize(true).get(30, TimeUnit.SECONDS);
|
||||
// This deliberately simulates startup inside a running test; restore the runtime's deferred state afterward.
|
||||
ExpansionVoidReuse.open(level, generator, List.of(session.island("sanctuary")));
|
||||
for (var island : session.islands()) if (!island.reuseToken().isEmpty()) ExpansionVoidReuse.deferred(level, island);
|
||||
}
|
||||
private static List<ChunkPos> positions(ExpansionIsland island) {
|
||||
var result = new ArrayList<ChunkPos>(); int radius = island.terrainRadius() + ExpansionIsland.DECORATION_MARGIN;
|
||||
for (int z = (island.centerZ() - radius) >> 4; z <= (island.centerZ() + radius) >> 4; z++)
|
||||
for (int x = (island.centerX() - radius) >> 4; x <= (island.centerX() + radius) >> 4; x++)
|
||||
if (island.ownsChunk(x, z)) result.add(new ChunkPos(x, z));
|
||||
return result;
|
||||
}
|
||||
private static Path path(ServerLevel level, String name) { return level.getServer().getWorldPath(LevelResource.ROOT).resolve(name); }
|
||||
private static void write(ServerLevel level, String name, Map<String, Object> report) throws IOException {
|
||||
Files.writeString(path(level, name), new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
|
||||
}
|
||||
}
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.expansion.ExpansionIsland;
|
||||
import fr.koka.sanctuary.expansion.ExpansionJournal;
|
||||
import fr.koka.sanctuary.expansion.ExpansionRuntime;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPiece;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlan;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityStructures;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
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.world.entity.EntityTypes;
|
||||
import net.minecraft.world.entity.MobCategory;
|
||||
import net.minecraft.world.entity.npc.villager.Villager;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.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 engine proof on a development world, including a separate-process reload. */
|
||||
public final class LostCityWorldGameTests {
|
||||
@GameTest(maxTicks = 6000)
|
||||
public void cityDistrictsAreNativePersistentStructures(GameTestHelper helper) {
|
||||
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
|
||||
"Lost City checks require the unified generator");
|
||||
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
helper.assertTrue(generator.rootGeneration() == 14, "Lost City must use new generation 14");
|
||||
if (Boolean.getBoolean("sanctuary.test.expansionReload")) {
|
||||
reload(helper, generator);
|
||||
return;
|
||||
}
|
||||
new Scenario(helper, generator).advance();
|
||||
}
|
||||
|
||||
private static final class Scenario {
|
||||
final GameTestHelper helper;
|
||||
final ServerLevel level;
|
||||
final SanctuaryChunkGenerator generator;
|
||||
final ExpansionRuntime.Session session;
|
||||
final Map<String, Object> report;
|
||||
final List<Map<String, Object>> sites = new ArrayList<>();
|
||||
int phase;
|
||||
long poolPlacedTick;
|
||||
String childId;
|
||||
|
||||
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
this.helper = helper; this.level = helper.getLevel(); this.generator = generator;
|
||||
session = ExpansionRuntime.session(level); report = report(level);
|
||||
}
|
||||
|
||||
void advance() {
|
||||
try {
|
||||
if (phase == 0) {
|
||||
report.put("archetypes", verifySavedArchetypes(helper));
|
||||
verifyLegacyCodec(helper, generator);
|
||||
placePoolFixture(helper, generator);
|
||||
poolPlacedTick = helper.getTick();
|
||||
var root = session.island("sanctuary");
|
||||
helper.assertTrue(root.generation() == 14, "New root must keep generation 14 in its journal");
|
||||
sites.add(observe(helper, generator, root));
|
||||
var prospects = new ArrayList<Map<String, Object>>();
|
||||
report.put("city_prospects", prospects);
|
||||
for (String id : List.of("city_test", "city_harbor", "city_gardens", "city_heights", "city_works")) {
|
||||
var candidate = session.candidate(id, "sanctuary", "east", "lost_city", 256, "natural", 2048);
|
||||
try {
|
||||
var plan = generator.lostCityPlan(candidate);
|
||||
prospects.add(Map.of("id", id, "seed", candidate.seed(), "diameter", candidate.diameter(),
|
||||
"districts", plan.districts().size(), "read_only", true));
|
||||
if (!plan.isEmpty()) { childId = id; break; }
|
||||
} finally { generator.discardUnpublishedContext(candidate); }
|
||||
}
|
||||
helper.assertTrue(childId != null, "Five bounded read-only prospects must find a naturally supported city reference");
|
||||
var child = session.create(childId, "sanctuary", "east", "lost_city", 256, "natural", 2048);
|
||||
helper.assertTrue(child.generation() == 14 && child.climate().equals("lost_city"),
|
||||
"City expansion must have its own saved generation and climate");
|
||||
phase = 1;
|
||||
}
|
||||
if (phase == 1 && session.island(childId).status().equals("ready") && helper.getTick() - poolPlacedTick >= 60) {
|
||||
report.put("native_pool_fixture", verifyPoolFixture(helper, true));
|
||||
forcePoolFixture(level, false);
|
||||
sites.add(observe(helper, generator, session.island(childId)));
|
||||
report.put("sites", sites);
|
||||
var first = generator.lostCityPlan(session.island("sanctuary")).districts().getFirst().parcels().getFirst();
|
||||
var marker = new BlockPos(first.minX(), first.floorY() + 1, first.minZ());
|
||||
level.setBlock(marker, Blocks.GOLD_BLOCK.defaultBlockState(), 3);
|
||||
report.put("gold_witness", List.of(marker.getX(), marker.getY(), marker.getZ()));
|
||||
report.put("islands", ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow());
|
||||
report.put("generator_codec", ChunkGenerator.CODEC.encodeStart(
|
||||
RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow());
|
||||
report.put("journal_sha256", hash(Files.readString(journal(level, generator))));
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush city chunks, deferred loot and marker before restart");
|
||||
report.put("world_flushed_before_shutdown", true);
|
||||
report.put("passed", true);
|
||||
write(level, report, false);
|
||||
helper.succeed();
|
||||
return;
|
||||
}
|
||||
if (helper.getTick() % 200 == 0) SanctuaryMod.LOGGER.info("Lost City integration phase {}: {}", phase, session.progress());
|
||||
helper.runAtTickTime(helper.getTick() + 10, this::advance);
|
||||
} catch (Exception exception) {
|
||||
forcePoolFixture(level, false);
|
||||
report.put("passed", false); report.put("failure", exception.toString()); report.put("sites", sites);
|
||||
try { write(level, report, false); } catch (IOException ignored) { /* Keep the original test failure. */ }
|
||||
throw new IllegalStateException("Lost City native integration failed", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deliberately isolated in development void, apart from the two naturally planned city witnesses.
|
||||
private static final int POOL_X = 6000, POOL_Z = 0, POOL_Y = 366;
|
||||
private static void forcePoolFixture(ServerLevel level, boolean forced) {
|
||||
for (int x = (POOL_X - 1) >> 4; x <= (POOL_X + 17) >> 4; x++)
|
||||
for (int z = (POOL_Z - 1) >> 4; z <= (POOL_Z + 17) >> 4; z++) level.setChunkForced(x, z, forced);
|
||||
}
|
||||
private static void placePoolFixture(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
ServerLevel level = helper.getLevel();
|
||||
forcePoolFixture(level, true);
|
||||
var parcel = new LostCityPlan.Parcel(LostCityPlan.Kind.POOL, POOL_X, POOL_Z, 17, 17, POOL_Y, 0, 0, 42, 8, new int[0]);
|
||||
var piece = new LostCityPiece(parcel);
|
||||
piece.getBoundingBox().intersectingChunks().forEach(pos -> {
|
||||
level.getChunk(pos.x(), pos.z());
|
||||
var clip = new net.minecraft.world.level.levelgen.structure.BoundingBox(pos.x() * 16, 0, pos.z() * 16,
|
||||
pos.x() * 16 + 15, 383, pos.z() * 16 + 15);
|
||||
piece.postProcess(level, level.structureManager(), generator, net.minecraft.util.RandomSource.create(42), clip, pos, BlockPos.ZERO);
|
||||
});
|
||||
level.scheduleTick(new BlockPos(POOL_X + 8, POOL_Y - 1, POOL_Z + 10), net.minecraft.world.level.material.Fluids.WATER, 5);
|
||||
}
|
||||
private static Map<String, Object> verifyPoolFixture(GameTestHelper helper, boolean requireTicks) {
|
||||
var level = helper.getLevel();
|
||||
var witness = new BlockPos(POOL_X + 8, POOL_Y - 1, POOL_Z + 10);
|
||||
if (requireTicks) {
|
||||
helper.assertTrue(level.shouldTickBlocksAt(ChunkPos.containing(witness).pack()), "Pool fixture must actually tick in Minecraft");
|
||||
helper.assertTrue(!level.getFluidTicks().hasScheduledTick(witness, net.minecraft.world.level.material.Fluids.WATER),
|
||||
"The explicitly scheduled water tick must have executed");
|
||||
}
|
||||
int water = 0;
|
||||
for (int x = -1; x <= 17; x++) for (int z = -1; z <= 17; z++) for (int y = -9; y <= 1; y++) {
|
||||
var pos = new BlockPos(POOL_X + x, POOL_Y + y, POOL_Z + z);
|
||||
if (!level.getFluidState(pos).isEmpty()) {
|
||||
water++;
|
||||
helper.assertTrue(x >= 4 && x <= 12 && z >= 5 && z <= 12 && y >= -3 && y <= -1,
|
||||
"Native pool water escaped its intended basin after ticks or reload: " + pos);
|
||||
}
|
||||
}
|
||||
helper.assertTrue(water >= 180, "The native pool lost its retained water");
|
||||
for (int step = 1; step <= 3; step++) helper.assertTrue(level.getBlockState(
|
||||
new BlockPos(POOL_X + 8, POOL_Y - step, POOL_Z + step + 4)).is(net.minecraft.tags.BlockTags.STAIRS),
|
||||
"Native submerged access steps must survive fluid ticks");
|
||||
return Map.of("scope", "Isolated native piece fixture, separate from random city placement", "retained_water_cells", water,
|
||||
"scheduled_water_tick_executed", requireTicks, "contained", true, "steps_preserved", true);
|
||||
}
|
||||
|
||||
private static Map<String, Object> observe(GameTestHelper helper, SanctuaryChunkGenerator generator, ExpansionIsland island) {
|
||||
ServerLevel level = helper.getLevel();
|
||||
LostCityPlan plan = generator.lostCityPlan(island);
|
||||
helper.assertTrue(!plan.isEmpty(), "This reference seed must demonstrate an accepted city district on " + island.id());
|
||||
var district = plan.districts().getFirst();
|
||||
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
|
||||
var holder = registry.getOrThrow(LostCityStructures.KEY);
|
||||
var startChunk = level.getChunkSource().getChunk(district.startChunkX(), district.startChunkZ(), ChunkStatus.STRUCTURE_STARTS, true);
|
||||
StructureStart start = startChunk.getStartForStructure(holder.value());
|
||||
helper.assertTrue(start != null && start.isValid(), "Accepted district must have a native StructureStart");
|
||||
helper.assertTrue(start.getPieces().size() == district.parcels().size(), "Every accepted parcel must be saved as a native piece");
|
||||
Map<Long, LevelChunk> chunks = new TreeMap<>();
|
||||
for (var piece : start.getPieces()) piece.getBoundingBox().intersectingChunks().forEach(pos -> {
|
||||
if (!chunks.containsKey(pos.pack())) {
|
||||
var chunk = level.getChunkSource().getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
|
||||
helper.assertTrue(chunk instanceof LevelChunk, "City inspection requires FULL chunks");
|
||||
chunks.put(pos.pack(), (LevelChunk) chunk);
|
||||
}
|
||||
});
|
||||
helper.assertTrue(chunks.size() <= 81, "One district is bounded; the check must not generate an entire island");
|
||||
for (var chunk : chunks.values()) helper.assertTrue(chunk.getReferencesForStructure(holder.value()).contains(start.getChunkPos().pack()),
|
||||
"Every touched FULL chunk must retain the native structure reference");
|
||||
var context = StructurePieceSerializationContext.fromLevel(level);
|
||||
var tag = start.createTag(context, start.getChunkPos());
|
||||
var decoded = StructureStart.loadStaticStart(context, tag, island.seed());
|
||||
helper.assertTrue(decoded != null && decoded.isValid() && decoded.createTag(context, decoded.getChunkPos()).equals(tag),
|
||||
"Native start and complete parcel parameters must round-trip through saved NBT");
|
||||
Map<String, Long> blocks = new TreeMap<>();
|
||||
Map<String, Integer> kinds = new TreeMap<>();
|
||||
List<Map<String, Object>> chests = new ArrayList<>();
|
||||
int biomeWitnesses = 0, interiorWitnesses = 0;
|
||||
for (var piece : start.getPieces()) {
|
||||
helper.assertTrue(piece instanceof LostCityPiece, "Lost City must load the registered native parcel type");
|
||||
var parcel = ((LostCityPiece) piece).parcel();
|
||||
kinds.merge(parcel.kind().name(), 1, Integer::sum);
|
||||
var box = piece.getBoundingBox();
|
||||
for (int x = box.minX(); x <= box.maxX(); x++) for (int z = box.minZ(); z <= box.maxZ(); z++)
|
||||
for (int y = box.minY(); y <= box.maxY(); y++) {
|
||||
var pos = new BlockPos(x, y, z);
|
||||
var state = level.getBlockState(pos);
|
||||
if (!state.isAir()) blocks.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), 1L, Long::sum);
|
||||
if (parcel.kind().building() && y >= parcel.floorY()) helper.assertTrue(
|
||||
!BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath().endsWith("_ore"),
|
||||
"Native ore features must not replace a city's masonry after structure placement");
|
||||
helper.assertTrue(!state.is(Blocks.SPAWNER), "City buildings contain no zombie spawner");
|
||||
if (state.is(Blocks.CHEST) && level.getBlockEntity(pos) instanceof RandomizableContainerBlockEntity chest
|
||||
&& chest.getLootTable() != null) {
|
||||
chests.add(Map.of("position", List.of(x, y, z), "table", chest.getLootTable().identifier().toString(), "seed", chest.getLootTableSeed()));
|
||||
}
|
||||
}
|
||||
var centre = new BlockPos(parcel.minX() + parcel.width() / 2, parcel.floorY() + 1, parcel.minZ() + parcel.depth() / 2);
|
||||
if (level.getBiome(centre).is(SanctuaryMod.id("lost_city"))) biomeWitnesses++;
|
||||
var entrance = entrance(parcel);
|
||||
if (parcel.kind().building() && level.getBlockState(entrance).isAir()
|
||||
&& level.getBlockState(entrance.above()).isAir() && level.getBlockState(entrance.below()).isSolid()) interiorWitnesses++;
|
||||
var monsters = generator.getMobsAt(level, level.structureManager(), MobCategory.MONSTER, centre).unwrap();
|
||||
helper.assertTrue(!monsters.isEmpty() && monsters.stream().allMatch(entry -> entry.value().type() == EntityTypes.ZOMBIE),
|
||||
"The effective native monster spawn list inside a city piece contains only vanilla zombies");
|
||||
}
|
||||
helper.assertTrue(biomeWitnesses > 0, "The city must exist as a real saved biome, not only as placed buildings");
|
||||
helper.assertTrue(interiorWitnesses > 0, "At least one observed contemporary building has a two-block-high traversable interior");
|
||||
helper.assertTrue(blocks.getOrDefault("minecraft:stone", 0L) > 0 && blocks.getOrDefault("minecraft:smooth_stone", 0L) > 0
|
||||
&& blocks.getOrDefault("minecraft:stone_bricks", 0L) > 0, "The actual city contains stone, smooth stone and restrained stone bricks");
|
||||
helper.assertTrue(!chests.isEmpty(), "An observed building must contain unconsumed deferred native loot");
|
||||
var bounds = start.getBoundingBox();
|
||||
helper.assertTrue(level.getEntitiesOfClass(Villager.class, new AABB(bounds.minX(), bounds.minY(), bounds.minZ(),
|
||||
bounds.maxX() + 1, bounds.maxY() + 1, bounds.maxZ() + 1)).isEmpty(), "The abandoned district contains no generated villagers");
|
||||
var located = generator.findNearestMapStructure(level, HolderSet.direct(holder),
|
||||
new BlockPos(district.startChunkX() * 16 + 8, 220, district.startChunkZ() * 16 + 8), 100, false);
|
||||
helper.assertTrue(located != null && located.getSecond().is(LostCityStructures.KEY), "The native locate path finds Lost City");
|
||||
helper.assertTrue(!LostCityStructures.isPlacing(), "The scoped decoration bypass must be cleared after placement");
|
||||
var result = new LinkedHashMap<String, Object>();
|
||||
result.put("island", island.id()); result.put("generation", island.generation());
|
||||
result.put("planned_districts", plan.districts().size()); result.put("start_chunk", List.of(start.getChunkPos().x(), start.getChunkPos().z()));
|
||||
result.put("full_chunks", chunks.size()); result.put("parcel_kinds", kinds); result.put("blocks", blocks);
|
||||
result.put("native_start_sha256", hash(tag.toString())); result.put("unopened_chests", chests);
|
||||
result.put("biome_witnesses", biomeWitnesses); result.put("traversable_interiors", interiorWitnesses);
|
||||
result.put("native_zombie_only_monster_spawns", true); result.put("native_locate", List.of(located.getFirst().getX(), located.getFirst().getY(), located.getFirst().getZ()));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> verifySavedArchetypes(GameTestHelper helper) {
|
||||
var context = StructurePieceSerializationContext.fromLevel(helper.getLevel());
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
for (var kind : LostCityPlan.Kind.values()) for (int facing = 0; facing < 4; facing++) {
|
||||
var parcel = new LostCityPlan.Parcel(kind, -32, 48, 17, 17, 220, kind.building() ? 3 : 1,
|
||||
facing, 0xA14C17L + kind.ordinal(), 4, new int[0]);
|
||||
var piece = new LostCityPiece(parcel);
|
||||
var tag = piece.createTag(context);
|
||||
var decoded = new LostCityPiece(context, tag);
|
||||
helper.assertTrue(decoded.createTag(context).equals(tag), "Every archetype and orientation must persist without changing parcel fields");
|
||||
var original = render(helper, parcel); var reloaded = render(helper, decoded.parcel());
|
||||
helper.assertTrue(!original.isEmpty() && original.equals(reloaded), "Reloaded parcels render exactly the same blocks and deferred loot seeds");
|
||||
results.add(Map.of("kind", kind.name(), "facing", facing, "positions", original.size(), "sha256", hash(original.toString())));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static BlockPos entrance(LostCityPlan.Parcel parcel) {
|
||||
int x = parcel.width() / 2, z = 1;
|
||||
int rotatedX = switch (parcel.facing()) {
|
||||
case 1 -> parcel.width() - 1 - z;
|
||||
case 2 -> parcel.width() - 1 - x;
|
||||
case 3 -> z;
|
||||
default -> x;
|
||||
};
|
||||
int rotatedZ = switch (parcel.facing()) {
|
||||
case 1 -> x;
|
||||
case 2 -> parcel.depth() - 1 - z;
|
||||
case 3 -> parcel.depth() - 1 - x;
|
||||
default -> z;
|
||||
};
|
||||
return new BlockPos(parcel.minX() + rotatedX, parcel.floorY() + 1, parcel.minZ() + rotatedZ);
|
||||
}
|
||||
|
||||
private static Map<String, String> render(GameTestHelper helper, LostCityPlan.Parcel parcel) {
|
||||
Map<String, String> rendered = new TreeMap<>();
|
||||
LostCityRenderer.render(parcel, new LostCityRenderer.BlockWriter() {
|
||||
private void put(int x, int y, int z, String value) {
|
||||
helper.assertTrue(x >= 0 && x < parcel.width() && z >= 0 && z < parcel.depth()
|
||||
&& parcel.floorY() + y >= parcel.minY() && parcel.floorY() + y <= parcel.maxY(),
|
||||
"Every renderer write must fit the persisted parcel bounds: " + parcel.kind() + " at " + x + "," + y + "," + z);
|
||||
rendered.put(x + "," + y + "," + z, value);
|
||||
}
|
||||
@Override public void set(int x, int y, int z, BlockState state) {
|
||||
helper.assertTrue(!state.is(Blocks.SPAWNER), "No original archetype emits a spawner");
|
||||
put(x, y, z, state.toString());
|
||||
}
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
|
||||
put(x, y, z, "loot:" + table.identifier() + ":" + seed);
|
||||
}
|
||||
});
|
||||
return rendered;
|
||||
}
|
||||
|
||||
private static void verifyLegacyCodec(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, helper.getLevel().registryAccess());
|
||||
JsonObject json = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow().getAsJsonObject();
|
||||
json.addProperty("type", "sanctuary:island_v13");
|
||||
json.getAsJsonObject("biome_source").addProperty("type", "sanctuary:island_v13");
|
||||
var old = (SanctuaryChunkGenerator) ChunkGenerator.CODEC.parse(ops, json).getOrThrow();
|
||||
helper.assertTrue(old.rootGeneration() == 13 && old.journalDirectory().equals("sanctuary-world-v13"),
|
||||
"The registered generation-13 codec keeps its journal and never enables city generation");
|
||||
helper.assertTrue(old.lostCityPlan(ExpansionIsland.origin(generator.initialDiameter(), helper.getLevel().getSeed(), 13)).isEmpty(),
|
||||
"Existing generation-13 worlds never acquire a city plan");
|
||||
}
|
||||
|
||||
private static void reload(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
var level = helper.getLevel(); var result = report(level);
|
||||
try {
|
||||
JsonObject previous = JsonParser.parseString(Files.readString(path(level, false))).getAsJsonObject();
|
||||
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(), "Reload requires a completed first process");
|
||||
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Native city reload must run in a second JVM");
|
||||
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed()
|
||||
&& previous.get("world_directory").getAsString().equals(level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize().toString()),
|
||||
"Reload must read the exact same saved development world and seed");
|
||||
var session = ExpansionRuntime.session(level);
|
||||
helper.assertTrue(previous.get("islands").equals(ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow()),
|
||||
"The ready root and Lost City expansion identities must persist across process restart");
|
||||
helper.assertTrue(hash(Files.readString(journal(level, generator))).equals(previous.get("journal_sha256").getAsString()),
|
||||
"Reload must preserve the existing expansion journal bytes");
|
||||
var witness = previous.getAsJsonArray("gold_witness");
|
||||
var gold = new BlockPos(witness.get(0).getAsInt(), witness.get(1).getAsInt(), witness.get(2).getAsInt());
|
||||
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "A saved edit inside the city must survive without structure regeneration");
|
||||
var context = StructurePieceSerializationContext.fromLevel(level);
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(LostCityStructures.KEY);
|
||||
int savedPieces = 0, deferredChests = 0;
|
||||
for (var element : previous.getAsJsonArray("sites")) {
|
||||
var site = element.getAsJsonObject(); var pos = site.getAsJsonArray("start_chunk");
|
||||
var chunk = level.getChunk(pos.get(0).getAsInt(), pos.get(1).getAsInt(), ChunkStatus.FULL, true);
|
||||
var start = chunk.getStartForStructure(structure.value());
|
||||
helper.assertTrue(start != null && start.isValid(), "Native start must load from the existing saved chunk");
|
||||
helper.assertTrue(hash(start.createTag(context, start.getChunkPos()).toString()).equals(site.get("native_start_sha256").getAsString()),
|
||||
"Saved native structure pieces, layout and decoration seeds must remain byte-equivalent after restart");
|
||||
savedPieces += start.getPieces().size();
|
||||
for (var chestElement : site.getAsJsonArray("unopened_chests")) {
|
||||
var saved = chestElement.getAsJsonObject(); var at = saved.getAsJsonArray("position");
|
||||
var chestPos = new BlockPos(at.get(0).getAsInt(), at.get(1).getAsInt(), at.get(2).getAsInt());
|
||||
level.getChunk(chestPos);
|
||||
helper.assertTrue(level.getBlockEntity(chestPos) instanceof RandomizableContainerBlockEntity,
|
||||
"Deferred chest block entity must survive native region storage");
|
||||
var chest = (RandomizableContainerBlockEntity) level.getBlockEntity(chestPos);
|
||||
helper.assertTrue(chest.getLootTable() != null && chest.getLootTable().identifier().toString().equals(saved.get("table").getAsString())
|
||||
&& chest.getLootTableSeed() == saved.get("seed").getAsLong(), "Unopened loot table and seed must remain unconsumed after restart");
|
||||
deferredChests++;
|
||||
}
|
||||
}
|
||||
var readback = new ExpansionJournal(journal(level, generator).getParent(), level.getSeed(), generator.initialDiameter(), 14);
|
||||
helper.assertTrue(readback.healthy() && readback.islands().equals(session.islands()), "The generation-14 journal reopens with its exact immutable identities");
|
||||
result.put("saved_native_pieces", savedPieces); result.put("preserved_deferred_chests", deferredChests);
|
||||
result.put("native_pool_fixture", verifyPoolFixture(helper, false));
|
||||
result.put("gold_witness_preserved", true); result.put("journal_preserved", true); result.put("passed", true);
|
||||
write(level, result, true); helper.succeed();
|
||||
} catch (Exception exception) {
|
||||
result.put("passed", false); result.put("failure", exception.toString());
|
||||
try { write(level, result, true); } catch (IOException ignored) { /* Keep the original failure. */ }
|
||||
throw new IllegalStateException("Lost City native reload failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> report(ServerLevel level) {
|
||||
var report = new LinkedHashMap<String, Object>();
|
||||
report.put("seed", level.getSeed()); report.put("process_id", ProcessHandle.current().pid());
|
||||
report.put("world_directory", level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize().toString());
|
||||
report.put("scope", "Actual native district generation on a fresh development root and one activated city expansion. "
|
||||
+ "Archetype coverage is a separate deterministic fixture; it does not claim every building occurs on every seed. "
|
||||
+ "Zombie checks inspect the effective spawn table, not a guaranteed daylight population. No personal world is opened.");
|
||||
return report;
|
||||
}
|
||||
|
||||
private static Path journal(ServerLevel level, SanctuaryChunkGenerator generator) {
|
||||
return level.getServer().getWorldPath(LevelResource.ROOT).resolve("data").resolve(generator.journalDirectory()).resolve("expansions.json");
|
||||
}
|
||||
private static Path path(ServerLevel level, boolean reload) {
|
||||
return Path.of("diagnostics", "lost-city-alpha14" + (reload ? "-reload" : "") + "-seed-" + level.getSeed() + ".json");
|
||||
}
|
||||
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
|
||||
Path path = path(level, reload); Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
|
||||
}
|
||||
private static String hash(String text) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(java.nio.charset.StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -60,8 +60,9 @@ public final class PopulationDiagnostics {
|
||||
}
|
||||
if (level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator) {
|
||||
helper.assertTrue(generator.getBiomeSource() instanceof SanctuaryBiomeSource
|
||||
&& generator.getBiomeSource().possibleBiomes().size() == 44,
|
||||
"The unified generator must retain Population's ten layers and the 34 native expansion biomes");
|
||||
&& generator.getBiomeSource().possibleBiomes().size() == (((SanctuaryChunkGenerator) level.getChunkSource().getGenerator()).rootGeneration() == 14 ? 45 : 44)
|
||||
&& generator.getBiomeSource().possibleBiomes().stream().map(b -> b.getRegisteredName()).collect(java.util.stream.Collectors.toSet()).containsAll(SanctuaryBiomeSource.REQUIRED_BIOMES),
|
||||
"The unified generator retains all 44 historical biome holders, with Lost City added only in generation 14");
|
||||
helper.assertTrue(binding.random() != level.getChunkSource().randomState(),
|
||||
"The unified starter must use its real terrain state, never the expansion host fallback");
|
||||
} else {
|
||||
|
||||
+7
-3
@@ -33,6 +33,9 @@ final class UnifiedWorldSettingsDiagnostics {
|
||||
var standard = publicPreset.createWorldDimensions();
|
||||
helper.assertTrue(standard.overworld() instanceof SanctuaryChunkGenerator,
|
||||
"The sole Sanctuary world type must use the unified generator");
|
||||
var historical = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v13"))).value().createWorldDimensions();
|
||||
helper.assertTrue(historical.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 13 && !old.source().citiesEnabled(),
|
||||
"Historical alpha.13 codec retains its original biome source and generation");
|
||||
var generator = (SanctuaryChunkGenerator) standard.overworld();
|
||||
helper.assertTrue(generator.initialDiameter() == 724, "The public preset defaults to Medium / 724 blocks");
|
||||
for (var tag : List.of(WorldPresetTags.NORMAL, WorldPresetTags.EXTENDED)) {
|
||||
@@ -43,9 +46,10 @@ final class UnifiedWorldSettingsDiagnostics {
|
||||
}
|
||||
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, access);
|
||||
var serialized = SanctuaryChunkGenerator.CODEC.codec().encodeStart(ops, generator).getOrThrow().getAsJsonObject();
|
||||
var activeCodec = generator.rootGeneration() == 14 ? SanctuaryChunkGenerator.CITY_CODEC : SanctuaryChunkGenerator.CODEC;
|
||||
var serialized = activeCodec.codec().encodeStart(ops, generator).getOrThrow().getAsJsonObject();
|
||||
serialized.remove("initial_diameter");
|
||||
helper.assertTrue(SanctuaryChunkGenerator.CODEC.codec().parse(ops, serialized).getOrThrow().initialDiameter() == 724,
|
||||
helper.assertTrue(activeCodec.codec().parse(ops, serialized).getOrThrow().initialDiameter() == 724,
|
||||
"An omitted new-generation size defaults to Medium");
|
||||
for (int rejected : new int[]{0, 511, 513, 723, 725, 768, 1023, 1025, 2048}) {
|
||||
helper.assertTrue(SanctuaryChunkGenerator.DIAMETER_CODEC.parse(JsonOps.INSTANCE, new JsonPrimitive(rejected)).error().isPresent(),
|
||||
@@ -71,7 +75,7 @@ final class UnifiedWorldSettingsDiagnostics {
|
||||
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.initialDiameter() == diameter && reloaded.rootGeneration() == generator.rootGeneration(),
|
||||
"The selected diameter must survive real world-settings storage");
|
||||
helper.assertTrue(WorldGenSettings.CODEC.encodeStart(ops, restored).getOrThrow()
|
||||
.equals(WorldGenSettings.CODEC.encodeStart(ops, original).getOrThrow()),
|
||||
|
||||
+1
-1
@@ -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")) {
|
||||
if (Boolean.getBoolean("sanctuary.test.expansion") || Boolean.getBoolean("sanctuary.test.lostCity") || Boolean.getBoolean("sanctuary.test.voidReuse")) {
|
||||
long until = System.nanoTime() + 20_000_000L;
|
||||
long remaining;
|
||||
// Worker completion unparks the server; keep the pacing deadline despite those signals.
|
||||
|
||||
@@ -51,6 +51,9 @@ public final class SanctuaryMod implements ModInitializer {
|
||||
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("main_island"), MainIslandDensity.CODEC);
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v13"), SanctuaryChunkGenerator.CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v13"), SanctuaryBiomeSource.CODEC);
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v14"), SanctuaryChunkGenerator.CITY_CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v14"), SanctuaryBiomeSource.CITY_CODEC);
|
||||
fr.koka.sanctuary.worldgen.city.LostCityStructures.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);
|
||||
|
||||
@@ -79,11 +79,13 @@ public final class ExpansionAdmission {
|
||||
int radius = island.reservedRadius();
|
||||
int minX = Math.floorDiv(island.centerX() - radius, 16), maxX = Math.floorDiv(island.centerX() + radius, 16);
|
||||
int minZ = Math.floorDiv(island.centerZ() - radius, 16), maxZ = Math.floorDiv(island.centerZ() + radius, 16);
|
||||
if ((long) (maxX - minX + 1) * (maxZ - minZ + 1) > MAX_CHUNKS) throw new IOException("Expansion admission exceeds 4000 chunks");
|
||||
int limit = island.generation() == 14 ? 8000 : MAX_CHUNKS;
|
||||
if ((long) (maxX - minX + 1) * (maxZ - minZ + 1) > limit) throw new IOException("Expansion admission exceeds " + limit + " chunks");
|
||||
ChunkMap map = level.getChunkSource().chunkMap;
|
||||
if (!(map instanceof ExpansionChunkMapAccessor)) throw new IOException("Expansion chunk safety accessor is unavailable");
|
||||
List<ChunkPos> positions = new ArrayList<>();
|
||||
for (int z = minZ; z <= maxZ; z++) for (int x = minX; x <= maxX; x++) positions.add(new ChunkPos(x, z));
|
||||
for (int z = minZ; z <= maxZ; z++) for (int x = minX; x <= maxX; x++)
|
||||
if (island.generation() != 14 || island.ownsChunk(x, z)) positions.add(new ChunkPos(x, z));
|
||||
return positions;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ public class ExpansionBiomeSource extends BiomeSource {
|
||||
}
|
||||
|
||||
String selected = switch (island.climate()) {
|
||||
case "temperate" -> Math.abs(variation) < 0.13 ? "plains"
|
||||
case "temperate", "lost_city" -> Math.abs(variation) < 0.13 ? "plains"
|
||||
: variation < -0.33 ? "birch_forest" : moisture > 0.44 ? "flower_forest" : "forest";
|
||||
case "boreal" -> elevation > 244 ? "snowy_taiga" : variation < -0.2 ? "grove" : "taiga";
|
||||
case "glacial" -> elevation > 246 ? "frozen_peaks" : variation > 0.35 ? "ice_spikes" : "snowy_plains";
|
||||
|
||||
@@ -120,6 +120,7 @@ public class ExpansionChunkGenerator extends ChunkGenerator {
|
||||
}
|
||||
@Override public void createStructures(RegistryAccess registries, ChunkGeneratorStructureState state, StructureManager structures,
|
||||
ChunkAccess chunk, StructureTemplateManager templates, ResourceKey<Level> dimension) {
|
||||
fr.koka.sanctuary.worldgen.city.LostCityStructures.generate(this, registries, state, structures, chunk, templates, dimension);
|
||||
ExpansionStructures.generate(this, registries, state, structures, chunk, templates, dimension);
|
||||
}
|
||||
@Override public void spawnOriginalMobs(WorldGenRegion region) {
|
||||
|
||||
@@ -48,12 +48,12 @@ public final class ExpansionCommands {
|
||||
.then(createTree("create", false)).then(createTree("preview", true))
|
||||
.then(Commands.literal("quick").then(Commands.argument("direction", StringArgumentType.word())
|
||||
.suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.DIRECTIONS, b))
|
||||
.executes(c -> quick(c, null, 128, "natural"))
|
||||
.executes(c -> quick(c, null, 0, "natural"))
|
||||
.then(Commands.argument("climate", StringArgumentType.word())
|
||||
.suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.CLIMATES, b))
|
||||
.executes(c -> quick(c, word(c, "climate"), 128, "natural"))
|
||||
.then(Commands.argument("diameter", IntegerArgumentType.integer(64, 512))
|
||||
.suggests((c, b) -> SharedSuggestionProvider.suggest(List.of("64", "128", "256", "512"), b))
|
||||
.executes(c -> quick(c, word(c, "climate"), 0, "natural"))
|
||||
.then(Commands.argument("diameter", IntegerArgumentType.integer(64, 1024))
|
||||
.suggests((c, b) -> SharedSuggestionProvider.suggest(List.of("64", "128", "256", "512", "1024"), b))
|
||||
.executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"), "natural"))
|
||||
.then(Commands.argument("relief", StringArgumentType.word())
|
||||
.suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.RELIEFS, b))
|
||||
@@ -78,8 +78,8 @@ public final class ExpansionCommands {
|
||||
})
|
||||
.then(Commands.argument("direction", StringArgumentType.word()).suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.DIRECTIONS, b))
|
||||
.then(Commands.argument("climate", StringArgumentType.word()).suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.CLIMATES, b))
|
||||
.then(Commands.argument("diameter", IntegerArgumentType.integer(64, 512))
|
||||
.suggests((c, b) -> SharedSuggestionProvider.suggest(List.of("64", "128", "256", "512"), b))
|
||||
.then(Commands.argument("diameter", IntegerArgumentType.integer(64, 1024))
|
||||
.suggests((c, b) -> SharedSuggestionProvider.suggest(List.of("64", "128", "256", "512", "1024"), b))
|
||||
.then(Commands.argument("relief", StringArgumentType.word()).suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.RELIEFS, b))
|
||||
.executes(c -> create(c, preview, 0))
|
||||
.then(distanceArgument().executes(c -> create(c, preview, IntegerArgumentType.getInteger(c, "distance"))))))))));
|
||||
@@ -113,8 +113,11 @@ public final class ExpansionCommands {
|
||||
}
|
||||
String direction = word(c, "direction");
|
||||
if (!ExpansionIsland.DIRECTIONS.contains(direction)) throw new IllegalArgumentException("invalid_direction");
|
||||
var island = session.create("island_" + next, "sanctuary", direction,
|
||||
climate == null ? ExpansionIsland.directionalClimate(direction) : climate, diameter, relief, minimumDistance);
|
||||
String id = "island_" + next;
|
||||
int chosenDiameter = diameter != 0 ? diameter : session.islands().getFirst().generation() == 14
|
||||
? ExpansionRandomSize.choose(c.getSource().getLevel().getSeed(), id) : 128;
|
||||
var island = session.create(id, "sanctuary", direction,
|
||||
climate == null ? ExpansionIsland.directionalClimate(direction) : climate, chosenDiameter, relief, minimumDistance);
|
||||
describe(c, island, false);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
});
|
||||
@@ -122,6 +125,8 @@ public final class ExpansionCommands {
|
||||
private static void describe(CommandContext<CommandSourceStack> c, ExpansionIsland island, boolean preview) {
|
||||
c.getSource().sendSuccess(() -> Component.translatable(PREFIX + (preview ? "preview" : "created"),
|
||||
island.id(), island.centerX(), island.centerZ(), label("climate", island.climate()), island.diameter(), label("relief", island.relief())), false);
|
||||
if (!preview && ExpansionVoidReuse.requiresReload(c.getSource().getLevel(), island.id()))
|
||||
c.getSource().sendSuccess(() -> Component.translatable(PREFIX + "reuse_reload", island.id()), false);
|
||||
}
|
||||
private static int help(CommandContext<CommandSourceStack> context) {
|
||||
context.getSource().sendSuccess(() -> Component.translatable(PREFIX + "help"), false);
|
||||
|
||||
@@ -6,12 +6,13 @@ import java.util.List;
|
||||
|
||||
/** Immutable alpha.12 region contract. Directions place islands; climate remains an explicit choice. */
|
||||
public record ExpansionIsland(String id, String parent, String direction, String climate,
|
||||
int diameter, int centerX, int centerZ, long seed, String relief, String status, int generation) {
|
||||
int diameter, int centerX, int centerZ, long seed, String relief, String status, int generation, String reuseToken) {
|
||||
public static final int GENERATION = 12;
|
||||
public static final List<String> DIRECTIONS = List.of("north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest");
|
||||
public static final List<String> CLIMATES = List.of("temperate", "boreal", "glacial", "cold_humid", "humid", "tropical", "warm", "arid", "dry", "cold_dry", "autumn", "cherry");
|
||||
public static final List<String> CLIMATES = List.of("temperate", "boreal", "glacial", "cold_humid", "humid", "tropical", "warm", "arid", "dry", "cold_dry", "autumn", "cherry", "lost_city");
|
||||
public static final List<String> RELIEFS = List.of("natural", "peaks", "canyon", "volcano");
|
||||
public static final List<Integer> DIAMETERS = List.of(64, 128, 256, 512);
|
||||
public static final List<Integer> LEGACY_DIAMETERS = List.of(64, 128, 256, 512);
|
||||
public static final List<Integer> DIAMETERS = List.of(64, 128, 256, 512, 1024);
|
||||
public static final int DECORATION_MARGIN = 32;
|
||||
public static final int GENERATION_HALO = 176;
|
||||
public static final Codec<ExpansionIsland> CODEC = RecordCodecBuilder.create(i -> i.group(
|
||||
@@ -25,15 +26,24 @@ public record ExpansionIsland(String id, String parent, String direction, String
|
||||
Codec.LONG.fieldOf("seed").forGetter(ExpansionIsland::seed),
|
||||
Codec.STRING.fieldOf("relief").forGetter(ExpansionIsland::relief),
|
||||
Codec.STRING.fieldOf("status").forGetter(ExpansionIsland::status),
|
||||
Codec.INT.fieldOf("generation").forGetter(ExpansionIsland::generation)
|
||||
Codec.INT.fieldOf("generation").forGetter(ExpansionIsland::generation),
|
||||
Codec.STRING.optionalFieldOf("reuse_token", "").forGetter(ExpansionIsland::reuseToken)
|
||||
).apply(i, ExpansionIsland::new));
|
||||
|
||||
public ExpansionIsland(String id, String parent, String direction, String climate, int diameter,
|
||||
int centerX, int centerZ, long seed, String relief, String status, int generation) {
|
||||
this(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, status, generation, "");
|
||||
}
|
||||
|
||||
public ExpansionIsland {
|
||||
if (!id.matches("[a-z][a-z0-9_]{0,31}") || !parent.matches("[a-z][a-z0-9_]{0,31}")) throw new IllegalArgumentException("Invalid island identifier");
|
||||
if (!DIRECTIONS.contains(direction) || !CLIMATES.contains(climate) || !RELIEFS.contains(relief)) throw new IllegalArgumentException("Unknown island profile");
|
||||
if (!(DIAMETERS.contains(diameter) || (id.equals("sanctuary") && (diameter == 724 || generation == 13 && diameter == 1024)))) throw new IllegalArgumentException("Unsupported nominal diameter");
|
||||
if (!((generation == 14 ? DIAMETERS : LEGACY_DIAMETERS).contains(diameter) || (id.equals("sanctuary") && (diameter == 724 || (generation == 13 || generation == 14) && 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 && !(generation == 13 && id.equals("sanctuary")))) throw new IllegalArgumentException("Unsupported island state or generation");
|
||||
if (!List.of("reserved", "ready").contains(status) || (generation != GENERATION && generation != 14 && !(generation == 13 && id.equals("sanctuary")))) throw new IllegalArgumentException("Unsupported island state or generation");
|
||||
if (climate.equals("lost_city") && (generation != 14 || id.equals("sanctuary"))) throw new IllegalArgumentException("Lost City requires a generation 14 expansion");
|
||||
if (reuseToken == null || !reuseToken.isEmpty() && (generation != 14 || id.equals("sanctuary") || !reuseToken.matches("[0-9a-f]{64}")))
|
||||
throw new IllegalArgumentException("Invalid void reuse certificate token");
|
||||
}
|
||||
|
||||
public static ExpansionIsland origin(int diameter, long seed) {
|
||||
@@ -47,8 +57,8 @@ public record ExpansionIsland(String id, String parent, String direction, String
|
||||
public double referenceDiameter() { return origin() && diameter == 724 ? Math.sqrt(2) * 512 : 512; }
|
||||
public double horizontalScale() { return origin() ? 1.0 : diameter / referenceDiameter(); }
|
||||
public double radius() { return diameter / 2.0; }
|
||||
public int terrainRadius() { if (generation == 13 && origin()) return diameter == 724 ? 395 : diameter / 2 + 32; return origin() && diameter == 724 ? 395 : (int) Math.ceil(radius() * 1.125); }
|
||||
public int reservedRadius() { return terrainRadius() + DECORATION_MARGIN + GENERATION_HALO; }
|
||||
public int terrainRadius() { if ((generation == 13 || generation == 14) && origin()) return diameter == 724 ? 395 : diameter / 2 + 32; return origin() && diameter == 724 ? 395 : (int) Math.ceil(radius() * 1.125); }
|
||||
public int reservedRadius() { return terrainRadius() + DECORATION_MARGIN + (generation == 14 ? 0 : GENERATION_HALO); }
|
||||
public boolean contains(int x, int z) { return Math.hypot((double) x - centerX, (double) z - centerZ) < terrainRadius(); }
|
||||
public boolean ownsChunk(int chunkX, int chunkZ) {
|
||||
int x = Math.clamp(centerX, chunkX * 16, chunkX * 16 + 15);
|
||||
@@ -56,10 +66,12 @@ public record ExpansionIsland(String id, String parent, String direction, String
|
||||
return Math.hypot((double) x - centerX, (double) z - centerZ) <= terrainRadius() + DECORATION_MARGIN;
|
||||
}
|
||||
public boolean conflicts(ExpansionIsland other) {
|
||||
if (generation == 14 && other.generation() == 14) return ExpansionSpatialPlacement14.conflicts(this, other);
|
||||
return Math.abs((long) centerX - other.centerX) <= reservedRadius() + other.reservedRadius()
|
||||
&& Math.abs((long) centerZ - other.centerZ) <= reservedRadius() + other.reservedRadius();
|
||||
}
|
||||
public ExpansionIsland ready() { return new ExpansionIsland(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, "ready", generation); }
|
||||
public ExpansionIsland ready() { return new ExpansionIsland(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, "ready", generation, reuseToken); }
|
||||
public ExpansionIsland withReuseToken(String token) { return new ExpansionIsland(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, status, generation, token); }
|
||||
public static String directionalClimate(String direction) {
|
||||
return switch (direction) {
|
||||
case "north" -> "boreal"; case "northeast" -> "cold_humid"; case "east" -> "humid";
|
||||
|
||||
@@ -40,6 +40,7 @@ public final class ExpansionJournal {
|
||||
private final long seed;
|
||||
private final int rootDiameter;
|
||||
private final int rootGeneration;
|
||||
private final DirectorySync directorySync;
|
||||
private List<ExpansionIsland> islands;
|
||||
private boolean failed;
|
||||
|
||||
@@ -47,10 +48,16 @@ public final class ExpansionJournal {
|
||||
this(directory, seed, rootDiameter, ExpansionIsland.GENERATION);
|
||||
}
|
||||
public ExpansionJournal(Path directory, long seed, int rootDiameter, int rootGeneration) throws IOException {
|
||||
if (rootGeneration != 12 && rootGeneration != 13) throw new IOException("Unsupported root generation");
|
||||
if (rootDiameter != 512 && rootDiameter != 724 && !(rootGeneration == 13 && rootDiameter == 1024))
|
||||
this(directory, seed, rootDiameter, rootGeneration,
|
||||
directorySyncFor(directory.getFileSystem().getSeparator(), ExpansionJournal::forceDirectory));
|
||||
}
|
||||
|
||||
ExpansionJournal(Path directory, long seed, int rootDiameter, int rootGeneration, DirectorySync directorySync) throws IOException {
|
||||
if (rootGeneration != 12 && rootGeneration != 13 && rootGeneration != 14) throw new IOException("Unsupported root generation");
|
||||
if (rootDiameter != 512 && rootDiameter != 724 && !((rootGeneration == 13 || rootGeneration == 14) && rootDiameter == 1024))
|
||||
throw new IOException("Unsupported expansion root diameter");
|
||||
this.rootGeneration = rootGeneration;
|
||||
this.directorySync = java.util.Objects.requireNonNull(directorySync);
|
||||
this.directory = directory.toAbsolutePath().normalize();
|
||||
this.file = this.directory.resolve(FILE_NAME);
|
||||
this.seed = seed;
|
||||
@@ -110,7 +117,7 @@ public final class ExpansionJournal {
|
||||
// No fallback to a non-atomic replacement: failure must remain visible to the caller.
|
||||
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
temporary = null;
|
||||
try (FileChannel parent = FileChannel.open(directory, StandardOpenOption.READ)) { parent.force(true); }
|
||||
directorySync.force(directory);
|
||||
if (!read().equals(next)) throw new IOException("Expansion journal verification disagrees with the committed plan");
|
||||
} catch (IOException | RuntimeException e) {
|
||||
failed = true;
|
||||
@@ -123,6 +130,19 @@ public final class ExpansionJournal {
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface DirectorySync { void force(Path directory) throws IOException; }
|
||||
|
||||
static DirectorySync directorySyncFor(String separator, DirectorySync supportedSync) {
|
||||
// Windows FileChannel cannot open directory handles. Only this additional
|
||||
// directory flush is omitted: file force, atomic replacement and read-back remain mandatory.
|
||||
return "\\".equals(separator) ? directory -> {} : supportedSync;
|
||||
}
|
||||
|
||||
private static void forceDirectory(Path directory) throws IOException {
|
||||
try (FileChannel parent = FileChannel.open(directory, StandardOpenOption.READ)) { parent.force(true); }
|
||||
}
|
||||
|
||||
private List<ExpansionIsland> read() throws IOException {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Existing expansion directory has no regular journal file");
|
||||
if (Files.size(file) > MAX_BYTES) throw new IOException("Expansion journal exceeds its size limit");
|
||||
@@ -138,10 +158,13 @@ public final class ExpansionJournal {
|
||||
List<ExpansionIsland> decoded = new ArrayList<>();
|
||||
for (JsonElement element : entries) {
|
||||
JsonObject entry = element.getAsJsonObject();
|
||||
requireFields(entry, ISLAND_FIELDS);
|
||||
var fields = new java.util.HashSet<>(ISLAND_FIELDS);
|
||||
if (rootGeneration == 14 && entry.has("reuse_token")) fields.add("reuse_token");
|
||||
requireFields(entry, fields);
|
||||
decoded.add(new ExpansionIsland(string(entry, "id"), string(entry, "parent"), string(entry, "direction"), string(entry, "climate"),
|
||||
integer(entry, "diameter"), integer(entry, "center_x"), integer(entry, "center_z"), number(entry, "seed").longValueExact(),
|
||||
string(entry, "relief"), string(entry, "status"), integer(entry, "generation")));
|
||||
string(entry, "relief"), string(entry, "status"), integer(entry, "generation"),
|
||||
entry.has("reuse_token") ? string(entry, "reuse_token") : ""));
|
||||
}
|
||||
return validate(decoded);
|
||||
} catch (RuntimeException e) {
|
||||
@@ -158,12 +181,13 @@ public final class ExpansionJournal {
|
||||
previous.put("sanctuary", checked.getFirst());
|
||||
for (int i = 1; i < checked.size(); i++) {
|
||||
ExpansionIsland island = checked.get(i);
|
||||
if (island.generation() != (rootGeneration == 14 ? 14 : 12)) throw new IOException("Expansion generation does not match its world contract");
|
||||
if (previous.containsKey(island.id())) throw new IOException("Duplicate expansion identifier: " + island.id());
|
||||
ExpansionIsland parent = previous.get(island.parent());
|
||||
if (parent == null || !parent.status().equals("ready")) throw new IOException("Expansion parent must be an earlier ready region: " + island.parent());
|
||||
if (island.seed() != ExpansionIsland.deriveSeed(seed, island.id())) throw new IOException("Expansion seed differs from its saved identity: " + island.id());
|
||||
long dx = (long) island.centerX() - parent.centerX(), dz = (long) island.centerZ() - parent.centerZ();
|
||||
if (!followsDirection(island.direction(), dx, dz)) throw new IOException("Expansion position does not follow its direction: " + island.id());
|
||||
if (!(island.generation() == 14 ? ExpansionSpatialPlacement14.followsDirection(island.direction(), dx, dz) : followsDirection(island.direction(), dx, dz))) throw new IOException("Expansion position does not follow its direction: " + island.id());
|
||||
for (ExpansionIsland other : previous.values()) if (island.conflicts(other)) throw new IOException("Expansion reservations overlap: " + island.id() + " / " + other.id());
|
||||
previous.put(island.id(), island);
|
||||
}
|
||||
@@ -194,7 +218,9 @@ public final class ExpansionJournal {
|
||||
entry.addProperty("direction", island.direction()); entry.addProperty("climate", island.climate());
|
||||
entry.addProperty("diameter", island.diameter()); entry.addProperty("center_x", island.centerX()); entry.addProperty("center_z", island.centerZ());
|
||||
entry.addProperty("seed", island.seed()); entry.addProperty("relief", island.relief()); entry.addProperty("status", island.status());
|
||||
entry.addProperty("generation", island.generation()); array.add(entry);
|
||||
entry.addProperty("generation", island.generation());
|
||||
if (!island.reuseToken().isEmpty()) entry.addProperty("reuse_token", island.reuseToken());
|
||||
array.add(entry);
|
||||
}
|
||||
root.add("islands", array);
|
||||
return root;
|
||||
|
||||
@@ -23,12 +23,15 @@ public final class ExpansionPlacement {
|
||||
if (!ExpansionIsland.DIAMETERS.contains(diameter)) throw new IllegalArgumentException("invalid_diameter");
|
||||
if (relief == null || !ExpansionIsland.RELIEFS.contains(relief)) throw new IllegalArgumentException("invalid_relief");
|
||||
validateDistance(minimumDistance);
|
||||
int generation = islands.getFirst().generation() == 14 ? 14 : ExpansionIsland.GENERATION;
|
||||
if ((climate.equals("lost_city") || diameter == 1024) && generation != 14) throw new IllegalArgumentException("city_generation_required");
|
||||
var parent = islands.stream().filter(i -> i.id().equals(parentId)).findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("unknown_island"));
|
||||
if (!parent.status().equals("ready")) throw new IllegalArgumentException("parent_not_ready");
|
||||
int ordinal = ExpansionIsland.DIRECTIONS.indexOf(direction);
|
||||
var sample = new ExpansionIsland(id, parentId, direction, climate, diameter, 0, 0,
|
||||
ExpansionIsland.deriveSeed(worldSeed, id), relief, "reserved", ExpansionIsland.GENERATION);
|
||||
ExpansionIsland.deriveSeed(worldSeed, id), relief, "reserved", generation);
|
||||
if (generation == 14) return ExpansionSpatialPlacement14.candidates(islands, parent, sample, minimumDistance);
|
||||
int step = ((parent.reservedRadius() + sample.reservedRadius() + 63) / 16) * 16;
|
||||
boolean diagonal = DX[ordinal] != 0 && DZ[ordinal] != 0;
|
||||
int requestedComponent = (int) Math.ceil(minimumDistance / (diagonal ? Math.sqrt(2) : 1.0) / 16) * 16;
|
||||
@@ -40,7 +43,7 @@ public final class ExpansionPlacement {
|
||||
long z = parent.centerZ() + DZ[ordinal] * component;
|
||||
if (Math.abs(x) > MAX_DISTANCE || Math.abs(z) > MAX_DISTANCE) break;
|
||||
var candidate = new ExpansionIsland(id, parentId, direction, climate, diameter, (int) x, (int) z,
|
||||
sample.seed(), relief, "reserved", ExpansionIsland.GENERATION);
|
||||
sample.seed(), relief, "reserved", generation);
|
||||
if (islands.stream().noneMatch(candidate::conflicts)) candidates.add(candidate);
|
||||
}
|
||||
return List.copyOf(candidates);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
/** A quick request keeps its draw across placement retries; success records the size in the journal. */
|
||||
public final class ExpansionRandomSize {
|
||||
private ExpansionRandomSize() {}
|
||||
|
||||
public static int choose(long worldSeed, String islandId) {
|
||||
int draw = (int) Long.remainderUnsigned(ExpansionIsland.deriveSeed(worldSeed ^ 0xA14D1A6E7L, islandId), 100);
|
||||
if (draw < 40) return 64;
|
||||
if (draw < 70) return 128;
|
||||
if (draw < 88) return 256;
|
||||
if (draw < 97) return 512;
|
||||
return 1024;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
/** Set only after an authorized certificate was consumed, or its native saved marker was verified. */
|
||||
public interface ExpansionReuseMarker {
|
||||
String sanctuary$reuseMarker();
|
||||
void sanctuary$reuseMarker(String token);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
|
||||
/** Transient serialization state; never inferred merely from a chunk's location. */
|
||||
public interface ExpansionReuseSerializedData {
|
||||
void sanctuary$originalReuseNbt(CompoundTag tag);
|
||||
void sanctuary$serializedReuseMarker(String token);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
|
||||
/** Server-owned append-only admission and bounded preparation queue. No existing chunk is regenerated. */
|
||||
/** Server-owned append-only admission and bounded preparation; certified void reuse starts on reload. */
|
||||
public final class ExpansionRuntime {
|
||||
private static final Map<ServerLevel, Session> SESSIONS = new HashMap<>();
|
||||
private static final TicketType PREPARATION = new TicketType(0L, 2 | 8);
|
||||
@@ -38,6 +38,7 @@ public final class ExpansionRuntime {
|
||||
&& !java.nio.file.Files.isDirectory(directory, java.nio.file.LinkOption.NOFOLLOW_LINKS))
|
||||
throw new IOException("Initialized laboratory world is missing its expansion journal directory");
|
||||
var journal = new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter(), generator.rootGeneration());
|
||||
ExpansionVoidReuse.open(level, generator, journal.islands());
|
||||
generator.initialize(level, journal.islands());
|
||||
var session = new Session(level, generator, journal);
|
||||
SESSIONS.put(level, session);
|
||||
@@ -53,6 +54,7 @@ public final class ExpansionRuntime {
|
||||
ServerLevelEvents.UNLOAD.register((server, level) -> {
|
||||
Session session = SESSIONS.remove(level);
|
||||
if (session != null) session.releaseTicket();
|
||||
ExpansionVoidReuse.close(level);
|
||||
});
|
||||
ServerLifecycleEvents.SERVER_STOPPED.register(server -> SESSIONS.clear());
|
||||
}
|
||||
@@ -109,17 +111,20 @@ public final class ExpansionRuntime {
|
||||
for (var value : candidates) {
|
||||
ExpansionAdmission.checkDeadline(deadline);
|
||||
try {
|
||||
// Existing void is preserved: only proven occupation advances along the same ray.
|
||||
ExpansionAdmission.verify(level, value, deadline);
|
||||
var inspection = value.generation() == 14 ? ExpansionVoidReuse.inspect(level, value, deadline) : null;
|
||||
if (inspection == null) ExpansionAdmission.verify(level, value, deadline);
|
||||
if (probe(value) == null) throw new IllegalArgumentException("no_ground");
|
||||
var admitted = inspection == null ? value : value.withReuseToken(ExpansionVoidReuse.persist(inspection));
|
||||
var next = new ArrayList<>(islands());
|
||||
next.add(value);
|
||||
ExpansionAdmission.verifyHolders(level, value, deadline);
|
||||
next.add(admitted);
|
||||
if (inspection == null) ExpansionAdmission.verifyHolders(level, value, deadline);
|
||||
commitJournal(next); // Durability and re-read come before worldgen sees the island.
|
||||
generator.setIslands(journal.islands());
|
||||
if (!admitted.reuseToken().isEmpty()) ExpansionVoidReuse.deferred(level, admitted);
|
||||
publishActiveIslands();
|
||||
failure = "";
|
||||
enqueue(value);
|
||||
return value;
|
||||
if (ExpansionVoidReuse.requiresReload(level, admitted.id())) failure = admitted.id() + ": reload_required";
|
||||
else enqueue(admitted);
|
||||
return admitted;
|
||||
} catch (ExpansionAdmission.OccupiedException occupied) {
|
||||
// IO failures, timeouts and unsuitable terrain stop this request without a new draw.
|
||||
} finally {
|
||||
@@ -133,12 +138,19 @@ public final class ExpansionRuntime {
|
||||
requireHealthyJournal();
|
||||
var island = island(id);
|
||||
if (!island.status().equals("reserved")) return;
|
||||
if (ExpansionVoidReuse.requiresReload(level, id)) throw new IllegalArgumentException("reload_required");
|
||||
failure = "";
|
||||
enqueue(island);
|
||||
}
|
||||
private void enqueue(ExpansionIsland island) {
|
||||
String reuseFailure = ExpansionVoidReuse.failure(level, island.id());
|
||||
if (reuseFailure != null) { failure = island.id() + ": " + reuseFailure; return; }
|
||||
if (ExpansionVoidReuse.requiresReload(level, island.id())) return;
|
||||
if (jobs.stream().noneMatch(j -> j.island.id().equals(island.id()))) jobs.add(new Job(island));
|
||||
}
|
||||
private void publishActiveIslands() {
|
||||
generator.setIslands(journal.islands().stream().filter(i -> !ExpansionVoidReuse.requiresReload(level, i.id())).toList());
|
||||
}
|
||||
private void requireHealthyJournal() {
|
||||
if (!journal.healthy()) {
|
||||
stopForJournalFailure(null);
|
||||
@@ -213,6 +225,8 @@ public final class ExpansionRuntime {
|
||||
Job job = jobs.peek();
|
||||
if (job == null) return;
|
||||
try {
|
||||
String reuseFailure = ExpansionVoidReuse.failure(level, job.island.id());
|
||||
if (reuseFailure != null) throw new IllegalStateException(reuseFailure);
|
||||
if (job.pending != null) {
|
||||
if (!job.pending.isDone()) return;
|
||||
job.pending.join();
|
||||
@@ -222,9 +236,10 @@ public final class ExpansionRuntime {
|
||||
job.index++;
|
||||
}
|
||||
if (job.index == job.chunks.size()) {
|
||||
if (!ExpansionVoidReuse.complete(level, job.island)) throw new IllegalStateException("void_reuse_incomplete");
|
||||
var next = islands().stream().map(i -> i.id().equals(job.island.id()) ? i.ready() : i).toList();
|
||||
commitJournal(next);
|
||||
generator.setIslands(journal.islands());
|
||||
publishActiveIslands();
|
||||
jobs.remove();
|
||||
SanctuaryMod.LOGGER.info("Expansion {} ready: {} FULL chunks", job.island.id(), job.index);
|
||||
for (var player : level.players()) if (player.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_GAMEMASTER))
|
||||
@@ -255,7 +270,8 @@ public final class ExpansionRuntime {
|
||||
this.island = island;
|
||||
int radius = island.terrainRadius() + ExpansionIsland.DECORATION_MARGIN;
|
||||
for (int x = (island.centerX() - radius) >> 4; x <= (island.centerX() + radius) >> 4; x++)
|
||||
for (int z = (island.centerZ() - radius) >> 4; z <= (island.centerZ() + radius) >> 4; z++) chunks.add(new ChunkPos(x, z));
|
||||
for (int z = (island.centerZ() - radius) >> 4; z <= (island.centerZ() + radius) >> 4; z++)
|
||||
if (island.generation() != 14 || island.ownsChunk(x, z)) chunks.add(new ChunkPos(x, z));
|
||||
}
|
||||
}
|
||||
public static boolean safeLanding(ServerLevel level, BlockPos feet) {
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Generation 14 incremental Poisson-disc rejection with variable island radii.
|
||||
* Accepted regions keep a minimum separation; candidate draws are reproducible and bounded.
|
||||
* The requested direction selects a 45-degree sector, independently of biome and climate.
|
||||
* Generation dependencies are read neighborhoods, not ownership reservations. */
|
||||
public final class ExpansionSpatialPlacement14 {
|
||||
public static final int MAX_ATTEMPTS = 32;
|
||||
// Distinct circular footprints must also avoid owning the same 16x16 corner chunk.
|
||||
private static final int CHUNK_CORNER_GUARD = 24;
|
||||
private static final int[] DX = {0, 1, 1, 1, 0, -1, -1, -1};
|
||||
private static final int[] DZ = {-1, -1, 0, 1, 1, 1, 0, -1};
|
||||
private static final double HALF_SECTOR_TANGENT = Math.tan(Math.PI / 8);
|
||||
|
||||
private ExpansionSpatialPlacement14() {}
|
||||
|
||||
/** The caller validates IDs/profile choices before constructing the prototype.
|
||||
* Alternatives may overlap each other: only one is admitted, then added to islands.
|
||||
* Subsequent calls reject its complete write footprint, including pending reservations. */
|
||||
public static List<ExpansionIsland> candidates(List<ExpansionIsland> islands,
|
||||
ExpansionIsland parent, ExpansionIsland prototype, int minimumDistance) {
|
||||
if (parent.generation() != 14 || prototype.generation() != 14 || prototype.origin())
|
||||
throw new IllegalArgumentException("Spatial placement requires generation 14 expansions");
|
||||
if (!parent.status().equals("ready")) throw new IllegalArgumentException("parent_not_ready");
|
||||
if (!prototype.parent().equals(parent.id())) throw new IllegalArgumentException("unknown_island");
|
||||
ExpansionPlacement.validateDistance(minimumDistance);
|
||||
int direction = ExpansionIsland.DIRECTIONS.indexOf(prototype.direction());
|
||||
if (direction < 0) throw new IllegalArgumentException("invalid_direction");
|
||||
double angle = Math.atan2(DZ[direction], DX[direction]);
|
||||
double start = Math.max(minimumSeparation(parent, prototype), minimumDistance);
|
||||
double annulus = Math.max(64, writeRadius(prototype) * .70);
|
||||
long drawSeed = mix(prototype.seed() ^ parent.seed() ^ 0xA14D15C5EEDL);
|
||||
List<ExpansionIsland> result = new ArrayList<>();
|
||||
for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
long draw = mix(drawSeed + attempt * 0x9E3779B97F4A7C15L);
|
||||
// Eight nearby candidates are exhausted before moving to each farther annulus.
|
||||
// Eighteen degrees leave snapping room inside the exact 22.5-degree sector.
|
||||
double candidateAngle = angle + (unit(draw) * 2 - 1) * Math.PI / 10;
|
||||
double distance = start + 24 + (attempt / 8 + unit(mix(draw ^ 0xD15C14L))) * annulus;
|
||||
long x = Math.round((parent.centerX() + Math.cos(candidateAngle) * distance) / 16) * 16;
|
||||
long z = Math.round((parent.centerZ() + Math.sin(candidateAngle) * distance) / 16) * 16;
|
||||
if (Math.abs(x) > ExpansionPlacement.MAX_DISTANCE || Math.abs(z) > ExpansionPlacement.MAX_DISTANCE) continue;
|
||||
if (!followsDirection(prototype.direction(), x - parent.centerX(), z - parent.centerZ())) continue;
|
||||
if (Math.hypot(x - parent.centerX(), z - parent.centerZ()) < minimumDistance) continue;
|
||||
ExpansionIsland candidate = new ExpansionIsland(prototype.id(), prototype.parent(), prototype.direction(),
|
||||
prototype.climate(), prototype.diameter(), (int) x, (int) z, prototype.seed(), prototype.relief(), "reserved", 14);
|
||||
if (islands.stream().noneMatch(saved -> conflicts(candidate, saved)) && !result.contains(candidate)) result.add(candidate);
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** All coordinates writable by this island, including decoration spilling beyond its terrain. */
|
||||
public static int writeRadius(ExpansionIsland island) {
|
||||
return island.terrainRadius() + ExpansionIsland.DECORATION_MARGIN;
|
||||
}
|
||||
|
||||
/** Vanilla neighboring stages may read this far, without claiming or changing those chunks. */
|
||||
public static int readRadius(ExpansionIsland island) {
|
||||
return writeRadius(island) + ExpansionIsland.GENERATION_HALO;
|
||||
}
|
||||
|
||||
public static int minimumSeparation(ExpansionIsland a, ExpansionIsland b) {
|
||||
int relevantSize = a.origin() ? b.diameter() : b.origin() ? a.diameter() : Math.max(a.diameter(), b.diameter());
|
||||
return writeRadius(a) + writeRadius(b) + CHUNK_CORNER_GUARD + extraGap(relevantSize);
|
||||
}
|
||||
|
||||
public static boolean conflicts(ExpansionIsland a, ExpansionIsland b) {
|
||||
double dx = (double) a.centerX() - b.centerX(), dz = (double) a.centerZ() - b.centerZ();
|
||||
return dx * dx + dz * dz < Math.pow(minimumSeparation(a, b), 2);
|
||||
}
|
||||
|
||||
/** Direction validation for persisted v14 records; v12/v13 retain their exact-ray rule. */
|
||||
public static boolean followsDirection(String direction, long dx, long dz) {
|
||||
int ordinal = ExpansionIsland.DIRECTIONS.indexOf(direction);
|
||||
if (ordinal < 0) return false;
|
||||
double projection = (double) dx * DX[ordinal] + (double) dz * DZ[ordinal];
|
||||
double cross = (double) dx * DZ[ordinal] - (double) dz * DX[ordinal];
|
||||
return projection > 0 && Math.abs(cross) <= projection * HALF_SECTOR_TANGENT + 1.0e-9;
|
||||
}
|
||||
|
||||
private static int extraGap(int diameter) {
|
||||
if (diameter <= 64) return 0;
|
||||
if (diameter <= 128) return 16;
|
||||
if (diameter <= 256) return 48;
|
||||
if (diameter <= 512) return 96;
|
||||
return 160;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,9 @@ public final class ExpansionStructures {
|
||||
RandomState randomState, StructureStart start, boolean temple) {
|
||||
// Native beard adaptation already expands this box by 12 blocks when applicable.
|
||||
BoundingBox total = start.getBoundingBox();
|
||||
if (generator instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator city
|
||||
&& city.lostCityPlan(island).intersects(total.minX(), total.minZ(), total.maxX(), total.maxZ()))
|
||||
return "occupied-city-district";
|
||||
if (island.origin() && generator instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator unified) {
|
||||
var binding = unified.starterContext();
|
||||
int centreX = (total.minX() + total.maxX()) / 2, centreZ = (total.minZ() + total.maxZ()) / 2;
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.nbt.Tag;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
|
||||
/** Strict read-only proof of generated, untouched Sanctuary void, including its independent stores.
|
||||
* Fingerprints describe verified semantic emptiness; time counters and light caches may change on save. */
|
||||
public record ExpansionVoidCertificate(int x, int z, String terrain, String entities, String poi, boolean absent) {
|
||||
private static final Set<String> CHUNK_KEYS = Set.of("DataVersion", "xPos", "yPos", "zPos", "Status", "LastUpdate",
|
||||
"InhabitedTime", "isLightOn", "sections", "Heightmaps", "structures", "block_entities", "entities",
|
||||
"block_ticks", "fluid_ticks", "PostProcessing", "UpgradeData");
|
||||
private static final Set<String> HEIGHTMAPS = Set.of("WORLD_SURFACE_WG", "WORLD_SURFACE", "OCEAN_FLOOR_WG",
|
||||
"OCEAN_FLOOR", "MOTION_BLOCKING", "MOTION_BLOCKING_NO_LEAVES");
|
||||
private static final Set<String> FULL_HEIGHTMAPS = Set.of("WORLD_SURFACE", "OCEAN_FLOOR", "MOTION_BLOCKING", "MOTION_BLOCKING_NO_LEAVES");
|
||||
|
||||
public ChunkPos position() { return new ChunkPos(x, z); }
|
||||
|
||||
public static ExpansionVoidCertificate certify(ChunkPos pos, CompoundTag terrain, Optional<CompoundTag> entities,
|
||||
Optional<CompoundTag> poi, int dataVersion) throws IOException {
|
||||
return new ExpansionVoidCertificate(pos.x(), pos.z(), terrainFingerprint(pos, terrain, dataVersion),
|
||||
entityFingerprint(pos, entities, dataVersion), poiFingerprint(pos, poi, dataVersion), false);
|
||||
}
|
||||
|
||||
public static ExpansionVoidCertificate absent(ChunkPos pos, Optional<CompoundTag> entities,
|
||||
Optional<CompoundTag> poi, int dataVersion) throws IOException {
|
||||
return new ExpansionVoidCertificate(pos.x(), pos.z(), "absent", entityFingerprint(pos, entities, dataVersion),
|
||||
poiFingerprint(pos, poi, dataVersion), true);
|
||||
}
|
||||
|
||||
public static String terrainFingerprint(ChunkPos pos, CompoundTag tag, int dataVersion) throws IOException {
|
||||
keys(tag, CHUNK_KEYS, "chunk");
|
||||
for (String name : Set.of("DataVersion", "xPos", "yPos", "zPos")) type(tag, name, Tag.TAG_INT);
|
||||
type(tag, "LastUpdate", Tag.TAG_LONG); type(tag, "InhabitedTime", Tag.TAG_LONG);
|
||||
require(tag.getLong("LastUpdate").orElse(-1L) >= 0 && tag.getLong("InhabitedTime").orElse(-1L) >= 0,
|
||||
"invalid chunk time counter");
|
||||
if (tag.contains("isLightOn")) booleanByte(tag, "isLightOn");
|
||||
require(tag.getInt("DataVersion").orElse(-1) == dataVersion, "different data version");
|
||||
require(tag.getInt("xPos").orElse(Integer.MIN_VALUE) == pos.x()
|
||||
&& tag.getInt("zPos").orElse(Integer.MIN_VALUE) == pos.z() && tag.getInt("yPos").orElse(-1) == 0,
|
||||
"unexpected chunk coordinates or vertical extent");
|
||||
require(tag.getString("Status").orElse("").equals("minecraft:full"), "chunk is not FULL");
|
||||
for (String name : Set.of("block_entities", "block_ticks", "fluid_ticks"))
|
||||
require(list(tag, name).isEmpty(), "nonempty " + name);
|
||||
emptyList(tag, "entities"); // Legacy in-chunk entities are optional for native FULL chunks.
|
||||
if (tag.contains("UpgradeData")) require(compound(tag, "UpgradeData").isEmpty(), "pending upgrade data");
|
||||
var structures = compound(tag, "structures");
|
||||
keys(structures, Set.of("starts", "References"), "structures");
|
||||
for (String name : Set.of("starts", "References")) require(compound(structures, name).isEmpty(), "structure start or reference");
|
||||
var heightmaps = compound(tag, "Heightmaps");
|
||||
keys(heightmaps, HEIGHTMAPS, "heightmaps");
|
||||
require(heightmaps.keySet().containsAll(FULL_HEIGHTMAPS), "missing FULL heightmap");
|
||||
for (String name : heightmaps.keySet()) {
|
||||
var data = heightmaps.getLongArray(name).orElseThrow(() -> failure("invalid heightmap"));
|
||||
// Native 384-block height maps use nine bits: seven columns per long, 37 longs for 16x16.
|
||||
require(data.length == 37, "invalid heightmap length");
|
||||
for (long value : data) require(value == 0L, "nonempty heightmap");
|
||||
}
|
||||
var postProcessing = list(tag, "PostProcessing");
|
||||
require(postProcessing.size() == 24, "invalid post-processing section count");
|
||||
for (var section : postProcessing)
|
||||
require(section instanceof ListTag values && values.isEmpty(), "post-processing work");
|
||||
Set<Integer> seen = new HashSet<>();
|
||||
for (var entry : list(tag, "sections")) {
|
||||
require(entry instanceof CompoundTag, "invalid section");
|
||||
var section = (CompoundTag) entry;
|
||||
keys(section, Set.of("Y", "block_states", "biomes", "BlockLight", "SkyLight"), "section");
|
||||
type(section, "Y", Tag.TAG_BYTE);
|
||||
int y = section.getByte("Y").map(Byte::intValue).orElse(Integer.MIN_VALUE);
|
||||
require(y >= -1 && y <= 24 && seen.add(y), "duplicate or outside section");
|
||||
for (String light : Set.of("BlockLight", "SkyLight")) if (section.contains(light))
|
||||
require(section.getByteArray(light).map(bytes -> bytes.length == 2048).orElse(false), "invalid light cache");
|
||||
if (y < 0 || y >= 24) {
|
||||
require(!section.contains("block_states") && !section.contains("biomes"), "terrain outside 384-block world");
|
||||
require(section.contains("BlockLight") || section.contains("SkyLight"), "empty non-native light halo");
|
||||
continue;
|
||||
}
|
||||
var blocks = compound(section, "block_states");
|
||||
keys(blocks, Set.of("palette", "data"), "block palette");
|
||||
var palette = list(blocks, "palette");
|
||||
require(palette.size() == 1, "non-pristine block palette");
|
||||
// 26.3 encodes states without properties as strings; its full dispatch codec uses lowercase id.
|
||||
if (palette.getFirst() instanceof CompoundTag only) {
|
||||
keys(only, Set.of("id"), "block state");
|
||||
require(only.getString("id").orElse("").equals("minecraft:air"), "block or fluid present");
|
||||
} else require(palette.getString(0).orElse("").equals("minecraft:air"), "block or fluid present");
|
||||
zeroData(blocks);
|
||||
var biomes = compound(section, "biomes");
|
||||
keys(biomes, Set.of("palette", "data"), "biome palette");
|
||||
var biomePalette = list(biomes, "palette");
|
||||
require(biomePalette.size() == 1 && biomePalette.getString(0).orElse("").equals("minecraft:the_void"),
|
||||
"chunk is outside the pristine void biome");
|
||||
zeroData(biomes);
|
||||
}
|
||||
for (int y = 0; y < 24; y++) require(seen.contains(y), "missing section; emptiness cannot be proved");
|
||||
return digest("sanctuary-void-v1|" + dataVersion + "|" + pos.x() + "|" + pos.z() + "|384|air|the_void");
|
||||
}
|
||||
|
||||
public static String entityFingerprint(ChunkPos pos, Optional<CompoundTag> raw, int dataVersion) throws IOException {
|
||||
if (raw.isPresent()) {
|
||||
var tag = raw.get(); keys(tag, Set.of("DataVersion", "Position", "Entities"), "entity storage");
|
||||
type(tag, "DataVersion", Tag.TAG_INT);
|
||||
require(tag.getInt("DataVersion").orElse(-1) == dataVersion, "different entity data version");
|
||||
var coordinates = tag.getIntArray("Position").orElseThrow(() -> failure("missing entity position"));
|
||||
require(coordinates.length == 2 && coordinates[0] == pos.x() && coordinates[1] == pos.z(), "misplaced entity storage");
|
||||
require(list(tag, "Entities").isEmpty(), "saved entity present");
|
||||
}
|
||||
return digest("sanctuary-empty-entities-v1|" + pos.x() + "|" + pos.z());
|
||||
}
|
||||
|
||||
public static String poiFingerprint(ChunkPos pos, Optional<CompoundTag> raw, int dataVersion) throws IOException {
|
||||
if (raw.isPresent()) {
|
||||
var tag = raw.get(); keys(tag, Set.of("DataVersion", "Sections"), "point-of-interest storage");
|
||||
type(tag, "DataVersion", Tag.TAG_INT);
|
||||
require(tag.getInt("DataVersion").orElse(-1) == dataVersion, "different point-of-interest data version");
|
||||
var sections = compound(tag, "Sections");
|
||||
for (String key : sections.keySet()) {
|
||||
int y;
|
||||
try { y = Integer.parseInt(key); } catch (NumberFormatException error) { throw failure("invalid point-of-interest section"); }
|
||||
require(y >= 0 && y < 24 && key.equals(Integer.toString(y)), "outside or noncanonical point-of-interest section");
|
||||
var section = compound(sections, key); keys(section, Set.of("Valid", "Records"), "point-of-interest section");
|
||||
booleanByte(section, "Valid");
|
||||
require(list(section, "Records").isEmpty(), "saved point of interest");
|
||||
}
|
||||
}
|
||||
return digest("sanctuary-empty-poi-v1|" + pos.x() + "|" + pos.z());
|
||||
}
|
||||
|
||||
private static void zeroData(CompoundTag tag) throws IOException {
|
||||
// A native single-valued palette has no packed indices; nonempty arrays are not proof of pristine storage.
|
||||
if (tag.contains("data")) require(tag.getLongArray("data")
|
||||
.map(values -> values.length == 0).orElse(false), "unexpected single-valued palette data");
|
||||
}
|
||||
private static void type(CompoundTag tag, String key, byte expected) throws IOException {
|
||||
require(tag.get(key) != null && tag.get(key).getId() == expected, "missing or invalid type for " + key);
|
||||
}
|
||||
private static void booleanByte(CompoundTag tag, String key) throws IOException {
|
||||
type(tag, key, Tag.TAG_BYTE);
|
||||
byte value = tag.getByte(key).orElse((byte) -1);
|
||||
require(value == 0 || value == 1, "invalid boolean " + key);
|
||||
}
|
||||
private static void emptyList(CompoundTag tag, String key) throws IOException {
|
||||
if (tag.contains(key)) require(list(tag, key).isEmpty(), "nonempty " + key);
|
||||
}
|
||||
private static ListTag list(CompoundTag tag, String key) throws IOException {
|
||||
return tag.getList(key).orElseThrow(() -> failure("missing or invalid " + key));
|
||||
}
|
||||
private static CompoundTag compound(CompoundTag tag, String key) throws IOException {
|
||||
return tag.getCompound(key).orElseThrow(() -> failure("missing or invalid " + key));
|
||||
}
|
||||
private static void keys(CompoundTag tag, Set<String> allowed, String where) throws IOException {
|
||||
require(allowed.containsAll(tag.keySet()), "foreign or unsupported metadata in " + where);
|
||||
}
|
||||
private static void require(boolean condition, String reason) throws IOException { if (!condition) throw failure(reason); }
|
||||
private static IOException failure(String reason) { return new IOException("void_not_pristine: " + reason); }
|
||||
public static String digest(String value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.mixin.ExpansionChunkMapAccessor;
|
||||
import fr.koka.sanctuary.mixin.ExpansionVoidEntityManagerAccess;
|
||||
import fr.koka.sanctuary.mixin.ExpansionVoidEntityStorageAccess;
|
||||
import fr.koka.sanctuary.mixin.ExpansionVoidPoiStorageAccess;
|
||||
import fr.koka.sanctuary.mixin.ExpansionVoidServerLevelAccess;
|
||||
import fr.koka.sanctuary.mixin.ExpansionVoidTicksAccess;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.SectionPos;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.ai.village.poi.PoiManager;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.ImposterProtoChunk;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.chunk.UpgradeData;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.chunk.storage.SerializableChunkData;
|
||||
import net.minecraft.world.level.chunk.storage.SimpleRegionStorage;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.minecraft.world.ticks.LevelTicks;
|
||||
|
||||
/** Generation-14-only transaction: certify untouched void, bind an immutable manifest in the journal,
|
||||
* then let Minecraft rebuild certified chunks on the next world load. Never patches a live FULL chunk. */
|
||||
public final class ExpansionVoidReuse {
|
||||
public static final String NBT_MARKER = "sanctuary:void_reuse";
|
||||
private static final Gson JSON = new Gson();
|
||||
private static final Map<ServerLevel, State> STATES = new WeakHashMap<>();
|
||||
// Immutable snapshots are read by Minecraft's asynchronous NBT parser; mutable session state stays on the server thread.
|
||||
private static volatile Map<ServerLevel, Set<Long>> CAPTURE = Map.of();
|
||||
private static final int BATCH = 64;
|
||||
private static final int MAX_CHUNKS = 8000;
|
||||
private static final int MAX_MANIFEST_BYTES = 4_000_000;
|
||||
|
||||
private ExpansionVoidReuse() {}
|
||||
|
||||
public record Inspection(ServerLevel level, ExpansionIsland island, long deadline,
|
||||
List<ExpansionVoidCertificate> certificates) {
|
||||
public Inspection { certificates = List.copyOf(certificates); }
|
||||
}
|
||||
private record Manifest(String token, ExpansionIsland island, List<ExpansionVoidCertificate> certificates) {}
|
||||
private record Claim(Manifest manifest, ExpansionVoidCertificate certificate) {}
|
||||
private static final class State {
|
||||
final Map<String, Manifest> manifests = new HashMap<>();
|
||||
final Map<Long, Claim> claims = new HashMap<>();
|
||||
final Set<String> deferred = new HashSet<>();
|
||||
final Set<Long> completed = new HashSet<>();
|
||||
final Map<String, String> failures = new HashMap<>();
|
||||
}
|
||||
public record LoadDecision(ProtoChunk replacement, String trustedMarker) {
|
||||
static final LoadDecision KEEP = new LoadDecision(null, "");
|
||||
}
|
||||
|
||||
/** Every potential write chunk is inspected; existing halo neighbours are deliberately read-only. */
|
||||
public static Inspection inspect(ServerLevel level, ExpansionIsland island, long deadline) throws IOException {
|
||||
requireServerThread(level);
|
||||
if (!(level.getChunkSource().getGenerator() instanceof ExpansionChunkGenerator generator)
|
||||
|| generator.rootGeneration() != 14 || island.generation() != 14 || island.origin())
|
||||
throw new IOException("Void reuse is restricted to a new generation-14 expansion");
|
||||
List<ChunkPos> positions = positions(island);
|
||||
var entityStorage = entityRegion(level);
|
||||
var poiStorage = ((ExpansionVoidPoiStorageAccess) level.getPoiManager()).sanctuary$simpleRegionStorage();
|
||||
var certificates = new ArrayList<ExpansionVoidCertificate>();
|
||||
var occupiedPoi = occupiedPoiChunks(level);
|
||||
for (int offset = 0; offset < positions.size(); offset += BATCH) {
|
||||
ExpansionAdmission.checkDeadline(deadline);
|
||||
int end = Math.min(offset + BATCH, positions.size());
|
||||
var reads = new ArrayList<Read>();
|
||||
for (int i = offset; i < end; i++) {
|
||||
ChunkPos pos = positions.get(i);
|
||||
reads.add(new Read(pos, level.getChunkSource().chunkMap.read(pos), entityStorage.read(pos), poiStorage.read(pos)));
|
||||
}
|
||||
await(CompletableFuture.allOf(reads.stream().flatMap(read -> java.util.stream.Stream.of(
|
||||
read.terrain(), read.entities(), read.poi())).toArray(CompletableFuture[]::new)), deadline);
|
||||
IOException refusal = null;
|
||||
for (Read read : reads) {
|
||||
// Drain all submitted operations even if a preceding position is occupied.
|
||||
var disk = await(read.terrain(), deadline);
|
||||
var entities = await(read.entities(), deadline);
|
||||
var poi = await(read.poi(), deadline);
|
||||
try {
|
||||
verifyMemory(level, read.position(), false, occupiedPoi);
|
||||
ExpansionVoidCertificate.entityFingerprint(read.position(), entities, dataVersion());
|
||||
ExpansionVoidCertificate.poiFingerprint(read.position(), poi, dataVersion());
|
||||
ChunkAccess loaded = loaded(level, read.position());
|
||||
if (disk.isPresent()) ExpansionVoidCertificate.terrainFingerprint(read.position(), disk.get(), dataVersion());
|
||||
if (loaded != null) {
|
||||
// The holder is inspected as-is, never promoted, cancelled or reconfigured.
|
||||
if (!(unwrap(loaded) instanceof LevelChunk)) throw new IOException("void_reuse_retry: chunk generation is in progress at " + read.position());
|
||||
var snapshot = SerializableChunkData.copyOf(level, unwrap(loaded)).write();
|
||||
certificates.add(ExpansionVoidCertificate.certify(read.position(), snapshot, entities, poi, dataVersion()));
|
||||
} else if (disk.isPresent()) {
|
||||
certificates.add(ExpansionVoidCertificate.certify(read.position(), disk.get(), entities, poi, dataVersion()));
|
||||
} else {
|
||||
certificates.add(ExpansionVoidCertificate.absent(read.position(), entities, poi, dataVersion()));
|
||||
}
|
||||
} catch (IOException error) { if (refusal == null) refusal = error; }
|
||||
}
|
||||
if (refusal != null) {
|
||||
if (refusal.getMessage().startsWith("void_not_pristine"))
|
||||
throw new ExpansionAdmission.OccupiedException(refusal.getMessage());
|
||||
throw refusal;
|
||||
}
|
||||
}
|
||||
ExpansionAdmission.checkDeadline(deadline);
|
||||
return new Inspection(level, island, deadline, certificates);
|
||||
}
|
||||
|
||||
/** Must run immediately before committing the reservation, on the same server turn. */
|
||||
public static String persist(Inspection inspection) throws IOException {
|
||||
Inspection verified = inspect(inspection.level(), inspection.island(), inspection.deadline());
|
||||
if (!verified.certificates().equals(inspection.certificates())) throw new IOException("void_reuse_retry: inspected chunks changed before commit");
|
||||
if (verified.certificates().stream().allMatch(ExpansionVoidCertificate::absent)) return "";
|
||||
var body = new JsonObject();
|
||||
body.addProperty("schema", 1); body.addProperty("world_seed", inspection.level().getSeed());
|
||||
body.addProperty("data_version", dataVersion());
|
||||
body.add("island", ExpansionIsland.CODEC.encodeStart(JsonOps.INSTANCE, identity(inspection.island())).getOrThrow());
|
||||
body.add("certificates", JSON.toJsonTree(inspection.certificates()));
|
||||
String encoded = body + "\n";
|
||||
String token = ExpansionVoidCertificate.digest(encoded);
|
||||
Path file = manifestPath(inspection.level(), token);
|
||||
Files.createDirectories(file.getParent());
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!readText(file).equals(encoded)) throw new IOException("Void certificate manifest changed on disk");
|
||||
return token;
|
||||
}
|
||||
Path temporary = Files.createTempFile(file.getParent(), ".certificate-", ".tmp");
|
||||
try {
|
||||
try (FileChannel output = FileChannel.open(temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
|
||||
ByteBuffer bytes = StandardCharsets.UTF_8.encode(encoded);
|
||||
while (bytes.hasRemaining()) output.write(bytes);
|
||||
output.force(true);
|
||||
}
|
||||
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE);
|
||||
if (!file.getFileSystem().getSeparator().equals("\\")) {
|
||||
try (FileChannel parent = FileChannel.open(file.getParent(), StandardOpenOption.READ)) { parent.force(true); }
|
||||
}
|
||||
if (!readText(file).equals(encoded)) throw new IOException("Void certificate read-back failed");
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Run before publishing the journal islands to the generator at level startup. Missing authority aborts startup. */
|
||||
public static void open(ServerLevel level, ExpansionChunkGenerator generator, List<ExpansionIsland> islands) throws IOException {
|
||||
requireServerThread(level); close(level);
|
||||
if (generator.rootGeneration() != 14) return;
|
||||
var state = new State();
|
||||
for (var island : islands) if (!island.reuseToken().isEmpty()) add(state, loadManifest(level, island));
|
||||
// Validate every transaction before writing even an EMPTY placeholder or publishing any island.
|
||||
// In particular, an originally absent position may have been explored during the deferred session.
|
||||
var absent = new ArrayList<Claim>();
|
||||
for (var manifest : state.manifests.values()) absent.addAll(preflight(level, manifest));
|
||||
stageAbsent(level, absent);
|
||||
STATES.put(level, state);
|
||||
var captures = new HashMap<>(CAPTURE);
|
||||
captures.put(level, Set.copyOf(state.claims.keySet()));
|
||||
CAPTURE = Map.copyOf(captures);
|
||||
}
|
||||
|
||||
public static void close(ServerLevel level) {
|
||||
STATES.remove(level);
|
||||
var captures = new HashMap<>(CAPTURE); captures.remove(level); CAPTURE = Map.copyOf(captures);
|
||||
}
|
||||
|
||||
public static boolean needsOriginal(net.minecraft.world.level.LevelHeightAccessor height, CompoundTag raw) {
|
||||
if (!(height instanceof ServerLevel level)) return false;
|
||||
var claims = CAPTURE.get(level);
|
||||
if (claims == null || claims.isEmpty()) return false;
|
||||
var x = raw.getInt("xPos"); var z = raw.getInt("zPos");
|
||||
return x.isPresent() && z.isPresent() && claims.contains(new ChunkPos(x.get(), z.get()).pack());
|
||||
}
|
||||
|
||||
/** The just-committed reservation stays invisible until a real reload; a live FULL holder is never reset. */
|
||||
public static void deferred(ServerLevel level, ExpansionIsland island) throws IOException {
|
||||
requireServerThread(level);
|
||||
if (island.reuseToken().isEmpty()) return;
|
||||
State state = STATES.get(level);
|
||||
if (state == null) throw new IOException("Void reuse session is unavailable");
|
||||
state.deferred.add(island.id());
|
||||
add(state, loadManifest(level, island));
|
||||
}
|
||||
public static boolean requiresReload(ServerLevel level, String island) {
|
||||
State state = STATES.get(level); return state != null && state.deferred.contains(island);
|
||||
}
|
||||
public static String failure(ServerLevel level, String island) {
|
||||
State state = STATES.get(level); return state == null ? null : state.failures.get(island);
|
||||
}
|
||||
public static boolean complete(ServerLevel level, ExpansionIsland island) {
|
||||
if (island.reuseToken().isEmpty()) return true;
|
||||
State state = STATES.get(level);
|
||||
if (state == null || state.deferred.contains(island.id()) || state.failures.containsKey(island.id())) return false;
|
||||
Manifest manifest = state.manifests.get(island.reuseToken());
|
||||
return manifest != null && manifest.certificates().stream().allMatch(c -> state.completed.contains(c.position().pack()));
|
||||
}
|
||||
|
||||
/** Called by SerializableChunkData.read on Minecraft's main-thread load executor. Refusal preserves the original data. */
|
||||
public static LoadDecision read(ServerLevel level, ChunkPos position, CompoundTag raw) {
|
||||
State state = STATES.get(level);
|
||||
if (state == null) return LoadDecision.KEEP;
|
||||
Claim claim = state.claims.get(position.pack());
|
||||
if (claim == null) return LoadDecision.KEEP;
|
||||
String island = claim.manifest().island().id();
|
||||
if (raw == null && state.deferred.contains(island)) return LoadDecision.KEEP;
|
||||
try {
|
||||
requireServerThread(level);
|
||||
if (raw == null) throw new IOException("Original chunk NBT is unavailable");
|
||||
if (trustedMarker(level, position, raw, claim.manifest().token()))
|
||||
return new LoadDecision(null, claim.manifest().token());
|
||||
if (state.deferred.contains(island)) return LoadDecision.KEEP;
|
||||
if (claim.certificate().absent()) throw new IOException("An originally absent chunk appeared without its transaction marker");
|
||||
if (state.failures.containsKey(island)) return LoadDecision.KEEP;
|
||||
long deadline = ExpansionAdmission.searchDeadline();
|
||||
var entities = await(entityRegion(level).read(position), deadline);
|
||||
var poi = await(((ExpansionVoidPoiStorageAccess) level.getPoiManager()).sanctuary$simpleRegionStorage().read(position), deadline);
|
||||
verifyMemory(level, position, true);
|
||||
if (loaded(level, position, true) != null) throw new IOException("A live chunk holder must not be replaced");
|
||||
var freshProof = ExpansionVoidCertificate.certify(position, raw, entities, poi, dataVersion());
|
||||
if (!freshProof.equals(claim.certificate())) throw new IOException("Void certificate no longer matches the saved chunk");
|
||||
var replacement = new ProtoChunk(position, UpgradeData.EMPTY, level, level.palettedContainerFactory(), null);
|
||||
marker(replacement, claim.manifest().token());
|
||||
SanctuaryMod.LOGGER.debug("Rebuilding certified void {} for expansion {} through the native pipeline", position, island);
|
||||
return new LoadDecision(replacement, claim.manifest().token());
|
||||
} catch (Exception error) {
|
||||
String failure = "void_reuse_blocked: " + position + ": " + error.getMessage();
|
||||
state.failures.put(island, failure);
|
||||
state.deferred.add(island);
|
||||
if (level.getChunkSource().getGenerator() instanceof ExpansionChunkGenerator generator)
|
||||
generator.setIslands(generator.source().islands().stream().filter(i -> !i.id().equals(island)).toList());
|
||||
SanctuaryMod.LOGGER.error("Expansion {} stopped; the original chunk is preserved: {}", island, failure);
|
||||
// ChunkMap treats an IOException from loading as permission to create EMPTY. Never throw one here.
|
||||
return LoadDecision.KEEP;
|
||||
}
|
||||
}
|
||||
|
||||
public static String marker(ChunkAccess chunk) { return ((ExpansionReuseMarker) unwrap(chunk)).sanctuary$reuseMarker(); }
|
||||
public static void marker(ChunkAccess chunk, String token) { ((ExpansionReuseMarker) unwrap(chunk)).sanctuary$reuseMarker(token); }
|
||||
public static void loaded(ServerLevel level, ChunkAccess chunk, String token) {
|
||||
if (token == null || token.isEmpty()) return;
|
||||
marker(chunk, token);
|
||||
State state = STATES.get(level);
|
||||
Claim claim = state == null ? null : state.claims.get(chunk.getPos().pack());
|
||||
if (claim != null && claim.manifest().token().equals(token) && unwrap(chunk) instanceof LevelChunk)
|
||||
state.completed.add(chunk.getPos().pack());
|
||||
}
|
||||
|
||||
/** Global startup gate. A native marked chunk may now contain gameplay data and is preserved whole. */
|
||||
private static List<Claim> preflight(ServerLevel level, Manifest manifest) throws IOException {
|
||||
var missing = new ArrayList<Claim>();
|
||||
var occupiedPoi = occupiedPoiChunks(level);
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(120);
|
||||
var entityStorage = entityRegion(level);
|
||||
var poiStorage = ((ExpansionVoidPoiStorageAccess) level.getPoiManager()).sanctuary$simpleRegionStorage();
|
||||
for (int offset = 0; offset < manifest.certificates().size(); offset += BATCH) {
|
||||
var reads = new ArrayList<Read>();
|
||||
int end = Math.min(offset + BATCH, manifest.certificates().size());
|
||||
for (int i = offset; i < end; i++) {
|
||||
ChunkPos pos = manifest.certificates().get(i).position();
|
||||
reads.add(new Read(pos, level.getChunkSource().chunkMap.read(pos), entityStorage.read(pos), poiStorage.read(pos)));
|
||||
}
|
||||
await(CompletableFuture.allOf(reads.stream().flatMap(read -> java.util.stream.Stream.of(
|
||||
read.terrain(), read.entities(), read.poi())).toArray(CompletableFuture[]::new)), deadline);
|
||||
for (int i = 0; i < reads.size(); i++) {
|
||||
var read = reads.get(i);
|
||||
var certificate = manifest.certificates().get(offset + i);
|
||||
var raw = await(read.terrain(), deadline);
|
||||
var entities = await(read.entities(), deadline);
|
||||
var poi = await(read.poi(), deadline);
|
||||
if (raw.isPresent() && trustedMarker(level, read.position(), raw.get(), manifest.token())) continue;
|
||||
verifyMemory(level, read.position(), false, occupiedPoi);
|
||||
if (loaded(level, read.position()) != null) throw new IOException("Reload has a live uncertified holder at " + read.position());
|
||||
ExpansionVoidCertificate proof;
|
||||
if (raw.isEmpty()) {
|
||||
if (!certificate.absent()) throw new IOException("Certified saved void disappeared at " + read.position());
|
||||
proof = ExpansionVoidCertificate.absent(read.position(), entities, poi, dataVersion());
|
||||
missing.add(new Claim(manifest, certificate));
|
||||
} else {
|
||||
if (certificate.absent()) throw new IOException("Unclaimed saved chunk appeared after reservation at " + read.position());
|
||||
proof = ExpansionVoidCertificate.certify(read.position(), raw.get(), entities, poi, dataVersion());
|
||||
}
|
||||
if (!proof.equals(certificate)) throw new IOException("Void reuse preflight changed at " + read.position());
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
/** Durably starts the native pipeline in positions proven absent by the entire preflight.
|
||||
* These EMPTY records contain no generated terrain. A crash leaves journal-authorized records
|
||||
* which the next preflight recognizes, rather than an ambiguous partially activated footprint. */
|
||||
private static void stageAbsent(ServerLevel level, List<Claim> claims) throws IOException {
|
||||
if (claims.isEmpty()) return;
|
||||
var storage = level.getChunkSource().chunkMap;
|
||||
var occupiedPoi = occupiedPoiChunks(level);
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(120);
|
||||
for (int offset = 0; offset < claims.size(); offset += BATCH) {
|
||||
var writes = new ArrayList<CompletableFuture<Void>>();
|
||||
int end = Math.min(offset + BATCH, claims.size());
|
||||
for (int i = offset; i < end; i++) {
|
||||
var claim = claims.get(i); ChunkPos pos = claim.certificate().position();
|
||||
// This server turn cannot interleave gameplay. Re-read the owning IO worker to include queued writes.
|
||||
if (await(storage.read(pos), deadline).isPresent() || loaded(level, pos) != null)
|
||||
throw new IOException("Previously absent chunk changed before staging at " + pos);
|
||||
verifyMemory(level, pos, false, occupiedPoi);
|
||||
var fresh = new ProtoChunk(pos, UpgradeData.EMPTY, level, level.palettedContainerFactory(), null);
|
||||
marker(fresh, claim.manifest().token());
|
||||
writes.add(storage.write(pos, SerializableChunkData.copyOf(level, fresh).write()));
|
||||
}
|
||||
await(CompletableFuture.allOf(writes.toArray(CompletableFuture[]::new)), deadline);
|
||||
}
|
||||
await(storage.synchronize(true), deadline);
|
||||
for (var claim : claims) {
|
||||
var pos = claim.certificate().position();
|
||||
var raw = await(storage.read(pos), deadline).orElseThrow(() -> new IOException("Staged chunk was not saved at " + pos));
|
||||
if (!trustedMarker(level, pos, raw, claim.manifest().token())
|
||||
|| SerializableChunkData.getChunkStatusFromTag(raw) != ChunkStatus.EMPTY)
|
||||
throw new IOException("Staged native EMPTY read-back failed at " + pos);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean trustedMarker(ServerLevel level, ChunkPos position, CompoundTag raw, String token) throws IOException {
|
||||
if (!raw.contains(NBT_MARKER)) return false;
|
||||
if (!raw.getString(NBT_MARKER).orElse("").equals(token)) throw new IOException("Chunk has a foreign reuse marker at " + position);
|
||||
if (raw.getInt("DataVersion").orElse(-1) != dataVersion()
|
||||
|| raw.getInt("xPos").orElse(Integer.MIN_VALUE) != position.x()
|
||||
|| raw.getInt("zPos").orElse(Integer.MIN_VALUE) != position.z()
|
||||
|| raw.read("Status", ChunkStatus.CODEC).isEmpty()
|
||||
|| SerializableChunkData.parse(level, level.palettedContainerFactory(), raw) == null)
|
||||
throw new IOException("Marked chunk cannot be read safely at " + position);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ChunkAccess unwrap(ChunkAccess chunk) { return chunk instanceof ImposterProtoChunk proxy ? proxy.getWrapped() : chunk; }
|
||||
private static int dataVersion() { return SharedConstants.getCurrentVersion().dataVersion().version(); }
|
||||
private static ExpansionIsland identity(ExpansionIsland island) { return island.withReuseToken("").ready(); }
|
||||
private static Path manifestPath(ServerLevel level, String token) {
|
||||
if (!token.matches("[a-f0-9]{64}")) throw new IllegalArgumentException("Invalid void reuse token");
|
||||
return level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-world-v14/void-reuse").resolve(token + ".json");
|
||||
}
|
||||
private static String readText(Path file) throws IOException {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.size(file) > MAX_MANIFEST_BYTES)
|
||||
throw new IOException("Missing or invalid immutable void certificate: " + file.getFileName());
|
||||
return Files.readString(file, StandardCharsets.UTF_8);
|
||||
}
|
||||
private static Manifest loadManifest(ServerLevel level, ExpansionIsland island) throws IOException {
|
||||
try {
|
||||
String encoded = readText(manifestPath(level, island.reuseToken()));
|
||||
if (!ExpansionVoidCertificate.digest(encoded).equals(island.reuseToken())) throw new IOException("Void certificate checksum differs from the journal");
|
||||
JsonObject body = JsonParser.parseString(encoded).getAsJsonObject();
|
||||
if (!body.keySet().equals(Set.of("schema", "world_seed", "data_version", "island", "certificates"))
|
||||
|| body.get("schema").getAsInt() != 1 || body.get("world_seed").getAsLong() != level.getSeed()
|
||||
|| body.get("data_version").getAsInt() != dataVersion()) throw new IOException("Unsupported void certificate contract");
|
||||
var identity = ExpansionIsland.CODEC.parse(JsonOps.INSTANCE, body.get("island")).getOrThrow();
|
||||
if (!identity.equals(identity(island))) throw new IOException("Void certificate belongs to a different island");
|
||||
var certificates = new ArrayList<ExpansionVoidCertificate>();
|
||||
var positions = new HashSet<Long>();
|
||||
for (var element : body.getAsJsonArray("certificates")) {
|
||||
if (!element.getAsJsonObject().keySet().equals(Set.of("x", "z", "absent", "terrain", "entities", "poi"))) throw new IOException("Foreign void certificate fields");
|
||||
var certificate = JSON.fromJson(element, ExpansionVoidCertificate.class);
|
||||
if (!island.ownsChunk(certificate.x(), certificate.z()) || !positions.add(certificate.position().pack())
|
||||
|| !(certificate.absent() ? certificate.terrain().equals("absent") : certificate.terrain().matches("[a-f0-9]{64}"))
|
||||
|| !certificate.entities().matches("[a-f0-9]{64}")
|
||||
|| !certificate.poi().matches("[a-f0-9]{64}")) throw new IOException("Invalid certified write scope");
|
||||
certificates.add(certificate);
|
||||
}
|
||||
if (certificates.isEmpty() || certificates.size() > MAX_CHUNKS
|
||||
|| certificates.stream().allMatch(ExpansionVoidCertificate::absent)
|
||||
|| !positions.equals(positions(island).stream().map(ChunkPos::pack).collect(java.util.stream.Collectors.toSet())))
|
||||
throw new IOException("Void certificate does not cover the complete write scope");
|
||||
return new Manifest(island.reuseToken(), identity, List.copyOf(certificates));
|
||||
} catch (RuntimeException error) { throw new IOException("Could not decode the journal's immutable void certificate", error); }
|
||||
}
|
||||
private static void add(State state, Manifest manifest) throws IOException {
|
||||
Manifest old = state.manifests.get(manifest.token());
|
||||
if (old != null) return;
|
||||
for (var certificate : manifest.certificates()) {
|
||||
Claim previous = state.claims.get(certificate.position().pack());
|
||||
if (previous != null && !previous.manifest().token().equals(manifest.token()))
|
||||
throw new IOException("Two void reuse transactions claim the same chunk");
|
||||
}
|
||||
state.manifests.put(manifest.token(), manifest);
|
||||
for (var certificate : manifest.certificates()) state.claims.put(certificate.position().pack(), new Claim(manifest, certificate));
|
||||
}
|
||||
private static List<ChunkPos> positions(ExpansionIsland island) throws IOException {
|
||||
int radius = island.terrainRadius() + ExpansionIsland.DECORATION_MARGIN;
|
||||
var result = new ArrayList<ChunkPos>();
|
||||
for (int z = Math.floorDiv(island.centerZ() - radius, 16); z <= Math.floorDiv(island.centerZ() + radius, 16); z++)
|
||||
for (int x = Math.floorDiv(island.centerX() - radius, 16); x <= Math.floorDiv(island.centerX() + radius, 16); x++)
|
||||
if (island.ownsChunk(x, z)) {
|
||||
result.add(new ChunkPos(x, z));
|
||||
if (result.size() > MAX_CHUNKS) throw new IOException("Expansion exceeds the bounded admission scope");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private static ChunkAccess loaded(ServerLevel level, ChunkPos pos) throws IOException {
|
||||
return loaded(level, pos, false);
|
||||
}
|
||||
private static ChunkAccess loaded(ServerLevel level, ChunkPos pos, boolean currentLoad) throws IOException {
|
||||
var map = level.getChunkSource().chunkMap;
|
||||
if (((ExpansionChunkMapAccessor) map).sanctuary$pendingUnloads().containsKey(pos.pack()))
|
||||
throw new IOException("void_reuse_retry: chunk is unloading at " + pos);
|
||||
var holder = map.getUpdatingChunkIfPresent(pos.pack());
|
||||
if (holder == null) return null;
|
||||
var chunk = holder.getLatestChunk();
|
||||
if (chunk == null && !currentLoad) throw new IOException("void_reuse_retry: chunk load is in progress at " + pos);
|
||||
return chunk;
|
||||
}
|
||||
private static SimpleRegionStorage entityRegion(ServerLevel level) throws IOException {
|
||||
var manager = ((ExpansionVoidServerLevelAccess) level).sanctuary$entityManager();
|
||||
var storage = ((ExpansionVoidEntityManagerAccess) manager).sanctuary$permanentStorage();
|
||||
if (!(storage instanceof ExpansionVoidEntityStorageAccess access)) throw new IOException("Entity storage cannot be verified");
|
||||
return access.sanctuary$simpleRegionStorage();
|
||||
}
|
||||
private static void verifyMemory(ServerLevel level, ChunkPos pos) throws IOException {
|
||||
verifyMemory(level, pos, false);
|
||||
}
|
||||
private static void verifyMemory(ServerLevel level, ChunkPos pos, boolean currentLoad) throws IOException {
|
||||
verifyMemory(level, pos, currentLoad, null);
|
||||
}
|
||||
private static void verifyMemory(ServerLevel level, ChunkPos pos, boolean currentLoad, Set<Long> occupiedPoi) throws IOException {
|
||||
requireServerThread(level);
|
||||
var access = (ExpansionVoidServerLevelAccess) level;
|
||||
var manager = (ExpansionVoidEntityManagerAccess) access.sanctuary$entityManager();
|
||||
var entityStatus = manager.sanctuary$chunkLoadStatuses().get(pos.pack());
|
||||
if (entityStatus != null && entityStatus.name().equals("PENDING"))
|
||||
throw new IOException("void_reuse_retry: entity load is in progress at " + pos);
|
||||
var poiAccess = (ExpansionVoidPoiStorageAccess) level.getPoiManager();
|
||||
boolean prefetched = false;
|
||||
synchronized (poiAccess.sanctuary$loadLock()) {
|
||||
var pending = poiAccess.sanctuary$pendingLoads().get(pos.pack());
|
||||
if (pending != null) {
|
||||
// ChunkMap's own LOAD waits for this prefetch but native unpacking happens inside read.
|
||||
if (!currentLoad || !pending.isDone() || pending.isCompletedExceptionally())
|
||||
throw new IOException("void_reuse_retry: point-of-interest load is in progress at " + pos);
|
||||
prefetched = true;
|
||||
}
|
||||
}
|
||||
if (prefetched && level.getPoiManager().getInChunk(holder -> true, pos, PoiManager.Occupancy.ANY).findAny().isPresent())
|
||||
throw new IOException("void_not_pristine: prefetched point of interest at " + pos);
|
||||
if (manager.sanctuary$sectionStorage().getExistingSectionsInChunk(pos.pack()).anyMatch(section -> !section.isEmpty())
|
||||
|| manager.sanctuary$loadingInbox().stream().anyMatch(chunk -> chunk.getPos().equals(pos) && !chunk.isEmpty()))
|
||||
throw new IOException("void_not_pristine: loaded or loading entity at " + pos);
|
||||
if ((occupiedPoi == null ? occupiedPoiChunks(level) : occupiedPoi).contains(pos.pack()))
|
||||
throw new IOException("void_not_pristine: loaded point of interest at " + pos);
|
||||
verifyTicks(level.getBlockTicks(), pos); verifyTicks(level.getFluidTicks(), pos);
|
||||
if (access.sanctuary$blockEvents().stream().anyMatch(event -> ChunkPos.containing(event.pos()).equals(pos))
|
||||
|| access.sanctuary$blockEventsToReschedule().stream().anyMatch(event -> ChunkPos.containing(event.pos()).equals(pos)))
|
||||
throw new IOException("void_not_pristine: pending block event at " + pos);
|
||||
}
|
||||
/** Snapshot once per admission/preflight turn; include every cached Y, even unsupported out-of-world sections.
|
||||
* The server thread does not yield while inspecting, so loaded records cannot change between its chunk checks. */
|
||||
private static Set<Long> occupiedPoiChunks(ServerLevel level) throws IOException {
|
||||
requireServerThread(level);
|
||||
var result = new HashSet<Long>();
|
||||
var poi = ((ExpansionVoidPoiStorageAccess) level.getPoiManager()).sanctuary$storage();
|
||||
for (var entry : poi.long2ObjectEntrySet()) if (entry.getValue().isPresent()
|
||||
&& entry.getValue().get().getRecords(holder -> true, PoiManager.Occupancy.ANY).findAny().isPresent())
|
||||
result.add(new ChunkPos(SectionPos.x(entry.getLongKey()), SectionPos.z(entry.getLongKey())).pack());
|
||||
return result;
|
||||
}
|
||||
private static void verifyTicks(LevelTicks<?> ticks, ChunkPos pos) throws IOException {
|
||||
var access = (ExpansionVoidTicksAccess) ticks;
|
||||
var container = access.sanctuary$allContainers().get(pos.pack());
|
||||
if (container != null && container.count() != 0
|
||||
|| access.sanctuary$toRunThisTick().stream().anyMatch(tick -> ChunkPos.containing(tick.pos()).equals(pos))
|
||||
|| access.sanctuary$alreadyRunThisTick().stream().anyMatch(tick -> ChunkPos.containing(tick.pos()).equals(pos)))
|
||||
throw new IOException("void_not_pristine: scheduled tick at " + pos);
|
||||
}
|
||||
private static void requireServerThread(ServerLevel level) throws IOException {
|
||||
if (!level.getServer().isSameThread()) throw new IOException("Void inspection must run on the server thread");
|
||||
}
|
||||
private static <T> T await(CompletableFuture<T> future, long deadline) throws IOException {
|
||||
try {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
if (remaining <= 0) throw new TimeoutException();
|
||||
return future.get(remaining, TimeUnit.NANOSECONDS);
|
||||
} catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new IOException("Void inspection interrupted", error); }
|
||||
catch (ExecutionException error) { throw new IOException("Void storage read failed", error.getCause()); }
|
||||
catch (TimeoutException error) { throw new IOException("search_timeout", error); }
|
||||
}
|
||||
private record Read(ChunkPos position, CompletableFuture<Optional<CompoundTag>> terrain,
|
||||
CompletableFuture<Optional<CompoundTag>> entities, CompletableFuture<Optional<CompoundTag>> poi) {}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.expansion.ExpansionReuseMarker;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Unique;
|
||||
|
||||
/** Authority follows the actual chunk object; coordinates alone never confer permission to mark a chunk. */
|
||||
@Mixin(ChunkAccess.class)
|
||||
public abstract class ExpansionReuseChunkMarkerMixin implements ExpansionReuseMarker {
|
||||
@Unique private String sanctuary$reuseMarker = "";
|
||||
@Override public String sanctuary$reuseMarker() { return sanctuary$reuseMarker; }
|
||||
@Override public void sanctuary$reuseMarker(String token) { sanctuary$reuseMarker = token; }
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.expansion.ExpansionVoidReuse;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(LevelChunk.class)
|
||||
public abstract class ExpansionReusePromotionMixin {
|
||||
@Inject(method = "<init>(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/level/chunk/ProtoChunk;Lnet/minecraft/world/level/chunk/LevelChunk$PostLoadProcessor;)V", at = @At("RETURN"))
|
||||
private void sanctuary$promoteCertificate(ServerLevel level, ProtoChunk proto, LevelChunk.PostLoadProcessor processor, CallbackInfo ci) {
|
||||
ExpansionVoidReuse.loaded(level, (LevelChunk) (Object) this, ExpansionVoidReuse.marker(proto));
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.expansion.ExpansionVoidReuse;
|
||||
import fr.koka.sanctuary.expansion.ExpansionReuseSerializedData;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.ai.village.poi.PoiManager;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.PalettedContainerFactory;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.chunk.storage.RegionStorageInfo;
|
||||
import net.minecraft.world.level.chunk.storage.SerializableChunkData;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Unique;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
/** Retains the original NBT until the main-thread read boundary, before any native recovery can discard fields. */
|
||||
@Mixin(SerializableChunkData.class)
|
||||
public abstract class ExpansionReuseSerializationMixin implements ExpansionReuseSerializedData {
|
||||
@Unique private CompoundTag sanctuary$originalNbt;
|
||||
@Unique private String sanctuary$reuseMarker = "";
|
||||
@Override public void sanctuary$originalReuseNbt(CompoundTag tag) { sanctuary$originalNbt = tag; }
|
||||
@Override public void sanctuary$serializedReuseMarker(String token) { sanctuary$reuseMarker = token; }
|
||||
|
||||
@Inject(method = "parse", at = @At("RETURN"))
|
||||
private static void sanctuary$captureOriginal(LevelHeightAccessor height, PalettedContainerFactory factory, CompoundTag raw,
|
||||
CallbackInfoReturnable<SerializableChunkData> cir) {
|
||||
if (cir.getReturnValue() != null && ExpansionVoidReuse.needsOriginal(height, raw))
|
||||
((ExpansionReuseSerializedData) (Object) cir.getReturnValue()).sanctuary$originalReuseNbt(raw.copy());
|
||||
}
|
||||
|
||||
@Inject(method = "read", at = @At("HEAD"), cancellable = true)
|
||||
private void sanctuary$restoreCertifiedVoid(ServerLevel level, PoiManager pois, RegionStorageInfo storage, ChunkPos position,
|
||||
CallbackInfoReturnable<ProtoChunk> cir) {
|
||||
var decision = ExpansionVoidReuse.read(level, position, sanctuary$originalNbt);
|
||||
sanctuary$originalNbt = null;
|
||||
sanctuary$reuseMarker = decision.trustedMarker();
|
||||
if (decision.replacement() != null) cir.setReturnValue(decision.replacement());
|
||||
}
|
||||
|
||||
@Inject(method = "read", at = @At("RETURN"))
|
||||
private void sanctuary$restoreNativeMarker(ServerLevel level, PoiManager pois, RegionStorageInfo storage, ChunkPos position,
|
||||
CallbackInfoReturnable<ProtoChunk> cir) {
|
||||
if (cir.getReturnValue() != null) ExpansionVoidReuse.loaded(level, cir.getReturnValue(), sanctuary$reuseMarker);
|
||||
}
|
||||
|
||||
@Inject(method = "copyOf", at = @At("RETURN"))
|
||||
private static void sanctuary$captureNativeMarker(ServerLevel level, ChunkAccess chunk, CallbackInfoReturnable<SerializableChunkData> cir) {
|
||||
((ExpansionReuseSerializedData) (Object) cir.getReturnValue()).sanctuary$serializedReuseMarker(ExpansionVoidReuse.marker(chunk));
|
||||
}
|
||||
|
||||
@Inject(method = "write", at = @At("RETURN"))
|
||||
private void sanctuary$persistNativeMarker(CallbackInfoReturnable<CompoundTag> cir) {
|
||||
if (!sanctuary$reuseMarker.isEmpty()) cir.getReturnValue().putString(ExpansionVoidReuse.NBT_MARKER, sanctuary$reuseMarker);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
|
||||
import java.util.Queue;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.level.entity.ChunkEntities;
|
||||
import net.minecraft.world.level.entity.EntityPersistentStorage;
|
||||
import net.minecraft.world.level.entity.EntitySectionStorage;
|
||||
import net.minecraft.world.level.entity.PersistentEntitySectionManager;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
/** Exposes both loaded and pending entity collections to the conservative admission check. */
|
||||
@Mixin(PersistentEntitySectionManager.class)
|
||||
public interface ExpansionVoidEntityManagerAccess {
|
||||
@Accessor("permanentStorage")
|
||||
EntityPersistentStorage<Entity> sanctuary$permanentStorage();
|
||||
|
||||
@Accessor("sectionStorage")
|
||||
EntitySectionStorage<Entity> sanctuary$sectionStorage();
|
||||
|
||||
@Accessor("loadingInbox")
|
||||
Queue<ChunkEntities<Entity>> sanctuary$loadingInbox();
|
||||
|
||||
@Accessor("chunkLoadStatuses")
|
||||
Long2ObjectMap<? extends Enum<?>> sanctuary$chunkLoadStatuses();
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import net.minecraft.world.level.chunk.storage.EntityStorage;
|
||||
import net.minecraft.world.level.chunk.storage.SimpleRegionStorage;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
/** Lets admission inspect saved entities without pretending an unloaded chunk is empty. */
|
||||
@Mixin(EntityStorage.class)
|
||||
public interface ExpansionVoidEntityStorageAccess {
|
||||
@Accessor("simpleRegionStorage")
|
||||
SimpleRegionStorage sanctuary$simpleRegionStorage();
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import net.minecraft.world.entity.ai.village.poi.PoiSection;
|
||||
import net.minecraft.world.level.chunk.storage.SectionStorage;
|
||||
import net.minecraft.world.level.chunk.storage.SimpleRegionStorage;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
/** Used only on the world's POI manager to inspect its persisted and already loaded records. */
|
||||
@Mixin(SectionStorage.class)
|
||||
public interface ExpansionVoidPoiStorageAccess {
|
||||
@Accessor("simpleRegionStorage")
|
||||
SimpleRegionStorage sanctuary$simpleRegionStorage();
|
||||
|
||||
@Accessor("storage")
|
||||
Long2ObjectMap<Optional<PoiSection>> sanctuary$storage();
|
||||
|
||||
@Accessor("pendingLoads")
|
||||
Long2ObjectMap<CompletableFuture<?>> sanctuary$pendingLoads();
|
||||
|
||||
@Accessor("loadLock")
|
||||
Object sanctuary$loadLock();
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet;
|
||||
import java.util.List;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.level.BlockEventData;
|
||||
import net.minecraft.world.level.entity.PersistentEntitySectionManager;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
/** Read-only access for checking entity ownership before deferred void admission. */
|
||||
@Mixin(ServerLevel.class)
|
||||
public interface ExpansionVoidServerLevelAccess {
|
||||
@Accessor("entityManager")
|
||||
PersistentEntitySectionManager<Entity> sanctuary$entityManager();
|
||||
|
||||
@Accessor("blockEvents")
|
||||
ObjectLinkedOpenHashSet<BlockEventData> sanctuary$blockEvents();
|
||||
|
||||
@Accessor("blockEventsToReschedule")
|
||||
List<BlockEventData> sanctuary$blockEventsToReschedule();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
import net.minecraft.world.ticks.LevelChunkTicks;
|
||||
import net.minecraft.world.ticks.LevelTicks;
|
||||
import net.minecraft.world.ticks.ScheduledTick;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
/** Admission checks scheduled, queued and currently executing ticks, not just chunk containers. */
|
||||
@Mixin(LevelTicks.class)
|
||||
public interface ExpansionVoidTicksAccess {
|
||||
@Accessor("allContainers")
|
||||
Long2ObjectMap<LevelChunkTicks<?>> sanctuary$allContainers();
|
||||
|
||||
@Accessor("toRunThisTick")
|
||||
Queue<ScheduledTick<?>> sanctuary$toRunThisTick();
|
||||
|
||||
@Accessor("alreadyRunThisTick")
|
||||
List<ScheduledTick<?>> sanctuary$alreadyRunThisTick();
|
||||
}
|
||||
+15
@@ -13,6 +13,21 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
/** Only new alpha.13 decoration writes are filtered, never player edits or saved chunk loading. */
|
||||
@Mixin(WorldGenRegion.class)
|
||||
public abstract class UnifiedDecorationProtectionMixin {
|
||||
/** Some native features (notably ore veins) write directly to a chunk section after
|
||||
* checking ensureCanWrite, so setBlock alone cannot enforce island ownership. */
|
||||
@Inject(method = "ensureCanWrite", at = @At("HEAD"), cancellable = true)
|
||||
private void sanctuary$keepIslandOwnership(BlockPos pos, CallbackInfoReturnable<Boolean> callback) {
|
||||
var region = (WorldGenRegion) (Object) this;
|
||||
if (!(region.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator)
|
||||
|| generator.rootGeneration() != 14) return;
|
||||
var center = region.getCenter();
|
||||
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))
|
||||
callback.setReturnValue(false);
|
||||
}
|
||||
|
||||
@Inject(method = "setBlock", at = @At("HEAD"), cancellable = true)
|
||||
private void sanctuary$keepHydrology(BlockPos pos, BlockState state, int flags, int recursion,
|
||||
CallbackInfoReturnable<Boolean> callback) {
|
||||
|
||||
@@ -28,13 +28,25 @@ public final class SanctuaryBiomeSource extends ExpansionBiomeSource {
|
||||
public static final MapCodec<SanctuaryBiomeSource> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
|
||||
).apply(i, SanctuaryBiomeSource::new));
|
||||
public static final MapCodec<SanctuaryBiomeSource> CITY_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
|
||||
).apply(i, biomes -> new SanctuaryBiomeSource(biomes, true)));
|
||||
|
||||
private final List<Holder<Biome>> allBiomes;
|
||||
private final PopulationIslandBiomeSource population;
|
||||
private final boolean cities;
|
||||
private final Holder<Biome> cityBiome;
|
||||
private volatile java.util.Map<String, fr.koka.sanctuary.worldgen.city.LostCityPlan> cityPlans = java.util.Map.of();
|
||||
|
||||
public SanctuaryBiomeSource(List<Holder<Biome>> biomes) {
|
||||
this(biomes, false);
|
||||
}
|
||||
private SanctuaryBiomeSource(List<Holder<Biome>> biomes, boolean cities) {
|
||||
super(biomes);
|
||||
this.cities = cities;
|
||||
allBiomes = List.copyOf(biomes);
|
||||
cityBiome = cities ? required(ResourceKey.create(net.minecraft.core.registries.Registries.BIOME,
|
||||
fr.koka.sanctuary.SanctuaryMod.id("lost_city"))) : null;
|
||||
population = new PopulationIslandBiomeSource(
|
||||
required(PopulationIslandBiomeSource.OAK_FOREST), required(PopulationIslandBiomeSource.BIRCH_FOREST),
|
||||
required(PopulationIslandBiomeSource.CLEARING), required(PopulationIslandBiomeSource.DRY_WOODLAND),
|
||||
@@ -44,9 +56,13 @@ public final class SanctuaryBiomeSource extends ExpansionBiomeSource {
|
||||
}
|
||||
|
||||
/** A world or delegate gets its own activation snapshot; registered holders remain immutable. */
|
||||
public SanctuaryBiomeSource copy() { return new SanctuaryBiomeSource(allBiomes); }
|
||||
public SanctuaryBiomeSource copy() { return new SanctuaryBiomeSource(allBiomes, cities); }
|
||||
public boolean citiesEnabled() { return cities; }
|
||||
public void setCityPlans(java.util.Map<String, fr.koka.sanctuary.worldgen.city.LostCityPlan> plans) {
|
||||
cityPlans = java.util.Map.copyOf(plans);
|
||||
}
|
||||
|
||||
@Override protected MapCodec<SanctuaryBiomeSource> codec() { return CODEC; }
|
||||
@Override protected MapCodec<SanctuaryBiomeSource> codec() { return cities ? CITY_CODEC : CODEC; }
|
||||
|
||||
@Override public BiomeResolver createResolver(Climate.Sampler sampler) {
|
||||
BiomeResolver woodlands = population.createResolver(sampler);
|
||||
@@ -69,6 +85,10 @@ public final class SanctuaryBiomeSource extends ExpansionBiomeSource {
|
||||
if (y < 0 || y >= 384 || laboratory.is(Biomes.LUSH_CAVES)
|
||||
|| laboratory.is(Biomes.DRIPSTONE_CAVES) || laboratory.is(Biomes.SULFUR_CAVES)) return laboratory;
|
||||
for (ExpansionIsland island : snapshot) {
|
||||
if (cities && island.generation() == 14 && island.contains(x, z)) {
|
||||
var plan = cityPlans.get(island.id());
|
||||
if (plan != null && plan.contains(x, y, z)) return cityBiome;
|
||||
}
|
||||
if (island.origin() && island.contains(x, z)) return woodlands.getNoiseBiome(quartX, quartY, quartZ);
|
||||
}
|
||||
return laboratory;
|
||||
|
||||
+90
-4
@@ -24,8 +24,18 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
|
||||
SanctuaryBiomeSource.CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
|
||||
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter)
|
||||
).apply(i, SanctuaryChunkGenerator::new));
|
||||
public static final MapCodec<SanctuaryChunkGenerator> CITY_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
SanctuaryBiomeSource.CITY_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
|
||||
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter)
|
||||
).apply(i, (source, diameter) -> new SanctuaryChunkGenerator(source, diameter, 14)));
|
||||
|
||||
private volatile Context originContext;
|
||||
private final int generation;
|
||||
private net.minecraft.world.level.LevelHeightAccessor heightAccessor;
|
||||
// A prospect may reuse an ID at a different position after admission rejects a candidate.
|
||||
// Terrain identity includes all descriptor fields except preparation state and reuse receipt.
|
||||
private final java.util.Map<ExpansionIsland, fr.koka.sanctuary.worldgen.city.LostCityPlan> cityPlans = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private volatile java.util.Map<String, fr.koka.sanctuary.worldgen.city.LostCityPlan> activeCityPlans = java.util.Map.of();
|
||||
// Root extent is finite (at most 68x68 chunks); each immutable mask is at most 12 KiB.
|
||||
private final java.util.concurrent.ConcurrentHashMap<Long, SanctuaryDecorationProtection> protections = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private final java.util.concurrent.atomic.LongAdder rejectedWrites = new java.util.concurrent.atomic.LongAdder();
|
||||
@@ -39,12 +49,17 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
|
||||
public long rejectedDecorationWrites() { return rejectedWrites.sum(); }
|
||||
|
||||
public SanctuaryChunkGenerator(SanctuaryBiomeSource source, int initialDiameter) {
|
||||
this(source, initialDiameter, 13);
|
||||
}
|
||||
private SanctuaryChunkGenerator(SanctuaryBiomeSource source, int initialDiameter, int generation) {
|
||||
super(source, initialDiameter, true);
|
||||
this.generation = generation;
|
||||
if ((generation == 14) != source.citiesEnabled()) throw new IllegalArgumentException("Sanctuary generator and biome source versions must agree");
|
||||
}
|
||||
@Override public SanctuaryBiomeSource source() { return (SanctuaryBiomeSource) super.source(); }
|
||||
@Override protected MapCodec<SanctuaryChunkGenerator> codec() { return CODEC; }
|
||||
@Override protected MapCodec<SanctuaryChunkGenerator> codec() { return generation == 14 ? CITY_CODEC : CODEC; }
|
||||
public SanctuaryChunkGenerator withInitialDiameter(int diameter) {
|
||||
return new SanctuaryChunkGenerator(source().copy(), diameter);
|
||||
return new SanctuaryChunkGenerator(source().copy(), diameter, generation);
|
||||
}
|
||||
public IslandCapacity capacity() {
|
||||
return switch (initialDiameter()) {
|
||||
@@ -70,12 +85,83 @@ public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
|
||||
return equivalent == null ? biome : registries.lookupOrThrow(Registries.BIOME).getOrThrow(
|
||||
net.minecraft.resources.ResourceKey.create(Registries.BIOME, net.minecraft.resources.Identifier.withDefaultNamespace(equivalent)));
|
||||
}
|
||||
@Override public String journalDirectory() { return "sanctuary-world-v13"; }
|
||||
@Override public int rootGeneration() { return 13; }
|
||||
@Override public String journalDirectory() { return "sanctuary-world-v" + generation; }
|
||||
@Override public int rootGeneration() { return generation; }
|
||||
@Override public void initialize(ServerLevel level, List<ExpansionIsland> islands) {
|
||||
heightAccessor = level;
|
||||
super.initialize(level, islands);
|
||||
// Publish one root context before any generation workers use it.
|
||||
context(islands.getFirst());
|
||||
prepareCities(islands);
|
||||
}
|
||||
@Override public void setIslands(List<ExpansionIsland> islands) {
|
||||
// Complete and publish city geometry before workers can observe the new owner list.
|
||||
prepareCities(islands);
|
||||
super.setIslands(islands);
|
||||
}
|
||||
private void prepareCities(List<ExpansionIsland> islands) {
|
||||
if (generation != 14) return;
|
||||
var published = new java.util.LinkedHashMap<String, fr.koka.sanctuary.worldgen.city.LostCityPlan>();
|
||||
for (var island : islands) published.put(island.id(), lostCityPlan(island));
|
||||
// Rejected read-only prospects remain private and never enter the biome source.
|
||||
activeCityPlans = java.util.Map.copyOf(published);
|
||||
source().setCityPlans(published);
|
||||
}
|
||||
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();
|
||||
return ready.reuseToken().isEmpty() ? ready : ready.withReuseToken("");
|
||||
}
|
||||
@Override public void discardUnpublishedContext(ExpansionIsland island) {
|
||||
super.discardUnpublishedContext(island);
|
||||
var key = cityIdentity(island);
|
||||
if (source().islands().stream().noneMatch(saved -> cityIdentity(saved).equals(key))) {
|
||||
cityPlans.remove(key);
|
||||
// City prospecting may have prepared the receipt-free terrain context too.
|
||||
super.discardUnpublishedContext(key);
|
||||
}
|
||||
}
|
||||
public fr.koka.sanctuary.worldgen.city.LostCityPlan lostCityPlan(ExpansionIsland island) {
|
||||
if (generation != 14 || island.generation() != 14 || (!island.origin() && !island.climate().equals("lost_city")))
|
||||
return fr.koka.sanctuary.worldgen.city.LostCityPlan.EMPTY;
|
||||
return cityPlans.computeIfAbsent(cityIdentity(island), ignored -> {
|
||||
long start = System.nanoTime();
|
||||
var terrain = context(island);
|
||||
var plan = fr.koka.sanctuary.worldgen.city.LostCityPlanner.plan(island.seed(), island.centerX(), island.centerZ(),
|
||||
island.diameter(), island.origin(), (x, z) -> {
|
||||
if (!island.contains(x, z)) return new fr.koka.sanctuary.worldgen.city.LostCityPlanner.Ground(0, 0, false, false);
|
||||
var column = terrain.generator().getBaseColumn(x, z, heightAccessor, terrain.random());
|
||||
int top = 383;
|
||||
while (top > 0 && column.getBlock(top).isAir()) top--;
|
||||
int bottom = top;
|
||||
while (bottom > 0 && top - bottom < 24 && column.getBlock(bottom).isSolid()
|
||||
&& column.getBlock(bottom).getFluidState().isEmpty()) bottom--;
|
||||
boolean dry = column.getBlock(top).isSolid() && column.getBlock(top).getFluidState().isEmpty();
|
||||
boolean protectedWater = island.origin() && top >= 168 && dry && top - bottom >= 6
|
||||
&& PopulationHydrologyRuntime.protects(terrain.generator(), terrain.random(), new net.minecraft.core.BlockPos(x, top, z), 1, 30, 12);
|
||||
return new fr.koka.sanctuary.worldgen.city.LostCityPlanner.Ground(top, top - bottom, dry, protectedWater);
|
||||
});
|
||||
SanctuaryMod.LOGGER.info("Lost City plan {} seed {}: {} districts in {} ms", island.id(), island.seed(),
|
||||
plan.districts().size(), (System.nanoTime() - start) / 1_000_000);
|
||||
return plan;
|
||||
});
|
||||
}
|
||||
public boolean protectsCityDecoration(net.minecraft.core.BlockPos pos) {
|
||||
if (generation != 14) return false;
|
||||
var island = islandAt(pos.getX(), pos.getZ());
|
||||
// Decoration is a per-block hot path, including while child islands are reserved.
|
||||
var plan = island == null ? null : activeCityPlans.get(island.id());
|
||||
return plan != null && plan.contains(pos.getX(), pos.getY(), pos.getZ());
|
||||
}
|
||||
/** Lost City handles exact, single-structure queries. Mixed sets retain the vanilla
|
||||
* search unchanged: searching both engines with skipReferenced=true would consume
|
||||
* two starts before choosing one result. Mixed-set city discovery is not added here. */
|
||||
@Override public com.mojang.datafixers.util.Pair<net.minecraft.core.BlockPos, net.minecraft.core.Holder<net.minecraft.world.level.levelgen.structure.Structure>> findNearestMapStructure(
|
||||
ServerLevel level, net.minecraft.core.HolderSet<net.minecraft.world.level.levelgen.structure.Structure> structures,
|
||||
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);
|
||||
return super.findNearestMapStructure(level, structures, origin, radius, skipReferenced);
|
||||
}
|
||||
@Override protected Context context(ExpansionIsland island) {
|
||||
if (!island.origin()) return super.context(island);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package fr.koka.sanctuary.worldgen.city;
|
||||
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/** Restrained masonry for the abandoned city; weathering never changes the structural palette. */
|
||||
public final class LostCityPalette {
|
||||
public static final BlockState STONE = Blocks.STONE.defaultBlockState();
|
||||
public static final BlockState SMOOTH = Blocks.SMOOTH_STONE.defaultBlockState();
|
||||
public static final BlockState BRICK = Blocks.STONE_BRICKS.defaultBlockState();
|
||||
public static final BlockState AIR = Blocks.AIR.defaultBlockState();
|
||||
|
||||
private LostCityPalette() {}
|
||||
|
||||
public static BlockState masonry(long seed, int x, int y, int z) {
|
||||
int weather = roll(seed, x / 3, y / 2, z / 3, 29);
|
||||
if (weather < 4) return Blocks.MOSSY_STONE_BRICKS.defaultBlockState();
|
||||
if (weather < 8) return Blocks.CRACKED_STONE_BRICKS.defaultBlockState();
|
||||
return BRICK;
|
||||
}
|
||||
|
||||
public static BlockState pavement(long seed, int x, int z) {
|
||||
int wear = roll(seed, x / 3, 0, z / 3, 31);
|
||||
if (wear < 3) return Blocks.ANDESITE.defaultBlockState();
|
||||
if (wear == 3) return Blocks.CRACKED_STONE_BRICKS.defaultBlockState();
|
||||
return STONE;
|
||||
}
|
||||
|
||||
public static int roll(long seed, int x, int y, int z, int bound) {
|
||||
long value = seed ^ ((long) x * 0x9e3779b97f4a7c15L)
|
||||
^ ((long) y * 0xc2b2ae3d27d4eb4fL) ^ ((long) z * 0x165667b19e3779f9L);
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
value ^= value >>> 31;
|
||||
return (int) Long.remainderUnsigned(value, bound);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package fr.koka.sanctuary.worldgen.city;
|
||||
|
||||
import java.util.Arrays;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
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;
|
||||
|
||||
/** A persisted parcel is sufficient to decorate a later chunk without recomputing the district. */
|
||||
public final class LostCityPiece extends StructurePiece {
|
||||
private static final int SCHEMA = 1;
|
||||
private final LostCityPlan.Parcel parcel;
|
||||
|
||||
public LostCityPiece(LostCityPlan.Parcel parcel) {
|
||||
super(LostCityStructures.PIECE, 0, bounds(parcel));
|
||||
this.parcel = parcel;
|
||||
setOrientation(null);
|
||||
}
|
||||
|
||||
public LostCityPiece(StructurePieceSerializationContext context, CompoundTag tag) {
|
||||
super(LostCityStructures.PIECE, tag);
|
||||
if (tag.getInt("SanctuarySchema").orElseThrow() != SCHEMA)
|
||||
throw new IllegalArgumentException("Unsupported Lost City piece schema");
|
||||
parcel = new LostCityPlan.Parcel(LostCityPlan.Kind.valueOf(tag.getString("Kind").orElseThrow()),
|
||||
tag.getInt("MinX").orElseThrow(), tag.getInt("MinZ").orElseThrow(),
|
||||
tag.getInt("Width").orElseThrow(), tag.getInt("Depth").orElseThrow(),
|
||||
tag.getInt("FloorY").orElseThrow(), tag.getInt("Floors").orElseThrow(),
|
||||
tag.getInt("Facing").orElseThrow(), tag.getLong("ParcelSeed").orElseThrow(),
|
||||
tag.getInt("FoundationDepth").orElseThrow(), tag.getIntArray("SurfaceHeights").orElseThrow());
|
||||
if (!getBoundingBox().equals(bounds(parcel)))
|
||||
throw new IllegalArgumentException("Lost City parcel disagrees with its saved native bounding box");
|
||||
setOrientation(null);
|
||||
}
|
||||
|
||||
public LostCityPlan.Parcel parcel() { return parcel; }
|
||||
|
||||
@Override public BlockPos getLocatorPosition() {
|
||||
int x = parcel.width() / 2, z = parcel.depth() / 2;
|
||||
return new BlockPos(parcel.minX() + x, parcel.surfaceY(x, z) + 1, parcel.minZ() + z);
|
||||
}
|
||||
|
||||
@Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) {
|
||||
tag.putInt("SanctuarySchema", SCHEMA);
|
||||
tag.putString("Kind", parcel.kind().name());
|
||||
tag.putInt("MinX", parcel.minX()); tag.putInt("MinZ", parcel.minZ());
|
||||
tag.putInt("Width", parcel.width()); tag.putInt("Depth", parcel.depth());
|
||||
tag.putInt("FloorY", parcel.floorY()); tag.putInt("Floors", parcel.floors());
|
||||
tag.putInt("Facing", parcel.facing()); tag.putLong("ParcelSeed", parcel.seed());
|
||||
tag.putInt("FoundationDepth", parcel.foundationDepth());
|
||||
tag.putIntArray("SurfaceHeights", parcel.surfaceHeights());
|
||||
}
|
||||
|
||||
@Override public void postProcess(WorldGenLevel level, StructureManager structures, ChunkGenerator generator,
|
||||
RandomSource random, BoundingBox chunkClip, ChunkPos chunkPos, BlockPos pivot) {
|
||||
LostCityStructures.placing(() -> LostCityRenderer.render(parcel, new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState block) {
|
||||
BlockPos pos = world(x, y, z);
|
||||
if (getBoundingBox().isInside(pos) && chunkClip.isInside(pos))
|
||||
placeBlock(level, block, pos.getX(), pos.getY(), pos.getZ(), chunkClip);
|
||||
}
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> loot, long seed) {
|
||||
BlockPos pos = world(x, y, z);
|
||||
if (getBoundingBox().isInside(pos) && chunkClip.isInside(pos))
|
||||
createChest(level, chunkClip, RandomSource.create(seed), pos, loot, null);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private BlockPos world(int x, int y, int z) {
|
||||
return new BlockPos(parcel.minX() + x, parcel.floorY() + y, parcel.minZ() + z);
|
||||
}
|
||||
|
||||
private static BoundingBox bounds(LostCityPlan.Parcel parcel) {
|
||||
int low = Math.min(parcel.floorY(), Arrays.stream(parcel.surfaceHeights()).min().orElse(parcel.floorY()))
|
||||
- parcel.foundationDepth();
|
||||
int high = Math.max(parcel.maxY(), Arrays.stream(parcel.surfaceHeights()).max().orElse(parcel.floorY()) + 5);
|
||||
return new BoundingBox(parcel.minX(), low, parcel.minZ(), parcel.maxX(), high, parcel.maxZ());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package fr.koka.sanctuary.worldgen.city;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Immutable, bounded city geometry. Coordinates and block elevations are absolute.
|
||||
* The plan uses only pre-decoration terrain, never loaded or player-edited chunks. */
|
||||
public final class LostCityPlan {
|
||||
public static final LostCityPlan EMPTY = new LostCityPlan(List.of());
|
||||
private final List<District> districts;
|
||||
|
||||
public LostCityPlan(List<District> districts) { this.districts = List.copyOf(districts); }
|
||||
public List<District> districts() { return districts; }
|
||||
public boolean isEmpty() { return districts.isEmpty(); }
|
||||
|
||||
/** City biomes follow accepted parcels, leaving underground cave biomes intact. */
|
||||
public boolean contains(int x, int y, int z) {
|
||||
for (District district : districts) for (Parcel parcel : district.parcels()) {
|
||||
if (x >= parcel.minX() - 2 && x <= parcel.maxX() + 2
|
||||
&& z >= parcel.minZ() - 2 && z <= parcel.maxZ() + 2
|
||||
&& y >= parcel.floorY() - 8 && y <= 383) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean intersects(int minX, int minZ, int maxX, int maxZ) {
|
||||
for (District district : districts) for (Parcel parcel : district.parcels()) {
|
||||
if (minX <= parcel.maxX() + 2 && maxX >= parcel.minX() - 2
|
||||
&& minZ <= parcel.maxZ() + 2 && maxZ >= parcel.minZ() - 2) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public enum Kind {
|
||||
STREET, BRIDGE, PARK, PLAZA, MARKET, PARKING,
|
||||
APARTMENT, HOTEL, LIBRARY, RESTAURANT, BARRACKS, HALL,
|
||||
EXCAVATION, POOL, PARK_GARDEN, PARK_PLAYGROUND, PARK_MEMORIAL;
|
||||
public boolean building() {
|
||||
return switch (this) {
|
||||
case APARTMENT, HOTEL, LIBRARY, RESTAURANT, BARRACKS, HALL -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public record District(int startChunkX, int startChunkZ, long seed, List<Parcel> parcels) {
|
||||
public District {
|
||||
parcels = List.copyOf(parcels);
|
||||
if (parcels.isEmpty()) throw new IllegalArgumentException("Empty city district");
|
||||
for (Parcel parcel : parcels) {
|
||||
if (Math.abs(Math.floorDiv(parcel.minX(), 16) - startChunkX) > 7
|
||||
|| Math.abs(Math.floorDiv(parcel.maxX(), 16) - startChunkX) > 7
|
||||
|| Math.abs(Math.floorDiv(parcel.minZ(), 16) - startChunkZ) > 7
|
||||
|| Math.abs(Math.floorDiv(parcel.maxZ(), 16) - startChunkZ) > 7)
|
||||
throw new IllegalArgumentException("City parcel exceeds native reference radius");
|
||||
}
|
||||
}
|
||||
public int minX() { return parcels.stream().mapToInt(Parcel::minX).min().orElseThrow(); }
|
||||
public int maxX() { return parcels.stream().mapToInt(Parcel::maxX).max().orElseThrow(); }
|
||||
public int minZ() { return parcels.stream().mapToInt(Parcel::minZ).min().orElseThrow(); }
|
||||
public int maxZ() { return parcels.stream().mapToInt(Parcel::maxZ).max().orElseThrow(); }
|
||||
}
|
||||
|
||||
/** surfaceHeights is empty for a level parcel, or width*depth elevations for a street/ramp.
|
||||
* Facing: 0 north, 1 east, 2 south, 3 west. Road coordinates are already world-aligned. */
|
||||
public record Parcel(Kind kind, int minX, int minZ, int width, int depth,
|
||||
int floorY, int floors, int facing, long seed, int foundationDepth, int[] surfaceHeights) {
|
||||
public Parcel {
|
||||
if (kind == null || width < 1 || depth < 1 || width > 25 || depth > 25
|
||||
|| floors < 0 || floors > 5 || facing < 0 || facing > 3
|
||||
|| foundationDepth < 0 || foundationDepth > 12
|
||||
|| floorY < 48 || floorY + Math.max(8, floors * 5 + 5) > 382)
|
||||
throw new IllegalArgumentException("Invalid city parcel dimensions");
|
||||
surfaceHeights = surfaceHeights.clone();
|
||||
if (surfaceHeights.length != 0 && surfaceHeights.length != width * depth)
|
||||
throw new IllegalArgumentException("Invalid city road elevations");
|
||||
for (int y : surfaceHeights) if (y < 48 || y > 370)
|
||||
throw new IllegalArgumentException("Invalid road height");
|
||||
}
|
||||
@Override public int[] surfaceHeights() { return surfaceHeights.clone(); }
|
||||
public int surfaceY(int localX, int localZ) {
|
||||
return surfaceHeights.length == 0 ? floorY : surfaceHeights[localZ * width + localX];
|
||||
}
|
||||
public int maxX() { return minX + width - 1; }
|
||||
public int maxZ() { return minZ + depth - 1; }
|
||||
public int minY() {
|
||||
int y = floorY;
|
||||
for (int height : surfaceHeights) y = Math.min(y, height);
|
||||
return y - foundationDepth;
|
||||
}
|
||||
public int maxY() {
|
||||
int y = floorY;
|
||||
for (int height : surfaceHeights) y = Math.max(y, height);
|
||||
return y + Math.max(8, floors * 5 + 5);
|
||||
}
|
||||
public int maxRoofY() { return maxY(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package fr.koka.sanctuary.worldgen.city;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlan.District;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Kind;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Parcel;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** A small connected, terraced street grid fitted to actual immutable noise columns.
|
||||
* Planning never loads a chunk, edits terrain, or retries indefinitely to force a city.
|
||||
* Unsupported parcels and protected hydrology remain natural, including on the starter island. */
|
||||
public final class LostCityPlanner {
|
||||
private static final int CELL = 24;
|
||||
private static final int LOT = 17;
|
||||
private static final int MIN_FLOOR = 176;
|
||||
private static final int MAX_COLUMNS = 16_000;
|
||||
private static final Ground INVALID = new Ground(0, 0, false, true);
|
||||
private static final Kind[] KINDS = {
|
||||
Kind.APARTMENT, Kind.PARK, Kind.LIBRARY, Kind.RESTAURANT,
|
||||
Kind.PLAZA, Kind.HOTEL, Kind.MARKET, Kind.BARRACKS,
|
||||
Kind.PARKING, Kind.HALL, Kind.APARTMENT, Kind.PARK,
|
||||
Kind.HOTEL, Kind.PLAZA, Kind.HALL, Kind.RESTAURANT,
|
||||
Kind.EXCAVATION, Kind.POOL, Kind.PARK_GARDEN, Kind.PARK_PLAYGROUND, Kind.PARK_MEMORIAL
|
||||
};
|
||||
|
||||
private LostCityPlanner() {}
|
||||
|
||||
public record Ground(int top, int solidDepth, boolean dry, boolean protectedTerrain) {}
|
||||
@FunctionalInterface public interface GroundSampler { Ground sample(int x, int z); }
|
||||
|
||||
public static LostCityPlan plan(long seed, int centerX, int centerZ, int diameter,
|
||||
boolean origin, GroundSampler sampler) {
|
||||
if (diameter < 64 || diameter > 1024) throw new IllegalArgumentException("Unsupported city island size");
|
||||
int cells = origin ? 3 : diameter >= 512 ? 4 : diameter >= 256 ? 3 : diameter >= 128 ? 2 : 1;
|
||||
TerrainCache survey = new TerrainCache(centerX, centerZ, diameter, sampler);
|
||||
List<Candidate> candidates = candidates(seed, centerX, centerZ, diameter, origin, cells, survey);
|
||||
candidates.sort(Comparator.comparingInt(Candidate::score).reversed().thenComparingLong(Candidate::salt));
|
||||
List<Parcel> best = List.of();
|
||||
Candidate chosen = null;
|
||||
List<Integer> acceptedPerAttempt = new ArrayList<>();
|
||||
// A bounded fallback is useful when a coarse footprint concealed a fissure.
|
||||
for (int i = 0; i < Math.min(3, candidates.size()); i++) {
|
||||
Candidate candidate = candidates.get(i);
|
||||
if (candidate.score() == 0 || survey.exhausted()) break;
|
||||
List<Parcel> parcels = build(seed, candidate, cells, survey);
|
||||
acceptedPerAttempt.add(lotCount(parcels));
|
||||
if (lotCount(parcels) > lotCount(best)) { best = parcels; chosen = candidate; }
|
||||
if (lotCount(best) >= (origin ? 5 : Math.min(6, cells * cells))) break;
|
||||
}
|
||||
if (chosen == null || lotCount(best) < (cells == 1 ? 1 : 2)
|
||||
|| best.stream().noneMatch(p -> p.kind().building())) {
|
||||
if (Boolean.getBoolean("sanctuary.test.lostCity") || Boolean.getBoolean("sanctuary.city.plannerDiagnostics"))
|
||||
System.getLogger(LostCityPlanner.class.getName()).log(System.Logger.Level.INFO,
|
||||
"Lost City rejected seed=" + seed + " diameter=" + diameter + " origin=" + origin
|
||||
+ " coarse=" + candidates.stream().map(Candidate::score).toList()
|
||||
+ " connected=" + acceptedPerAttempt + " " + survey.diagnostics());
|
||||
return LostCityPlan.EMPTY;
|
||||
}
|
||||
return new LostCityPlan(List.of(new District(Math.floorDiv(chosen.x() + cells * CELL / 2, 16),
|
||||
Math.floorDiv(chosen.z() + cells * CELL / 2, 16), mix(seed ^ chosen.salt()), best)));
|
||||
}
|
||||
|
||||
private static int lotCount(List<Parcel> parcels) {
|
||||
return (int) parcels.stream().filter(p -> p.kind() != Kind.STREET && p.kind() != Kind.BRIDGE).count();
|
||||
}
|
||||
|
||||
private static List<Candidate> candidates(long seed, int cx, int cz, int diameter,
|
||||
boolean origin, int cells, TerrainCache survey) {
|
||||
List<Candidate> result = new ArrayList<>();
|
||||
int attempts = origin ? 12 : 9;
|
||||
double angle = unit(mix(seed ^ 0xC174A14L)) * Math.PI * 2;
|
||||
for (int attempt = 0; attempt < attempts; attempt++) {
|
||||
long salt = mix(seed ^ (attempt * 0x9E3779B97F4A7C15L) ^ 0xC17D157L);
|
||||
double radius = origin ? Math.min(diameter * .20, 150) * (attempt < 8 ? 1 : .48)
|
||||
: attempt == 0 ? 0 : diameter * .13;
|
||||
double a = angle + attempt * Math.PI * 2 / 8;
|
||||
int x = cx + (int) Math.round(Math.cos(a) * radius) - cells * CELL / 2;
|
||||
int z = cz + (int) Math.round(Math.sin(a) * radius) - cells * CELL / 2;
|
||||
int score = 0;
|
||||
for (int row = 0; row < cells; row++) for (int col = 0; col < cells; col++) {
|
||||
if (coarseLot(x + col * CELL + 4, z + row * CELL + 4, survey)) score++;
|
||||
}
|
||||
result.add(new Candidate(x, z, score, salt));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static boolean coarseLot(int x, int z, TerrainCache survey) {
|
||||
int low = Integer.MAX_VALUE, high = Integer.MIN_VALUE;
|
||||
for (int[] point : new int[][] {{0, 0}, {16, 0}, {0, 16}, {16, 16}, {8, 8}, {8, 0}}) {
|
||||
Ground g = survey.at(x + point[0], z + point[1]);
|
||||
if (!supported(g)) { survey.reject("coarse-support"); return false; }
|
||||
low = Math.min(low, g.top()); high = Math.max(high, g.top());
|
||||
}
|
||||
int floor = lotFloor(low, high);
|
||||
if (low < floor - 10 || high > floor + 2) { survey.reject("coarse-slope"); return false; }
|
||||
if (floor < MIN_FLOOR || floor > 350) { survey.reject("coarse-height"); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<Parcel> build(long seed, Candidate site, int cells, TerrainCache survey) {
|
||||
int[][] heights = new int[cells + 1][cells + 1];
|
||||
for (int row = 0; row <= cells; row++) for (int col = 0; col <= cells; col++) {
|
||||
Ground g = survey.at(site.x() + col * CELL, site.z() + row * CELL);
|
||||
heights[row][col] = supported(g) && g.top() < 360 ? g.top() + 1 : -1;
|
||||
}
|
||||
List<Edge> edges = new ArrayList<>();
|
||||
for (int row = 0; row < cells; row++) for (int col = 0; col < cells; col++) {
|
||||
int x = site.x() + col * CELL, z = site.z() + row * CELL;
|
||||
long parcelSeed = mix(seed ^ site.salt() ^ col * 0xD1342543DE82EF95L ^ row * 0xC6BC279692B5CC83L);
|
||||
int kindOffset = (int) Long.remainderUnsigned(mix(seed ^ 0xC174CA7L), KINDS.length);
|
||||
Kind kind = cells == 1 ? Kind.APARTMENT : KINDS[Math.floorMod(row * cells + col + kindOffset, KINDS.length)];
|
||||
Parcel lot = lot(kind, x + 4, z + 4, parcelSeed, survey);
|
||||
int middleY = lot == null ? survey.at(x + CELL / 2, z).top() + 1 : lot.floorY();
|
||||
Parcel road = road(x, z, true, heights[row][col], middleY, heights[row][col + 1], parcelSeed ^ 31, survey);
|
||||
if (road == null) continue;
|
||||
Parcel entrance = lot == null ? null : entrance(lot, survey);
|
||||
if (entrance == null) lot = null;
|
||||
List<Parcel> pieces = new ArrayList<>();
|
||||
pieces.add(road);
|
||||
if (lot != null) { pieces.add(lot); pieces.add(entrance); }
|
||||
edges.add(new Edge(node(col, row, cells), node(col + 1, row, cells), List.copyOf(pieces)));
|
||||
}
|
||||
// Cross streets join the rows; short unsupported runs can be bridge decks.
|
||||
// Endpoints and the first/last two columns must have real, dry abutments.
|
||||
for (int row = 0; row < cells - 1; row++) for (int col = 0; col <= cells; col++) {
|
||||
int y0 = heights[row][col], y1 = heights[row + 1][col];
|
||||
Parcel road = road(site.x() + col * CELL, site.z() + row * CELL, false,
|
||||
y0, y0 < 0 || y1 < 0 ? -1 : (y0 + y1) / 2, y1,
|
||||
mix(seed ^ site.salt() ^ row * 479L ^ col * 1861L), survey);
|
||||
if (road != null) edges.add(new Edge(node(col, row, cells), node(col, row + 1, cells), List.of(road)));
|
||||
}
|
||||
return largestConnectedDistrict(edges);
|
||||
}
|
||||
|
||||
private static Parcel lot(Kind kind, int x, int z, long seed, TerrainCache survey) {
|
||||
if (!coarseLot(x, z, survey)) return null;
|
||||
int low = Integer.MAX_VALUE, high = Integer.MIN_VALUE;
|
||||
for (int dz = 0; dz < LOT; dz++) for (int dx = 0; dx < LOT; dx++) {
|
||||
Ground g = survey.at(x + dx, z + dz);
|
||||
if (!supported(g)) { survey.reject("lot-support"); return null; }
|
||||
low = Math.min(low, g.top()); high = Math.max(high, g.top());
|
||||
}
|
||||
int floor = lotFloor(low, high);
|
||||
if (low < floor - 10 || high > floor + 2) { survey.reject("lot-slope"); return null; }
|
||||
if (floor < MIN_FLOOR || floor > 350) { survey.reject("lot-height"); return null; }
|
||||
int floors = switch (kind) {
|
||||
case APARTMENT, HOTEL -> 3 + (int) Long.remainderUnsigned(seed, 3);
|
||||
case LIBRARY, RESTAURANT, BARRACKS, HALL -> 2;
|
||||
case PARKING, MARKET -> 1;
|
||||
default -> 0;
|
||||
};
|
||||
int foundation = Math.max(2, floor - low + 1);
|
||||
if (kind == Kind.PARKING || kind == Kind.POOL || kind == Kind.EXCAVATION) {
|
||||
// >=8 certifies a contained excavation, pool or optional underground garage.
|
||||
// Every existing surface must cover the ceiling and solid rock must cover its floor.
|
||||
if (low >= floor - 1 && solidTo(x, z, LOT, LOT, floor - 8, survey)) foundation = 8;
|
||||
else if (kind != Kind.PARKING) kind = Kind.PARK;
|
||||
else if (foundation >= 8) return null;
|
||||
}
|
||||
if (!solidTo(x, z, LOT, LOT, floor - foundation, survey)) { survey.reject("lot-foundation"); return null; }
|
||||
// Every parcel faces its street to the north. Variety comes from the seed,
|
||||
// facade details and building types, never from a door pointed into another building.
|
||||
return new Parcel(kind, x, z, LOT, LOT, floor, floors, 0, seed,
|
||||
foundation, new int[0]);
|
||||
}
|
||||
|
||||
private static Parcel entrance(Parcel lot, TerrainCache survey) {
|
||||
int x = lot.minX() + LOT / 2 - 1, z = lot.minZ() - 2, floor = lot.floorY(), foundation = 2;
|
||||
for (int dz = 0; dz < 2; dz++) for (int dx = 0; dx < 3; dx++) {
|
||||
Ground g = survey.at(x + dx, z + dz);
|
||||
if (!supported(g) || g.top() < floor - 10 || g.top() > floor + 2) return null;
|
||||
foundation = Math.max(foundation, floor - g.top() + 1);
|
||||
}
|
||||
if (!solidTo(x, z, 3, 2, floor - foundation, survey)) return null;
|
||||
return new Parcel(Kind.STREET, x, z, 3, 2, floor, 0, 0, lot.seed() ^ 63, foundation, new int[0]);
|
||||
}
|
||||
|
||||
private static Parcel road(int x, int z, boolean alongX, int startY, int middleY, int endY,
|
||||
long seed, TerrainCache survey) {
|
||||
if (startY < MIN_FLOOR || middleY < MIN_FLOOR || endY < MIN_FLOOR || startY > 360 || middleY > 360 || endY > 360
|
||||
|| Math.abs(startY - middleY) > CELL / 2 - 2 || Math.abs(endY - middleY) > CELL / 2 - 2) {
|
||||
survey.reject("road-endpoint-height"); return null;
|
||||
}
|
||||
int width = alongX ? CELL + 1 : 5, depth = alongX ? 5 : CELL + 1;
|
||||
int minX = alongX ? x : x - 2, minZ = alongX ? z - 2 : z;
|
||||
int[] surface = new int[width * depth];
|
||||
int foundation = 2, gapRun = 0;
|
||||
boolean bridge = false;
|
||||
for (int step = 0; step <= CELL; step++) {
|
||||
// Flat 5x5 intersections agree in both directions; ramps start beyond them.
|
||||
int y = step < 2 ? startY : step > CELL - 2 ? endY : step <= CELL / 2
|
||||
? lerp(startY, middleY, step - 2, CELL / 2 - 2)
|
||||
: lerp(middleY, endY, step - CELL / 2, CELL / 2 - 2);
|
||||
boolean gap = false;
|
||||
for (int across = 0; across < 5; across++) {
|
||||
int dx = alongX ? step : across, dz = alongX ? across : step;
|
||||
Ground g = survey.at(minX + dx, minZ + dz);
|
||||
// No deck cuts a protected river, spring or basin, even if an adjacent bank is solid.
|
||||
if (g.protectedTerrain()) { survey.reject("road-protected"); return null; }
|
||||
if (g.top() > y + 2) { survey.reject("road-cut"); return null; }
|
||||
boolean support = supported(g) && g.top() >= y - 10;
|
||||
if (!support) {
|
||||
if (g.top() >= y - 2) { survey.reject("road-weak-surface"); return null; }
|
||||
gap = true;
|
||||
} else foundation = Math.max(foundation, y - g.top() + 1);
|
||||
surface[dz * width + dx] = y;
|
||||
}
|
||||
if (gap) {
|
||||
if (step < 2 || step > CELL - 2 || ++gapRun > 8) { survey.reject("road-bridge-support"); return null; }
|
||||
bridge = true;
|
||||
} else gapRun = 0;
|
||||
}
|
||||
if (!bridge) for (int dz = 0; dz < depth; dz++) for (int dx = 0; dx < width; dx++) {
|
||||
Ground g = survey.at(minX + dx, minZ + dz);
|
||||
if (g.top() - (surface[dz * width + dx] - foundation) + 1 > g.solidDepth()) {
|
||||
survey.reject("road-foundation"); return null;
|
||||
}
|
||||
}
|
||||
return new Parcel(bridge ? Kind.BRIDGE : Kind.STREET, minX, minZ, width, depth,
|
||||
startY, 0, 0, seed, bridge ? 0 : foundation, surface);
|
||||
}
|
||||
|
||||
private static List<Parcel> largestConnectedDistrict(List<Edge> edges) {
|
||||
Map<Integer, List<Edge>> neighbors = new HashMap<>();
|
||||
for (Edge edge : edges) {
|
||||
neighbors.computeIfAbsent(edge.a(), ignored -> new ArrayList<>()).add(edge);
|
||||
neighbors.computeIfAbsent(edge.b(), ignored -> new ArrayList<>()).add(edge);
|
||||
}
|
||||
Set<Integer> visited = new HashSet<>();
|
||||
List<Parcel> best = List.of();
|
||||
// Stable insertion order comes from edges, never HashMap iteration.
|
||||
for (Edge start : edges) {
|
||||
if (visited.contains(start.a())) continue;
|
||||
Set<Edge> component = new HashSet<>();
|
||||
ArrayDeque<Integer> queue = new ArrayDeque<>();
|
||||
queue.add(start.a()); visited.add(start.a());
|
||||
while (!queue.isEmpty()) {
|
||||
int node = queue.removeFirst();
|
||||
for (Edge edge : neighbors.getOrDefault(node, List.of())) {
|
||||
component.add(edge);
|
||||
int other = edge.a() == node ? edge.b() : edge.a();
|
||||
if (visited.add(other)) queue.addLast(other);
|
||||
}
|
||||
}
|
||||
List<Parcel> parcels = new ArrayList<>();
|
||||
for (Edge edge : edges) if (component.contains(edge)) parcels.addAll(edge.parcels());
|
||||
if (lotCount(parcels) > lotCount(best)) best = List.copyOf(parcels);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static boolean supported(Ground g) {
|
||||
return g.dry() && !g.protectedTerrain() && g.solidDepth() >= 4 && g.top() >= MIN_FLOOR - 12;
|
||||
}
|
||||
/** A terrace follows the entire footprint rather than privileging its north entrance.
|
||||
* Flat sites stay one block above their natural floor; modest uphill sites may cut at
|
||||
* most two surface blocks. Foundation thickness and continuous rock are checked later. */
|
||||
private static int lotFloor(int low, int high) {
|
||||
return high + 1 - Math.min(2, (high - low) / 3);
|
||||
}
|
||||
private static boolean solidTo(int x, int z, int width, int depth, int bottomY, TerrainCache survey) {
|
||||
for (int dz = 0; dz < depth; dz++) for (int dx = 0; dx < width; dx++) {
|
||||
Ground g = survey.at(x + dx, z + dz);
|
||||
if (g.top() < bottomY || g.top() - bottomY + 1 > g.solidDepth()) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private static int lerp(int a, int b, int step, int length) { return a + Math.round((b - a) * (step / (float) length)); }
|
||||
private static int node(int col, int row, int cells) { return row * (cells + 1) + col; }
|
||||
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);
|
||||
}
|
||||
private record Candidate(int x, int z, int score, long salt) {}
|
||||
private record Edge(int a, int b, List<Parcel> parcels) {}
|
||||
|
||||
private static final class TerrainCache {
|
||||
private final int centerX, centerZ;
|
||||
private final double radius;
|
||||
private final GroundSampler sampler;
|
||||
private final Map<Long, Ground> columns = new HashMap<>();
|
||||
private final Map<String, Integer> rejections = new java.util.TreeMap<>();
|
||||
TerrainCache(int centerX, int centerZ, int diameter, GroundSampler sampler) {
|
||||
this.centerX = centerX; this.centerZ = centerZ; this.radius = diameter / 2.0 - 6;
|
||||
this.sampler = java.util.Objects.requireNonNull(sampler);
|
||||
}
|
||||
boolean exhausted() { return columns.size() >= MAX_COLUMNS; }
|
||||
void reject(String reason) { rejections.merge(reason, 1, Integer::sum); }
|
||||
String diagnostics() {
|
||||
return "columns=" + columns.size() + " heights=" + columns.values().stream().mapToInt(Ground::top).summaryStatistics()
|
||||
+ " dry=" + columns.values().stream().filter(Ground::dry).count()
|
||||
+ " solid4=" + columns.values().stream().filter(g -> g.solidDepth() >= 4).count()
|
||||
+ " protected=" + columns.values().stream().filter(Ground::protectedTerrain).count()
|
||||
+ " rejected=" + rejections;
|
||||
}
|
||||
Ground at(int x, int z) {
|
||||
if (Math.hypot((double) x - centerX, (double) z - centerZ) > radius) return INVALID;
|
||||
long key = ((long) x << 32) ^ (z & 0xffffffffL);
|
||||
Ground cached = columns.get(key);
|
||||
if (cached != null) return cached;
|
||||
if (exhausted()) return INVALID;
|
||||
Ground value = java.util.Objects.requireNonNull(sampler.sample(x, z));
|
||||
columns.put(key, value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
package fr.koka.sanctuary.worldgen.city;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Kind;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Parcel;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.BedPart;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.storage.loot.BuiltInLootTables;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
|
||||
/** Original, chunk-independent contemporary ruins. The saved parcel is the complete random input. */
|
||||
public final class LostCityRenderer {
|
||||
private static final BlockState AIR = LostCityPalette.AIR;
|
||||
private static final BlockState SMOOTH = LostCityPalette.SMOOTH;
|
||||
private static final BlockState STONE = LostCityPalette.STONE;
|
||||
private static final BlockState WOOD = Blocks.DARK_OAK_PLANKS.defaultBlockState();
|
||||
private static final int FLOOR_HEIGHT = 5;
|
||||
|
||||
/** The structure piece owns world translation, clipping and deferred loot persistence. */
|
||||
public interface BlockWriter {
|
||||
void set(int x, int y, int z, BlockState state);
|
||||
void chest(int x, int y, int z, ResourceKey<LootTable> table, long lootSeed);
|
||||
}
|
||||
|
||||
private LostCityRenderer() {}
|
||||
|
||||
public static void render(Parcel parcel, BlockWriter output) {
|
||||
boolean building = switch (parcel.kind()) {
|
||||
case APARTMENT, HOTEL, LIBRARY, RESTAURANT, BARRACKS, HALL, PARKING,
|
||||
EXCAVATION, POOL, PARK_GARDEN, PARK_PLAYGROUND, PARK_MEMORIAL -> true;
|
||||
default -> false;
|
||||
};
|
||||
BlockWriter writer = building ? rotated(output, parcel) : output;
|
||||
Painter paint = new Painter(parcel, writer);
|
||||
switch (parcel.kind()) {
|
||||
case STREET -> paint.street(false);
|
||||
case BRIDGE -> paint.street(true);
|
||||
case PARK -> paint.park();
|
||||
case PARK_GARDEN -> paint.garden();
|
||||
case PARK_PLAYGROUND -> paint.playground();
|
||||
case PARK_MEMORIAL -> paint.memorial();
|
||||
case PLAZA -> paint.plaza(false);
|
||||
case MARKET -> paint.plaza(true);
|
||||
case PARKING -> paint.parking();
|
||||
case EXCAVATION -> paint.excavation();
|
||||
case POOL -> paint.pool();
|
||||
case APARTMENT, HOTEL, LIBRARY, RESTAURANT, BARRACKS, HALL -> paint.building();
|
||||
}
|
||||
}
|
||||
|
||||
private static BlockWriter rotated(BlockWriter output, Parcel parcel) {
|
||||
if (parcel.facing() == 0) return output;
|
||||
Rotation rotation = switch (parcel.facing()) {
|
||||
case 1 -> Rotation.CLOCKWISE_90;
|
||||
case 2 -> Rotation.CLOCKWISE_180;
|
||||
case 3 -> Rotation.COUNTERCLOCKWISE_90;
|
||||
default -> throw new IllegalArgumentException("Invalid Lost City facing");
|
||||
};
|
||||
return new BlockWriter() {
|
||||
private int x(int x, int z) {
|
||||
return switch (parcel.facing()) {
|
||||
case 1 -> parcel.width() - 1 - z;
|
||||
case 2 -> parcel.width() - 1 - x;
|
||||
default -> z;
|
||||
};
|
||||
}
|
||||
|
||||
private int z(int x, int z) {
|
||||
return switch (parcel.facing()) {
|
||||
case 1 -> x;
|
||||
case 2 -> parcel.depth() - 1 - z;
|
||||
default -> parcel.depth() - 1 - x;
|
||||
};
|
||||
}
|
||||
|
||||
@Override public void set(int x, int y, int z, BlockState state) {
|
||||
output.set(x(x, z), y, z(x, z), state.rotate(rotation));
|
||||
}
|
||||
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long lootSeed) {
|
||||
output.chest(x(x, z), y, z(x, z), table, lootSeed);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static final class Painter {
|
||||
private final Parcel parcel;
|
||||
private final BlockWriter writer;
|
||||
private final int width;
|
||||
private final int depth;
|
||||
private final int middle;
|
||||
|
||||
Painter(Parcel parcel, BlockWriter writer) {
|
||||
this.parcel = parcel;
|
||||
this.writer = writer;
|
||||
width = parcel.width();
|
||||
depth = parcel.depth();
|
||||
middle = width / 2;
|
||||
}
|
||||
|
||||
private void set(int x, int y, int z, BlockState state) {
|
||||
writer.set(x, y, z, state);
|
||||
}
|
||||
|
||||
private void set(int x, int y, int z, Block block) {
|
||||
set(x, y, z, block.defaultBlockState());
|
||||
}
|
||||
|
||||
private void box(int x0, int y0, int z0, int x1, int y1, int z1, BlockState state) {
|
||||
for (int z = z0; z <= z1; z++) {
|
||||
for (int x = x0; x <= x1; x++) {
|
||||
for (int y = y0; y <= y1; y++) set(x, y, z, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int roll(int x, int y, int z, int bound) {
|
||||
return LostCityPalette.roll(parcel.seed(), x, y, z, bound);
|
||||
}
|
||||
|
||||
private BlockState brick(int x, int y, int z) {
|
||||
return LostCityPalette.masonry(parcel.seed(), x, y, z);
|
||||
}
|
||||
|
||||
private void foundation() {
|
||||
box(0, -parcel.foundationDepth(), 0, width - 1, -1, depth - 1, STONE);
|
||||
box(0, 0, 0, width - 1, 0, depth - 1, SMOOTH);
|
||||
}
|
||||
|
||||
private void street(boolean bridge) {
|
||||
boolean eastWest = width > depth;
|
||||
int length = eastWest ? width : depth;
|
||||
int breadth = eastWest ? depth : width;
|
||||
for (int along = 0; along < length; along++) {
|
||||
for (int across = 0; across < breadth; across++) {
|
||||
int x = eastWest ? along : across;
|
||||
int z = eastWest ? across : along;
|
||||
int y = parcel.surfaceY(x, z) - parcel.floorY();
|
||||
if (!bridge) {
|
||||
for (int support = 1; support <= parcel.foundationDepth(); support++) set(x, y - support, z, STONE);
|
||||
}
|
||||
// Bridge banks are verified by the planner; the deck never grows support pillars.
|
||||
boolean edge = across == 0 || across == breadth - 1;
|
||||
set(x, y, z, edge ? SMOOTH : LostCityPalette.pavement(parcel.seed(), x, z));
|
||||
for (int air = 1; air <= 5; air++) set(x, y + air, z, AIR);
|
||||
if (bridge && edge) set(x, y + 1, z, bars(eastWest));
|
||||
if (!edge && along > 0 && along < length - 1 && across == breadth / 2
|
||||
&& Math.floorMod(along, 7) < 3) set(x, y, z, SMOOTH);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void building() {
|
||||
foundation();
|
||||
int floors = parcel.floors();
|
||||
int roof = floors * FLOOR_HEIGHT;
|
||||
box(0, 1, 0, width - 1, roof + 2, depth - 1, AIR);
|
||||
for (int y = 1; y <= roof; y++) {
|
||||
for (int z = 0; z < depth; z++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
boolean wall = x == 0 || x == width - 1 || z == 0 || z == depth - 1;
|
||||
boolean slab = y % FLOOR_HEIGHT == 0;
|
||||
boolean hallVoid = parcel.kind() == Kind.HALL && y < roof
|
||||
&& x > 3 && x < width - 4 && z > 3 && z < depth - 4;
|
||||
if (slab && !hallVoid) {
|
||||
set(x, y, z, wall || y == roof ? SMOOTH : WOOD);
|
||||
} else if (wall) {
|
||||
boolean pillar = x % 4 == 0 && (z == 0 || z == depth - 1)
|
||||
|| z % 4 == 0 && (x == 0 || x == width - 1);
|
||||
boolean window = !pillar && (y % FLOOR_HEIGHT == 2 || y % FLOOR_HEIGHT == 3);
|
||||
if (pillar) set(x, y, z, STONE);
|
||||
else if (window) {
|
||||
if (roll(x, y / 5, z, 7) != 0) set(x, y, z, panes(z == 0 || z == depth - 1));
|
||||
} else if (!damagedCorner(x, y, z, roof)) set(x, y, z, brick(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Low parapets and rooftop ventilation distinguish flat-roofed modern buildings.
|
||||
for (int x = 0; x < width; x++) {
|
||||
if (!damagedCorner(x, roof, 0, roof)) set(x, roof + 1, 0, brick(x, roof, 0));
|
||||
if (!damagedCorner(x, roof, depth - 1, roof)) set(x, roof + 1, depth - 1, brick(x, roof, depth - 1));
|
||||
}
|
||||
for (int z = 1; z < depth - 1; z++) {
|
||||
set(0, roof + 1, z, brick(0, roof, z));
|
||||
if (!damagedCorner(width - 1, roof, z, roof)) set(width - 1, roof + 1, z, brick(width - 1, roof, z));
|
||||
}
|
||||
box(width - 5, roof + 1, depth - 5, width - 3, roof + 1, depth - 3, SMOOTH);
|
||||
set(width - 4, roof + 2, depth - 4, Blocks.IRON_TRAPDOOR);
|
||||
for (int floor = 0; floor < floors; floor++) furnish(floor);
|
||||
staircase(floors);
|
||||
// Doorway, central lobby and stair approach are always restored after furnishings.
|
||||
box(middle - 1, 1, 0, middle + 1, 3, 3, AIR);
|
||||
box(1, 1, 1, middle + 1, 3, 1, AIR);
|
||||
box(middle - 2, 4, 0, middle + 2, 4, 1, SMOOTH);
|
||||
for (int y = 1; y <= 3; y++) {
|
||||
set(middle - 2, y, 0, SMOOTH);
|
||||
set(middle + 2, y, 0, SMOOTH);
|
||||
}
|
||||
for (int floor = 0; floor < floors; floor++) {
|
||||
int y = floor * FLOOR_HEIGHT;
|
||||
if (floor > 0 && parcel.kind() != Kind.HALL) {
|
||||
box(middle - 1, y + 1, 1, middle + 1, y + 3, depth - 2, AIR);
|
||||
}
|
||||
set(width - 2, y + 4, depth - 2, Blocks.COBWEB);
|
||||
}
|
||||
// A small breach on the far corner leaves stairs, doors and all floor plates intact.
|
||||
if (roll(18, 0, 29, 3) == 0) {
|
||||
box(width - 3, roof, depth - 3, width - 2, roof, depth - 2, AIR);
|
||||
box(width - 2, roof - 3, depth - 2, width - 2, roof - 3, depth - 2, brick(width - 2, 0, depth - 2));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean damagedCorner(int x, int y, int z, int roof) {
|
||||
return x >= width - 4 && z >= depth - 4 && y > roof - 4
|
||||
&& roll(x / 2, y / 2, z / 2, 4) != 0;
|
||||
}
|
||||
|
||||
private void staircase(int floors) {
|
||||
for (int floor = 0; floor < floors; floor++) {
|
||||
int base = floor * FLOOR_HEIGHT;
|
||||
for (int step = 1; step <= FLOOR_HEIGHT; step++) {
|
||||
int z = step + 1;
|
||||
box(1, base + step, z, 2, base + step + 3, z, AIR);
|
||||
for (int x = 1; x <= 2; x++) set(x, base + step, z, stairs(Direction.SOUTH));
|
||||
}
|
||||
box(1, base + FLOOR_HEIGHT, 7, 3, base + FLOOR_HEIGHT, 8, WOOD);
|
||||
box(1, base + FLOOR_HEIGHT + 1, 7, parcel.kind() == Kind.HALL ? 2 : 3,
|
||||
base + FLOOR_HEIGHT + 3, 8, AIR);
|
||||
// West stairwell is accessible from every floor through the rear landing.
|
||||
if (parcel.kind() != Kind.HALL || floor == 0) {
|
||||
box(3, base + 1, 7, middle - 1, base + 3, 8, AIR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void furnish(int floor) {
|
||||
int y = floor * FLOOR_HEIGHT;
|
||||
switch (parcel.kind()) {
|
||||
case LIBRARY -> library(y);
|
||||
case RESTAURANT -> restaurant(y, floor == 0);
|
||||
case HOTEL -> hotel(y, floor == 0);
|
||||
case BARRACKS -> barracks(y);
|
||||
case HALL -> hall(y, floor == 0);
|
||||
case APARTMENT -> apartment(y);
|
||||
default -> throw new IllegalStateException("Not a building: " + parcel.kind());
|
||||
}
|
||||
}
|
||||
|
||||
private void library(int y) {
|
||||
// Large wall shelves, reading tables and stacks with walkable aisles.
|
||||
box(width - 2, y + 1, 2, width - 2, y + 3, depth - 3, Blocks.BOOKSHELF.defaultBlockState());
|
||||
box(4, y + 1, depth - 2, width - 3, y + 3, depth - 2, Blocks.BOOKSHELF.defaultBlockState());
|
||||
for (int z = 4; z < depth - 3; z += 5) {
|
||||
box(4, y + 1, z, 5, y + 2, z, Blocks.BOOKSHELF.defaultBlockState());
|
||||
table(width - 6, y + 1, z);
|
||||
}
|
||||
set(middle + 3, y + 1, 2, facing(Blocks.LECTERN, Direction.NORTH));
|
||||
if (y == 0) loot(width - 3, 1, depth - 3, BuiltInLootTables.VILLAGE_CARTOGRAPHER);
|
||||
}
|
||||
|
||||
private void restaurant(int y, boolean kitchen) {
|
||||
if (kitchen) {
|
||||
for (int x = 4; x < width - 2; x++) set(x, y + 1, depth - 3, SMOOTH);
|
||||
set(4, y + 1, depth - 2, facing(Blocks.FURNACE, Direction.NORTH));
|
||||
set(5, y + 1, depth - 2, facing(Blocks.SMOKER, Direction.NORTH));
|
||||
set(6, y + 1, depth - 2, Blocks.CAULDRON);
|
||||
set(width - 3, y + 1, depth - 2, Blocks.BARREL);
|
||||
box(4, y + 3, depth - 2, 6, y + 3, depth - 2, SMOOTH);
|
||||
loot(width - 4, y + 1, depth - 2, BuiltInLootTables.VILLAGE_BUTCHER);
|
||||
}
|
||||
for (int z = 4; z < depth - 5; z += 4) {
|
||||
table(middle + 4, y + 1, z);
|
||||
if (z > 7) table(4, y + 1, z);
|
||||
}
|
||||
if (!kitchen) {
|
||||
box(4, y + 1, depth - 3, width - 3, y + 1, depth - 3, WOOD);
|
||||
set(width - 3, y + 2, depth - 3, Blocks.FLOWER_POT);
|
||||
}
|
||||
}
|
||||
|
||||
private void hotel(int y, boolean reception) {
|
||||
if (reception) {
|
||||
box(middle + 3, y + 1, 3, width - 3, y + 1, 3, WOOD);
|
||||
set(width - 3, y + 2, 3, Blocks.POTTED_DEAD_BUSH);
|
||||
table(middle + 4, y + 1, 8);
|
||||
loot(width - 3, y + 1, 2, BuiltInLootTables.VILLAGE_PLAINS_HOUSE);
|
||||
bedroom(width - 5, y, depth - 6);
|
||||
return;
|
||||
}
|
||||
// A central corridor, actual room partitions and doors into the bedrooms.
|
||||
for (int x : new int[] { middle - 2, middle + 2 }) {
|
||||
box(x, y + 1, 2, x, y + 4, depth - 2, WOOD);
|
||||
box(x, y + 1, 4, x, y + 2, 5, AIR);
|
||||
box(x, y + 1, 10, x, y + 2, 11, AIR);
|
||||
}
|
||||
box(middle + 3, y + 1, 8, width - 2, y + 4, 8, WOOD);
|
||||
bedroom(width - 5, y, 3);
|
||||
bedroom(width - 5, y, 10);
|
||||
bedroom(3, y, 10);
|
||||
}
|
||||
|
||||
private void apartment(int y) {
|
||||
bedroom(width - 5, y, depth - 6);
|
||||
box(middle + 2, y + 1, depth - 8, width - 2, y + 3, depth - 8, WOOD);
|
||||
box(middle + 2, y + 1, depth - 7, middle + 2, y + 2, depth - 5, AIR);
|
||||
table(middle + 4, y + 1, 4);
|
||||
set(width - 3, y + 1, 2, facing(Blocks.FURNACE, Direction.SOUTH));
|
||||
set(width - 4, y + 1, 2, SMOOTH);
|
||||
set(width - 5, y + 1, 2, Blocks.CAULDRON);
|
||||
box(4, y + 1, depth - 3, 5, y + 1, depth - 3, facing(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
|
||||
if (y == 0) loot(width - 3, y + 1, depth - 3, BuiltInLootTables.VILLAGE_PLAINS_HOUSE);
|
||||
}
|
||||
|
||||
private void barracks(int y) {
|
||||
for (int z : new int[] { 3, 8, 12 }) {
|
||||
bedroom(width - 5, y, z);
|
||||
if (z > 8) bedroom(4, y, z);
|
||||
}
|
||||
box(4, y + 1, 2, 5, y + 1, 2, Blocks.BARREL.defaultBlockState());
|
||||
box(4, y + 2, 2, 5, y + 2, 2, bars(true));
|
||||
if (y == 0) loot(width - 3, y + 1, depth - 2, BuiltInLootTables.VILLAGE_FLETCHER);
|
||||
}
|
||||
|
||||
private void hall(int y, boolean groundFloor) {
|
||||
if (!groundFloor) {
|
||||
for (int x = 4; x < width - 4; x++) {
|
||||
set(x, y + 1, 3, bars(true));
|
||||
set(x, y + 1, depth - 4, bars(true));
|
||||
}
|
||||
for (int z = 4; z < depth - 4; z++) {
|
||||
set(3, y + 1, z, bars(false));
|
||||
set(width - 4, y + 1, z, bars(false));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Double-height meeting hall: an aisle, rows of seats, a stage and lectern.
|
||||
for (int z = 4; z < depth - 6; z += 3) {
|
||||
for (int x = 4; x <= 5; x++) set(x, y + 1, z, facing(Blocks.DARK_OAK_STAIRS, Direction.SOUTH));
|
||||
for (int x = width - 6; x < width - 3; x++) set(x, y + 1, z, facing(Blocks.DARK_OAK_STAIRS, Direction.SOUTH));
|
||||
}
|
||||
box(4, 1, depth - 5, width - 3, 1, depth - 2, WOOD);
|
||||
set(middle, 2, depth - 3, facing(Blocks.LECTERN, Direction.NORTH));
|
||||
set(middle + 3, 2, depth - 3, Blocks.POTTED_DEAD_BUSH);
|
||||
loot(width - 3, 1, 2, BuiltInLootTables.VILLAGE_CARTOGRAPHER);
|
||||
}
|
||||
|
||||
private void bedroom(int x, int y, int z) {
|
||||
if (z + 1 >= depth - 1) return;
|
||||
BlockState bed = Blocks.BED.white().defaultBlockState()
|
||||
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.SOUTH);
|
||||
set(x, y + 1, z, bed.setValue(BlockStateProperties.BED_PART, BedPart.FOOT));
|
||||
set(x, y + 1, z + 1, bed.setValue(BlockStateProperties.BED_PART, BedPart.HEAD));
|
||||
set(x + 1, y + 1, z + 1, Blocks.BARREL);
|
||||
}
|
||||
|
||||
private void table(int x, int y, int z) {
|
||||
set(x, y, z, Blocks.DARK_OAK_FENCE);
|
||||
set(x, y + 1, z, Blocks.DARK_OAK_PRESSURE_PLATE);
|
||||
set(x - 1, y, z, facing(Blocks.DARK_OAK_STAIRS, Direction.EAST));
|
||||
set(x + 1, y, z, facing(Blocks.DARK_OAK_STAIRS, Direction.WEST));
|
||||
}
|
||||
|
||||
private void loot(int x, int y, int z, ResourceKey<LootTable> table) {
|
||||
writer.chest(x, y, z, table, parcel.seed() ^ (x * 0x9e3779b9L) ^ (z * 0xc2b2ae35L));
|
||||
}
|
||||
|
||||
private void park() {
|
||||
parkGround();
|
||||
for (int z : new int[] { 3, depth - 4 }) {
|
||||
for (int x = middle + 3; x <= middle + 5; x++) set(x, 1, z, facing(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
|
||||
}
|
||||
tree(3, 3);
|
||||
if (roll(0, 0, 0, 3) != 0) tree(width - 4, depth - 4);
|
||||
set(3, 1, depth - 4, Blocks.COMPOSTER);
|
||||
}
|
||||
|
||||
private void parkGround() {
|
||||
foundation();
|
||||
box(0, 1, 0, width - 1, 8, depth - 1, AIR);
|
||||
for (int z = 0; z < depth; z++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
if (Math.abs(x - middle) <= 1 || Math.abs(z - depth / 2) <= 1) {
|
||||
set(x, 0, z, LostCityPalette.pavement(parcel.seed(), x, z));
|
||||
} else {
|
||||
set(x, 0, z, Blocks.GRASS_BLOCK);
|
||||
if (roll(x, 0, z, 9) == 0) set(x, 1, z, Blocks.SHORT_GRASS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void garden() {
|
||||
parkGround();
|
||||
BlockState hedge = Blocks.OAK_LEAVES.defaultBlockState().setValue(BlockStateProperties.PERSISTENT, true);
|
||||
for (int z0 : new int[] { 2, depth - 7 }) {
|
||||
for (int x0 : new int[] { 2, width - 7 }) {
|
||||
for (int z = z0; z <= z0 + 4; z++) {
|
||||
for (int x = x0; x <= x0 + 4; x++) {
|
||||
boolean edge = x == x0 || z == z0 || x == x0 + 4 || z == z0 + 4;
|
||||
set(x, 1, z, edge ? hedge : (roll(x, 0, z, 2) == 0 ? Blocks.POPPY : Blocks.OXEYE_DAISY).defaultBlockState());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int x = middle - 1; x <= middle + 1; x++) set(x, 1, depth - 2, facing(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
|
||||
set(1, 1, depth - 2, Blocks.COMPOSTER);
|
||||
}
|
||||
|
||||
private void playground() {
|
||||
parkGround();
|
||||
box(2, 0, 2, 6, 0, 6, Blocks.SAND.defaultBlockState());
|
||||
box(2, 1, 2, 6, 5, 6, AIR);
|
||||
// An abandoned swing frame, with a suspended wooden seat.
|
||||
box(2, 1, 4, 2, 4, 4, Blocks.DARK_OAK_FENCE.defaultBlockState());
|
||||
box(6, 1, 4, 6, 4, 4, Blocks.DARK_OAK_FENCE.defaultBlockState());
|
||||
box(2, 5, 4, 6, 5, 4, WOOD);
|
||||
box(4, 3, 4, 4, 4, 4, Blocks.IRON_CHAIN.defaultBlockState());
|
||||
set(4, 2, 4, Blocks.DARK_OAK_SLAB);
|
||||
// A small timber climbing platform and a stone slide, accessible by steps.
|
||||
box(width - 6, 3, depth - 6, width - 4, 3, depth - 4, WOOD);
|
||||
box(width - 6, 1, depth - 4, width - 6, 2, depth - 4, WOOD);
|
||||
box(width - 4, 1, depth - 4, width - 4, 2, depth - 4, WOOD);
|
||||
for (int step = 0; step < 3; step++) {
|
||||
set(width - 5, step, depth - 9 + step, stairs(Direction.SOUTH));
|
||||
box(width - 5, step + 1, depth - 9 + step, width - 5, step + 3, depth - 9 + step, AIR);
|
||||
}
|
||||
for (int x = 3; x <= 5; x++) set(x, 1, depth - 4, facing(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
|
||||
}
|
||||
|
||||
private void memorial() {
|
||||
plaza(false);
|
||||
box(middle - 2, 1, depth / 2 - 2, middle + 2, 3, depth / 2 + 2, AIR);
|
||||
box(middle - 1, 1, depth / 2 - 1, middle + 1, 1, depth / 2 + 1, SMOOTH);
|
||||
set(middle, 2, depth / 2, Blocks.STONE_BRICKS);
|
||||
set(middle, 3, depth / 2, Blocks.CHISELED_STONE_BRICKS);
|
||||
set(middle, 4, depth / 2, Blocks.CHISELED_STONE_BRICKS);
|
||||
set(middle, 5, depth / 2, Blocks.STONE_BRICK_SLAB);
|
||||
for (int x : new int[] { middle - 3, middle + 3 }) {
|
||||
set(x, 0, depth / 2, Blocks.GRASS_BLOCK);
|
||||
set(x, 1, depth / 2, Blocks.OXEYE_DAISY);
|
||||
}
|
||||
}
|
||||
|
||||
private void excavation() {
|
||||
if (parcel.foundationDepth() < 8) { park(); return; }
|
||||
foundation();
|
||||
box(0, 1, 0, width - 1, 7, depth - 1, AIR);
|
||||
box(3, -4, 3, width - 4, 0, depth - 4, AIR);
|
||||
for (int z = 3; z <= depth - 4; z++) {
|
||||
for (int x = 3; x <= width - 4; x++) set(x, -5, z,
|
||||
roll(x / 2, 0, z / 2, 4) == 0 ? Blocks.GRAVEL.defaultBlockState() : STONE);
|
||||
}
|
||||
// Intact temporary barriers surround the pit; only the street-facing stair gate is open.
|
||||
for (int x = 2; x < width - 2; x++) {
|
||||
if (Math.abs(x - middle) > 1) set(x, 1, 2, bars(true));
|
||||
set(x, 1, depth - 3, bars(true));
|
||||
}
|
||||
for (int z = 3; z < depth - 3; z++) {
|
||||
set(2, 1, z, bars(false));
|
||||
set(width - 3, 1, z, bars(false));
|
||||
}
|
||||
for (int step = 1; step <= 5; step++) {
|
||||
for (int x = middle - 1; x <= middle + 1; x++) {
|
||||
box(x, -step + 1, step + 2, x, 2, step + 2, AIR);
|
||||
set(x, -step, step + 2, stairs(Direction.NORTH));
|
||||
}
|
||||
}
|
||||
// Unfinished footings and construction supplies remain outside the stair route.
|
||||
box(4, -4, depth - 6, 5, -3, depth - 5, SMOOTH);
|
||||
box(width - 6, -4, depth - 6, width - 5, -2, depth - 5, brick(11, 0, 11));
|
||||
set(1, 1, depth - 3, Blocks.BARREL);
|
||||
set(1, 1, depth - 4, Blocks.CRAFTING_TABLE);
|
||||
}
|
||||
|
||||
private void pool() {
|
||||
if (parcel.foundationDepth() < 8) { park(); return; }
|
||||
foundation();
|
||||
box(0, 1, 0, width - 1, 7, depth - 1, AIR);
|
||||
// Continuous floor and walls seal three layers of water. Weathering is above the waterline.
|
||||
box(3, -4, 4, width - 4, 0, depth - 4, SMOOTH);
|
||||
box(4, -3, 5, width - 5, -1, depth - 5, Blocks.WATER.defaultBlockState());
|
||||
for (int z = 5; z < depth - 4; z++) {
|
||||
for (int x = 4; x < width - 4; x++) {
|
||||
set(x, 0, z, AIR);
|
||||
if (roll(x, 0, z, 13) == 0) set(x, -4, z, Blocks.MOSS_BLOCK);
|
||||
}
|
||||
}
|
||||
// Broad submerged steps connect the deck to the bottom without ladders or a jump trap.
|
||||
for (int step = 1; step <= 3; step++) {
|
||||
for (int x = middle - 1; x <= middle + 1; x++) {
|
||||
set(x, -step, step + 4, stairs(Direction.NORTH)
|
||||
.setValue(BlockStateProperties.WATERLOGGED, true));
|
||||
for (int y = -step + 1; y <= 0; y++) set(x, y, step + 4, AIR);
|
||||
}
|
||||
}
|
||||
for (int z = 5; z < depth - 5; z += 4) {
|
||||
set(1, 1, z, facing(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
|
||||
set(1, 1, z + 1, Blocks.DARK_OAK_SLAB);
|
||||
set(width - 2, 1, z, facing(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
|
||||
}
|
||||
set(width - 3, 1, 2, Blocks.CAULDRON);
|
||||
set(width - 4, 1, 2, Blocks.BARREL);
|
||||
}
|
||||
|
||||
private void tree(int x, int z) {
|
||||
box(x, 1, z, x, 5, z, Blocks.OAK_LOG.defaultBlockState());
|
||||
for (int y = 4; y <= 7; y++) {
|
||||
int radius = y == 7 ? 1 : 2;
|
||||
for (int dz = -radius; dz <= radius; dz++) {
|
||||
for (int dx = -radius; dx <= radius; dx++) {
|
||||
if (dx == 0 && dz == 0 && y <= 5) continue;
|
||||
if (Math.abs(dx) + Math.abs(dz) > radius + 1) continue;
|
||||
set(x + dx, y, z + dz, Blocks.OAK_LEAVES.defaultBlockState()
|
||||
.setValue(BlockStateProperties.PERSISTENT, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void plaza(boolean market) {
|
||||
foundation();
|
||||
box(0, 1, 0, width - 1, 7, depth - 1, AIR);
|
||||
for (int z = 0; z < depth; z++) {
|
||||
for (int x = 0; x < width; x++) set(x, 0, z,
|
||||
x % 4 == 0 || z % 4 == 0 ? SMOOTH : brick(x, 0, z));
|
||||
}
|
||||
if (market) {
|
||||
stall(2, 2);
|
||||
stall(width - 6, 2);
|
||||
stall(2, depth - 6);
|
||||
stall(width - 6, depth - 6);
|
||||
loot(width - 4, 1, depth - 4, BuiltInLootTables.VILLAGE_PLAINS_HOUSE);
|
||||
} else {
|
||||
// A dry fountain avoids adding uncontrolled water to the hydrology.
|
||||
for (int z = depth / 2 - 2; z <= depth / 2 + 2; z++) {
|
||||
for (int x = middle - 2; x <= middle + 2; x++) {
|
||||
set(x, 1, z, Math.abs(x - middle) == 2 || Math.abs(z - depth / 2) == 2 ? SMOOTH : AIR);
|
||||
}
|
||||
}
|
||||
set(middle, 1, depth / 2, Blocks.CHISELED_STONE_BRICKS);
|
||||
set(middle, 2, depth / 2, Blocks.STONE_BRICK_WALL);
|
||||
for (int x = 3; x <= 5; x++) {
|
||||
set(x, 1, 3, facing(Blocks.DARK_OAK_STAIRS, Direction.SOUTH));
|
||||
set(width - 1 - x, 1, depth - 4, facing(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void stall(int x, int z) {
|
||||
box(x, 1, z + 2, x + 3, 1, z + 2, WOOD);
|
||||
for (int dx : new int[] { 0, 3 }) {
|
||||
box(x + dx, 1, z, x + dx, 3, z, Blocks.DARK_OAK_FENCE.defaultBlockState());
|
||||
}
|
||||
for (int dx = 0; dx <= 3; dx++) {
|
||||
for (int dz = 0; dz <= 2; dz++) {
|
||||
if (roll(x + dx, 4, z + dz, 8) != 0) set(x + dx, 4, z + dz,
|
||||
(dx & 1) == 0 ? Blocks.WOOL.lightGray() : Blocks.WOOL.green());
|
||||
}
|
||||
}
|
||||
set(x + 1, 1, z, Blocks.BARREL);
|
||||
set(x + 2, 2, z + 2, Blocks.FLOWER_POT);
|
||||
}
|
||||
|
||||
private void parking() {
|
||||
foundation();
|
||||
// Depth >=8 is the planner's certificate of continuous rock under the whole garage.
|
||||
boolean underground = parcel.foundationDepth() >= 8;
|
||||
int base = underground ? -5 : 0;
|
||||
box(0, base + 1, 0, width - 1, 6, depth - 1, AIR);
|
||||
box(0, base, 0, width - 1, base, depth - 1, SMOOTH);
|
||||
box(0, base + 5, 0, width - 1, base + 5, depth - 1, SMOOTH);
|
||||
for (int z = 0; z < depth; z++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
if (x == 0 || x == width - 1 || z == depth - 1) {
|
||||
for (int y = base + 1; y < base + 5; y++) {
|
||||
if (underground || y == base + 1 || x % 4 == 0 || z % 4 == 0) set(x, y, z, brick(x, y, z));
|
||||
}
|
||||
}
|
||||
if (Math.abs(x - middle) <= 2) set(x, base, z, STONE);
|
||||
else if (z % 4 == 0) set(x, base, z, Blocks.CALCITE);
|
||||
}
|
||||
}
|
||||
for (int x : new int[] { 3, width - 4 }) {
|
||||
for (int z : new int[] { 3, depth - 4 }) box(x, base + 1, z, x, base + 4, z, STONE);
|
||||
}
|
||||
box(0, base + 1, 0, middle - 3, base + 4, 0, SMOOTH);
|
||||
box(middle + 3, base + 1, 0, width - 1, base + 4, 0, SMOOTH);
|
||||
if (underground) {
|
||||
for (int z = 0; z <= 5; z++) {
|
||||
int y = -z;
|
||||
box(middle - 2, y + 1, z, middle + 2, 3, z, AIR);
|
||||
for (int x = middle - 2; x <= middle + 2; x++) {
|
||||
set(x, y, z, z == 0 ? SMOOTH : stairs(Direction.NORTH));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
box(middle - 2, 1, 0, middle + 2, 3, 1, AIR);
|
||||
}
|
||||
set(width - 3, base + 1, depth - 3, Blocks.BARREL);
|
||||
loot(width - 3, base + 1, depth - 2, BuiltInLootTables.VILLAGE_MASON);
|
||||
}
|
||||
}
|
||||
|
||||
private static BlockState facing(Block block, Direction direction) {
|
||||
return block.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, direction);
|
||||
}
|
||||
|
||||
private static BlockState stairs(Direction direction) {
|
||||
return facing(Blocks.STONE_BRICK_STAIRS, direction);
|
||||
}
|
||||
|
||||
private static BlockState panes(boolean eastWest) {
|
||||
return Blocks.GLASS_PANE.defaultBlockState()
|
||||
.setValue(BlockStateProperties.EAST, eastWest)
|
||||
.setValue(BlockStateProperties.WEST, eastWest)
|
||||
.setValue(BlockStateProperties.NORTH, !eastWest)
|
||||
.setValue(BlockStateProperties.SOUTH, !eastWest);
|
||||
}
|
||||
|
||||
private static BlockState bars(boolean eastWest) {
|
||||
return Blocks.IRON_BARS.defaultBlockState()
|
||||
.setValue(BlockStateProperties.EAST, eastWest)
|
||||
.setValue(BlockStateProperties.WEST, eastWest)
|
||||
.setValue(BlockStateProperties.NORTH, !eastWest)
|
||||
.setValue(BlockStateProperties.SOUTH, !eastWest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package fr.koka.sanctuary.worldgen.city;
|
||||
|
||||
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;
|
||||
|
||||
/** A small native structure start per district, bounded by vanilla's eight-chunk reference reach. */
|
||||
public final class LostCityStructure extends Structure {
|
||||
public static final MapCodec<LostCityStructure> CODEC = simpleCodec(LostCityStructure::new);
|
||||
|
||||
public LostCityStructure(StructureSettings settings) { super(settings); }
|
||||
|
||||
@Override protected Optional<GenerationStub> findGenerationPoint(GenerationContext context) {
|
||||
if (!(context.chunkGenerator() instanceof SanctuaryChunkGenerator generator)
|
||||
|| generator.rootGeneration() != 14) return Optional.empty();
|
||||
var island = generator.islandForChunk(context.chunkPos().x(), context.chunkPos().z());
|
||||
if (island == null || island.generation() != 14) return Optional.empty();
|
||||
for (var district : generator.lostCityPlan(island).districts()) {
|
||||
if (district.startChunkX() != context.chunkPos().x() || district.startChunkZ() != context.chunkPos().z()) continue;
|
||||
if (district.parcels().isEmpty()) return Optional.empty();
|
||||
for (var parcel : district.parcels()) {
|
||||
// Never save a start that vanilla cannot reference in every touched chunk.
|
||||
if (Math.abs((parcel.minX() >> 4) - district.startChunkX()) > 8
|
||||
|| Math.abs((parcel.maxX() >> 4) - district.startChunkX()) > 8
|
||||
|| Math.abs((parcel.minZ() >> 4) - district.startChunkZ()) > 8
|
||||
|| Math.abs((parcel.maxZ() >> 4) - district.startChunkZ()) > 8)
|
||||
throw new IllegalStateException("Lost City district exceeds native structure reference range");
|
||||
}
|
||||
return Optional.of(new GenerationStub(LostCityStructures.locator(district), pieces ->
|
||||
district.parcels().forEach(parcel -> pieces.addPiece(new LostCityPiece(parcel)))));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override public StructureType<?> type() { return LostCityStructures.TYPE; }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package fr.koka.sanctuary.worldgen.city;
|
||||
|
||||
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 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;
|
||||
|
||||
/** Native starts, pieces, references and deferred loot; only the placement plan is Sanctuary-owned. */
|
||||
public final class LostCityStructures {
|
||||
public static final ResourceKey<Structure> KEY = ResourceKey.create(Registries.STRUCTURE, SanctuaryMod.id("lost_city"));
|
||||
public static final StructureType<LostCityStructure> TYPE = Registry.register(
|
||||
BuiltInRegistries.STRUCTURE_TYPE, SanctuaryMod.id("lost_city"), () -> LostCityStructure.CODEC);
|
||||
public static final StructurePieceType PIECE = Registry.register(
|
||||
BuiltInRegistries.STRUCTURE_PIECE, SanctuaryMod.id("lost_city_parcel"), LostCityPiece::new);
|
||||
private static final ThreadLocal<Integer> PLACEMENT_DEPTH = ThreadLocal.withInitial(() -> 0);
|
||||
|
||||
private LostCityStructures() {}
|
||||
|
||||
public static void register() { /* Load static serializers before dynamic registries. */ }
|
||||
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> level) {
|
||||
if (!manager.shouldGenerateStructures() || !(generator instanceof SanctuaryChunkGenerator sanctuary)
|
||||
|| generator.rootGeneration() != 14) return;
|
||||
var island = generator.islandForChunk(chunk.getPos().x(), chunk.getPos().z());
|
||||
if (island == null || island.generation() != 14) return;
|
||||
var plan = sanctuary.lostCityPlan(island);
|
||||
if (plan.districts().stream().noneMatch(d -> d.startChunkX() == chunk.getPos().x()
|
||||
&& d.startChunkZ() == chunk.getPos().z())) return;
|
||||
Holder<Structure> 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());
|
||||
StructureStart 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);
|
||||
}
|
||||
|
||||
/** Search only activated, ready islands. Native starts are loaded at STRUCTURE_STARTS, never regenerated. */
|
||||
public static Pair<BlockPos, Holder<Structure>> findNearest(ServerLevel level, HolderSet<Structure> structures,
|
||||
BlockPos origin, int radius, boolean skipReferenced) {
|
||||
if (!(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator)
|
||||
|| generator.rootGeneration() != 14 || !level.structureManager().shouldGenerateStructures()) return null;
|
||||
Holder<Structure> holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(KEY);
|
||||
if (!structures.contains(holder)) return null;
|
||||
var candidates = new ArrayList<LostCityPlan.District>();
|
||||
int originChunkX = origin.getX() >> 4, originChunkZ = origin.getZ() >> 4;
|
||||
for (var island : generator.source().islands()) {
|
||||
if (island.generation() != 14 || !island.status().equals("ready")) continue;
|
||||
for (var district : generator.lostCityPlan(island).districts()) {
|
||||
if (Math.abs((long) district.startChunkX() - originChunkX) <= radius
|
||||
&& Math.abs((long) district.startChunkZ() - originChunkZ) <= radius) candidates.add(district);
|
||||
}
|
||||
}
|
||||
candidates.sort(Comparator.comparingDouble(d -> origin.distSqr(locator(d))));
|
||||
for (var district : candidates) {
|
||||
var chunk = level.getChunk(district.startChunkX(), district.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);
|
||||
return Pair.of(start.getPieces().getFirst().getLocatorPosition(), holder);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static BlockPos locator(LostCityPlan.District district) {
|
||||
var parcel = district.parcels().getFirst();
|
||||
int x = parcel.width() / 2, z = parcel.depth() / 2;
|
||||
return new BlockPos(parcel.minX() + x, parcel.surfaceY(x, z) + 1, parcel.minZ() + z);
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@
|
||||
"sanctuary.expansion.error.journal_unavailable": "The expansion journal is unavailable. Preparation has stopped; fix storage and reload the world before resuming.",
|
||||
"sanctuary.expansion.error.invalid_id": "An identifier must start with a lowercase letter and contain at most 32 lowercase letters, digits or underscores.",
|
||||
"sanctuary.expansion.error.invalid_climate": "Unknown climate. Use autocomplete to choose a profile.",
|
||||
"sanctuary.expansion.error.invalid_diameter": "Allowed diameters are 64, 128, 256 and 512 blocks.",
|
||||
"sanctuary.expansion.error.invalid_diameter": "Supported diameters are 64, 128, 256, 512 and 1024 blocks (1024 requires generation 14).",
|
||||
"sanctuary.expansion.error.invalid_relief": "Unknown relief: natural, peaks, canyon or volcano.",
|
||||
"sanctuary.expansion.error.invalid_distance": "Distance must be between 0 and 100000 blocks. 0 uses automatic placement.",
|
||||
"sanctuary.expansion.error.no_free_space": "No free location found within the search limits. Try a greater distance or a different parent. No existing terrain was changed.",
|
||||
@@ -117,5 +117,12 @@
|
||||
"sanctuary.world_options.size.small": "Small",
|
||||
"sanctuary.world_options.size.medium": "Medium",
|
||||
"sanctuary.world_options.size.large": "Large",
|
||||
"sanctuary.world_options.diameter": "Nominal diameter: %s blocks. The terrain and seed shape the outline."
|
||||
"sanctuary.world_options.diameter": "Nominal diameter: %s blocks. The terrain and seed shape the outline.",
|
||||
"biome.sanctuary.lost_city": "Lost City",
|
||||
"structure.sanctuary.lost_city": "Ruined district",
|
||||
"sanctuary.expansion.climate.lost_city": "Lost City",
|
||||
"sanctuary.expansion.error.city_generation_required": "This profile or size requires a new Sanctuary alpha.14 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."
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
"sanctuary.expansion.error.journal_unavailable": "Le journal d’expansion est indisponible. La préparation est arrêtée ; corrigez le stockage puis rechargez le monde avant de reprendre.",
|
||||
"sanctuary.expansion.error.invalid_id": "L’identifiant doit commencer par une lettre minuscule et contenir au plus 32 lettres minuscules, chiffres ou tirets bas.",
|
||||
"sanctuary.expansion.error.invalid_climate": "Climat inconnu. Utiliser l’autocomplétion pour choisir un profil.",
|
||||
"sanctuary.expansion.error.invalid_diameter": "Les diamètres autorisés sont 64, 128, 256 et 512 blocs.",
|
||||
"sanctuary.expansion.error.invalid_diameter": "Les diamètres autorisés sont 64, 128, 256, 512 et 1024 blocs (1024 à partir de la génération 14).",
|
||||
"sanctuary.expansion.error.invalid_relief": "Relief inconnu : natural, peaks, canyon ou volcano.",
|
||||
"sanctuary.expansion.error.invalid_distance": "La distance doit être comprise entre 0 et 100000 blocs. 0 utilise le placement automatique.",
|
||||
"sanctuary.expansion.error.no_free_space": "Aucun emplacement libre trouvé dans les limites de recherche. Réessayer avec une distance plus grande ou un autre parent. Aucun terrain existant n’a été modifié.",
|
||||
@@ -117,5 +117,12 @@
|
||||
"sanctuary.world_options.size.small": "Petit",
|
||||
"sanctuary.world_options.size.medium": "Moyen",
|
||||
"sanctuary.world_options.size.large": "Grand",
|
||||
"sanctuary.world_options.diameter": "Diamètre nominal : %s blocs. Le relief et la graine dessinent le contour."
|
||||
"sanctuary.world_options.diameter": "Diamètre nominal : %s blocs. Le relief et la graine dessinent le contour.",
|
||||
"biome.sanctuary.lost_city": "Lost City",
|
||||
"structure.sanctuary.lost_city": "Quartier en ruine",
|
||||
"sanctuary.expansion.climate.lost_city": "Lost City",
|
||||
"sanctuary.expansion.error.city_generation_required": "Ce profil ou cette taille demande un nouveau monde Sanctuary alpha.14. 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."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"attributes": {
|
||||
"minecraft:audio/background_music": {
|
||||
"default": {
|
||||
"max_delay": 24000,
|
||||
"min_delay": 12000,
|
||||
"sound": "minecraft:music.overworld.meadow"
|
||||
}
|
||||
},
|
||||
"minecraft:gameplay/natural_mob_spawns": {
|
||||
"argument": {
|
||||
"spawn_costs": {},
|
||||
"spawns_by_category": {
|
||||
"monster": [
|
||||
{
|
||||
"type": "minecraft:zombie",
|
||||
"weight": 100,
|
||||
"count": {
|
||||
"type": "minecraft:uniform",
|
||||
"min_inclusive": 2,
|
||||
"max_inclusive": 4
|
||||
}
|
||||
}
|
||||
],
|
||||
"ambient": [
|
||||
{
|
||||
"type": "minecraft:bat",
|
||||
"weight": 10,
|
||||
"count": 4
|
||||
}
|
||||
],
|
||||
"creature": [],
|
||||
"axolotls": [],
|
||||
"misc": [],
|
||||
"water_ambient": [],
|
||||
"water_creature": [],
|
||||
"underground_water_creature": []
|
||||
}
|
||||
},
|
||||
"modifier": "overlay"
|
||||
},
|
||||
"minecraft:visual/sky_color": "#79a6ff"
|
||||
},
|
||||
"carvers": [],
|
||||
"downfall": 0.75,
|
||||
"effects": {
|
||||
"water_color": "#3f76e4",
|
||||
"grass_color": "#81936b",
|
||||
"foliage_color": "#768663",
|
||||
"dry_foliage_color": "#b9a766"
|
||||
},
|
||||
"features": [
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
"sanctuary:rift_ore_coal",
|
||||
"sanctuary:rift_ore_iron",
|
||||
"sanctuary:rift_ore_copper",
|
||||
"sanctuary:rift_ore_gold",
|
||||
"sanctuary:rift_ore_redstone",
|
||||
"sanctuary:rift_ore_lapis",
|
||||
"sanctuary:rift_ore_diamond",
|
||||
"sanctuary:rift_ore_emerald",
|
||||
"sanctuary:rift_ore_coal_surface",
|
||||
"sanctuary:rift_ore_iron_surface",
|
||||
"sanctuary:rift_ore_copper_surface",
|
||||
"sanctuary:rift_ore_coal_ledges",
|
||||
"sanctuary:rift_ore_iron_ledges",
|
||||
"sanctuary:rift_ore_copper_ledges"
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
"minecraft:freeze_top_layer"
|
||||
]
|
||||
],
|
||||
"has_precipitation": true,
|
||||
"temperature": 0.7
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"type": "sanctuary:lost_city",
|
||||
"biomes": "sanctuary:lost_city",
|
||||
"step": "surface_structures",
|
||||
"terrain_adaptation": "none",
|
||||
"spawn_overrides": {
|
||||
"monster": {
|
||||
"bounding_box": "piece",
|
||||
"spawns": [
|
||||
{
|
||||
"type": "minecraft:zombie",
|
||||
"weight": 100,
|
||||
"count": {
|
||||
"type": "minecraft:uniform",
|
||||
"min_inclusive": 2,
|
||||
"max_inclusive": 4
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -3,7 +3,7 @@
|
||||
"minecraft:overworld": {
|
||||
"type": "sanctuary:sanctuary_384",
|
||||
"generator": {
|
||||
"type": "sanctuary:island_v13",
|
||||
"type": "sanctuary:island_v14",
|
||||
"biome_source": {
|
||||
"biomes": [
|
||||
"minecraft:the_void",
|
||||
@@ -49,7 +49,8 @@
|
||||
"sanctuary:population_bamboo_grove",
|
||||
"sanctuary:population_sulfur_depths",
|
||||
"sanctuary:population_lush_caves",
|
||||
"sanctuary:population_dripstone_caves"
|
||||
"sanctuary:population_dripstone_caves",
|
||||
"sanctuary:lost_city"
|
||||
]
|
||||
},
|
||||
"initial_diameter": 724
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"dimensions": {
|
||||
"minecraft:overworld": {
|
||||
"type": "sanctuary:sanctuary_384",
|
||||
"generator": {
|
||||
"type": "sanctuary:island_v13",
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,15 @@
|
||||
"WoodlandSpringOutletMixin",
|
||||
"RiftSpringOutletMixin",
|
||||
"CavernSpringOutletMixin",
|
||||
"UnifiedDecorationProtectionMixin"
|
||||
"UnifiedDecorationProtectionMixin",
|
||||
"ExpansionVoidServerLevelAccess",
|
||||
"ExpansionVoidEntityManagerAccess",
|
||||
"ExpansionVoidEntityStorageAccess",
|
||||
"ExpansionVoidPoiStorageAccess",
|
||||
"ExpansionVoidTicksAccess",
|
||||
"ExpansionReuseChunkMarkerMixin",
|
||||
"ExpansionReusePromotionMixin",
|
||||
"ExpansionReuseSerializationMixin"
|
||||
],
|
||||
"client": [
|
||||
"client.SanctuarySkyMixin",
|
||||
|
||||
+124
-1
@@ -2,12 +2,14 @@ package fr.koka.sanctuary.expansion;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** Persistence failures must never turn saved continents into a new empty history. No game world is opened. */
|
||||
public final class ExpansionJournalSmoke {
|
||||
@@ -70,6 +72,52 @@ public final class ExpansionJournalSmoke {
|
||||
require(Arrays.equals(unifiedMediumBytes, Files.readAllBytes(unifiedMediumDirectory.resolve(ExpansionJournal.FILE_NAME))),
|
||||
"Rejected cross-generation read changed its journal");
|
||||
|
||||
Path cityDirectory = scratch.resolve("city-generation");
|
||||
var cityRoot = ExpansionIsland.origin(724, SEED, 14);
|
||||
var city = new ExpansionJournal(cityDirectory, SEED, 724, 14,
|
||||
ExpansionJournal.directorySyncFor("\\", ignored -> { throw new AssertionError("Windows must not open the directory"); }));
|
||||
var district = ExpansionPlacement.candidates(SEED, List.of(cityRoot), "city", "sanctuary", "east", "lost_city", 256, "natural", 0).getFirst();
|
||||
require(district.generation() == 14, "New city expansion retains generation 14");
|
||||
city.commit(List.of(cityRoot, district));
|
||||
require(new ExpansionJournal(cityDirectory, SEED, 724, 14).islands().equals(List.of(cityRoot, district)),
|
||||
"Generation 14 root and reserved city survive journal reload");
|
||||
byte[] cityBytes = Files.readAllBytes(cityDirectory.resolve(ExpansionJournal.FILE_NAME));
|
||||
reject(() -> new ExpansionJournal(cityDirectory, SEED, 724, 13), "Reading a city journal as alpha.13");
|
||||
reject(() -> new ExpansionJournal(unifiedMediumDirectory, SEED, 724, 14), "Reading alpha.13 as a city journal");
|
||||
require(Arrays.equals(cityBytes, Files.readAllBytes(cityDirectory.resolve(ExpansionJournal.FILE_NAME)))
|
||||
&& Arrays.equals(unifiedMediumBytes, Files.readAllBytes(unifiedMediumDirectory.resolve(ExpansionJournal.FILE_NAME))),
|
||||
"Cross-generation journal reads preserve both histories");
|
||||
reject(() -> city.commit(List.of(cityRoot, district, north)), "Legacy expansion in generation 14 history");
|
||||
var reuseDirectory = scratch.resolve("city-reuse");
|
||||
var reuseJournal = new ExpansionJournal(reuseDirectory, SEED, 724, 14);
|
||||
var certified = district.withReuseToken("a".repeat(64));
|
||||
reuseJournal.commit(List.of(cityRoot, certified));
|
||||
require(new ExpansionJournal(reuseDirectory, SEED, 724, 14).islands().get(1).equals(certified),
|
||||
"Void certificate binding survives reservation reload");
|
||||
byte[] reuseBytes = Files.readAllBytes(reuseDirectory.resolve(ExpansionJournal.FILE_NAME));
|
||||
reject(() -> reuseJournal.commit(List.of(cityRoot, district)), "Removing a persisted certificate token");
|
||||
reject(() -> reuseJournal.commit(List.of(cityRoot, district.withReuseToken("b".repeat(64)))), "Replacing a persisted certificate token");
|
||||
require(Arrays.equals(reuseBytes, Files.readAllBytes(reuseDirectory.resolve(ExpansionJournal.FILE_NAME))),
|
||||
"Rejected certificate changes preserve journal bytes");
|
||||
reuseJournal.commit(List.of(cityRoot, certified.ready()));
|
||||
require(new ExpansionJournal(reuseDirectory, SEED, 724, 14).islands().get(1).equals(certified.ready()),
|
||||
"Completed island retains its immutable certificate binding");
|
||||
for (var unsupported : List.of("../certificate", "A".repeat(64), "a".repeat(63))) {
|
||||
try { district.withReuseToken(unsupported); throw new AssertionError("Invalid token was accepted"); }
|
||||
catch (IllegalArgumentException expected) { }
|
||||
}
|
||||
for (var unsupported : List.of(cityRoot, north)) {
|
||||
try { unsupported.withReuseToken("a".repeat(64)); throw new AssertionError("Root or legacy reuse was accepted"); }
|
||||
catch (IllegalArgumentException expected) { }
|
||||
}
|
||||
require(!Files.readString(unifiedState).contains("reuse_token"), "Legacy journal encoding stays unchanged");
|
||||
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");
|
||||
} catch (IllegalArgumentException expected) {
|
||||
require(expected.getMessage().equals("city_generation_required"), "Explain the legacy world contract");
|
||||
}
|
||||
|
||||
Path missing = scratch.resolve("missing"); Files.createDirectory(missing);
|
||||
reject(() -> new ExpansionJournal(missing, SEED, 512), "Existing directory with no state");
|
||||
Files.writeString(missing.resolve(".expansions-interrupted.tmp"), "partial");
|
||||
@@ -120,10 +168,14 @@ public final class ExpansionJournalSmoke {
|
||||
afterCrash.commit(List.of(root, north.ready(), onward));
|
||||
require(new ExpansionJournal(resumeDirectory, SEED, 512).islands().size() == 3, "Resumed region lost its descendant");
|
||||
|
||||
windowsDirectoryPolicy(scratch.resolve("windows-policy"));
|
||||
directoryFailureAfterReplacement(scratch.resolve("directory-failure"));
|
||||
readbackFailureAfterReplacement(scratch.resolve("readback-failure"));
|
||||
|
||||
var tooMany = new ArrayList<ExpansionIsland>(); tooMany.add(root);
|
||||
for (int i = 0; i < 64; i++) tooMany.add(island("overflow_" + i, "sanctuary", "north", 0, -(i + 1) * 1024));
|
||||
reject(() -> afterCrash.commit(tooMany), "Region count limit");
|
||||
System.out.println("Expansion journal smoke passed: atomic persistence, restart, immutable history, corruption, interrupted reservation and unified root identity");
|
||||
System.out.println("Expansion journal smoke passed: atomic persistence, restart, immutable history, corruption, interrupted reservation, unified root identity, Windows directory policy and post-replacement failures");
|
||||
} finally {
|
||||
try (var paths = Files.walk(scratch)) {
|
||||
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
|
||||
@@ -131,6 +183,77 @@ public final class ExpansionJournalSmoke {
|
||||
}
|
||||
}
|
||||
|
||||
private static void windowsDirectoryPolicy(Path directory) throws Exception {
|
||||
var directoryAttempts = new AtomicInteger();
|
||||
var windowsSync = ExpansionJournal.directorySyncFor("\\", path -> {
|
||||
directoryAttempts.incrementAndGet();
|
||||
throw new AccessDeniedException(path.toString());
|
||||
});
|
||||
var root = ExpansionIsland.origin(724, SEED, 13);
|
||||
var north = island("windows_north", "sanctuary", "north", 0, -1024);
|
||||
var journal = new ExpansionJournal(directory, SEED, 724, 13, windowsSync);
|
||||
require(journal.healthy() && journal.islands().equals(List.of(root)),
|
||||
"Windows policy must bootstrap a verified journal without opening the directory as a file");
|
||||
journal.commit(List.of(root, north));
|
||||
var reserved = new ExpansionJournal(directory, SEED, 724, 13, windowsSync);
|
||||
require(reserved.healthy() && reserved.islands().equals(List.of(root, north)),
|
||||
"Windows policy must persist and reload the exact reservation");
|
||||
reserved.commit(List.of(root, north.ready()));
|
||||
var ready = new ExpansionJournal(directory, SEED, 724, 13, windowsSync);
|
||||
require(ready.healthy() && ready.islands().equals(List.of(root, north.ready())),
|
||||
"Windows policy must complete and reload the same reservation");
|
||||
require(directoryAttempts.get() == 0,
|
||||
"Windows policy must never attempt the unsupported directory channel, including bootstrap and completion");
|
||||
}
|
||||
|
||||
private static void directoryFailureAfterReplacement(Path directory) throws Exception {
|
||||
var root = ExpansionIsland.origin(724, SEED, 13);
|
||||
var north = island("retained_north", "sanctuary", "north", 0, -1024);
|
||||
new ExpansionJournal(directory, SEED, 724, 13);
|
||||
Path state = directory.resolve(ExpansionJournal.FILE_NAME);
|
||||
var attempts = new AtomicInteger();
|
||||
var denied = new AccessDeniedException(directory.toString());
|
||||
var requiredSync = ExpansionJournal.directorySyncFor("/", path -> {
|
||||
attempts.incrementAndGet();
|
||||
throw denied;
|
||||
});
|
||||
var failed = new ExpansionJournal(directory, SEED, 724, 13, requiredSync);
|
||||
try {
|
||||
failed.commit(List.of(root, north));
|
||||
throw new AssertionError("A directory sync failure on a supported filesystem was ignored");
|
||||
} catch (AccessDeniedException actual) {
|
||||
require(actual == denied, "The original directory sync error must reach the caller unchanged");
|
||||
}
|
||||
require(attempts.get() == 1, "A supported filesystem must attempt directory synchronization");
|
||||
require(!failed.healthy() && failed.islands().equals(List.of(root)),
|
||||
"Uncertain persistence must fail closed without publishing the new reservation to world generation");
|
||||
byte[] reservedBytes = Files.readAllBytes(state);
|
||||
var recovered = new ExpansionJournal(directory, SEED, 724, 13);
|
||||
require(recovered.healthy() && recovered.islands().equals(List.of(root, north)),
|
||||
"A verified reload must recover the reservation already installed before directory sync failed");
|
||||
reject(() -> failed.commit(List.of(root, north)), "Retrying a failed journal without reload");
|
||||
require(attempts.get() == 1 && Arrays.equals(reservedBytes, Files.readAllBytes(state)),
|
||||
"A failed session must neither retry storage operations nor overwrite its retained reservation");
|
||||
recovered.commit(List.of(root, north.ready()));
|
||||
require(new ExpansionJournal(directory, SEED, 724, 13).islands().equals(List.of(root, north.ready())),
|
||||
"A recovered reservation must complete normally without changing its identity");
|
||||
}
|
||||
|
||||
private static void readbackFailureAfterReplacement(Path directory) throws Exception {
|
||||
var root = ExpansionIsland.origin(724, SEED, 13);
|
||||
var north = island("readback_north", "sanctuary", "north", 0, -1024);
|
||||
new ExpansionJournal(directory, SEED, 724, 13);
|
||||
Path state = directory.resolve(ExpansionJournal.FILE_NAME);
|
||||
var journal = new ExpansionJournal(directory, SEED, 724, 13,
|
||||
path -> Files.writeString(state, "damaged after atomic replacement", StandardCharsets.UTF_8));
|
||||
reject(() -> journal.commit(List.of(root, north)), "Corruption detected by post-replacement verification");
|
||||
require(!journal.healthy() && journal.islands().equals(List.of(root)),
|
||||
"Successful replacement alone must not publish an unverified reservation");
|
||||
require(Files.readString(state).equals("damaged after atomic replacement"),
|
||||
"A failed readback must preserve evidence instead of silently recreating the journal");
|
||||
reject(() -> new ExpansionJournal(directory, SEED, 724, 13), "Reloading a journal damaged after replacement");
|
||||
}
|
||||
|
||||
private static ExpansionIsland island(String id, String parent, String direction, int x, int z) {
|
||||
return new ExpansionIsland(id, parent, direction, ExpansionIsland.directionalClimate(direction), 64, x, z,
|
||||
ExpansionIsland.deriveSeed(SEED, id), "natural", "reserved", ExpansionIsland.GENERATION);
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ public final class ExpansionPlacementSmoke {
|
||||
require(first.equals(candidates(saved, "new_island", root.id(), direction, 64, 1).getFirst()),
|
||||
"A short requested distance must still respect the existing reservation and generation halo");
|
||||
|
||||
for (int diameter : ExpansionIsland.DIAMETERS) {
|
||||
for (int diameter : ExpansionIsland.LEGACY_DIAMETERS) {
|
||||
var distant = candidates(saved, "new_island", root.id(), direction, diameter, 2048);
|
||||
verifyCandidates(distant, saved, root, direction, diameter, 2048);
|
||||
require(distance(root, distant.getFirst()) < 2048 + Math.sqrt(2) * 16,
|
||||
|
||||
@@ -17,7 +17,7 @@ public final class ExpansionReliefSmoke {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int diameter : ExpansionIsland.DIAMETERS) for (String relief : ExpansionIsland.RELIEFS) {
|
||||
for (int diameter : ExpansionIsland.LEGACY_DIAMETERS) for (String relief : ExpansionIsland.RELIEFS) {
|
||||
var island = island(seed, diameter, relief, 1024, -2048);
|
||||
require(ExpansionRelief.verticalScale(island) >= 0.4 && ExpansionRelief.verticalScale(island) <= 1.0,
|
||||
"Small islands keep enough vertical space for two levels");
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
public final class ExpansionSpatialPlacement14Smoke {
|
||||
public static void main(String[] args) throws Exception {
|
||||
ExpansionIsland origin = ExpansionIsland.origin(724, 42, 14);
|
||||
List<ExpansionIsland> saved = new ArrayList<>(List.of(origin));
|
||||
for (String direction : ExpansionIsland.DIRECTIONS) {
|
||||
String id = "satellite_" + direction;
|
||||
var sample = new ExpansionIsland(id, origin.id(), direction, "temperate", 64,
|
||||
0, 0, ExpansionIsland.deriveSeed(42, id), "natural", "reserved", 14);
|
||||
var first = ExpansionSpatialPlacement14.candidates(saved, origin, sample, 0);
|
||||
var again = ExpansionSpatialPlacement14.candidates(saved, origin, sample, 0);
|
||||
require(!first.isEmpty(), "No nearby satellite in " + direction);
|
||||
require(first.equals(again), "Candidate draw is not deterministic");
|
||||
require(first.size() <= 32, "Unbounded draw");
|
||||
var chosen = first.getFirst();
|
||||
double distance = ExpansionPlacement.distanceFromParent(chosen, origin);
|
||||
require(distance - origin.terrainRadius() - chosen.terrainRadius() < 210,
|
||||
"Small satellite too far from Sanctuary's edge");
|
||||
for (var candidate : first) {
|
||||
require((candidate.centerX() & 15) == 0 && (candidate.centerZ() & 15) == 0, "Not chunk aligned");
|
||||
require(ExpansionSpatialPlacement14.followsDirection(direction,
|
||||
candidate.centerX() - origin.centerX(), candidate.centerZ() - origin.centerZ()), "Wrong compass sector");
|
||||
for (var previous : saved) require(!ExpansionSpatialPlacement14.conflicts(candidate, previous), "Poisson separation violated");
|
||||
}
|
||||
saved.add(chosen.ready());
|
||||
}
|
||||
for (int a = 0; a < saved.size(); a++) for (int b = a + 1; b < saved.size(); b++) {
|
||||
var first = saved.get(a); var second = saved.get(b);
|
||||
require(!ExpansionSpatialPlacement14.conflicts(first, second), "Accepted island conflict");
|
||||
// The diagonal chunk guard prevents two islands from claiming one corner chunk.
|
||||
int minX = Math.floorDiv(second.centerX() - ExpansionSpatialPlacement14.writeRadius(second), 16);
|
||||
int maxX = Math.floorDiv(second.centerX() + ExpansionSpatialPlacement14.writeRadius(second), 16);
|
||||
int minZ = Math.floorDiv(second.centerZ() - ExpansionSpatialPlacement14.writeRadius(second), 16);
|
||||
int maxZ = Math.floorDiv(second.centerZ() + ExpansionSpatialPlacement14.writeRadius(second), 16);
|
||||
for (int z = minZ; z <= maxZ; z++) for (int x = minX; x <= maxX; x++)
|
||||
require(!(first.ownsChunk(x, z) && second.ownsChunk(x, z)), "Two islands own the same chunk");
|
||||
}
|
||||
var large = new ExpansionIsland("continent", "sanctuary", "north", "lost_city", 512,
|
||||
0, 0, ExpansionIsland.deriveSeed(42, "continent"), "natural", "reserved", 14);
|
||||
var far = ExpansionSpatialPlacement14.candidates(saved, origin, large, 4096);
|
||||
require(!far.isEmpty(), "No distant continent candidate");
|
||||
require(far.stream().allMatch(i -> ExpansionPlacement.distanceFromParent(i, origin) >= 4096), "Explicit distance ignored");
|
||||
require(ExpansionSpatialPlacement14.minimumSeparation(origin, large)
|
||||
> ExpansionSpatialPlacement14.minimumSeparation(origin, saved.get(1)), "Size does not affect distance");
|
||||
require(ExpansionSpatialPlacement14.readRadius(large) - ExpansionSpatialPlacement14.writeRadius(large) == 176,
|
||||
"Native generation dependency halo lost");
|
||||
require(!ExpansionSpatialPlacement14.followsDirection("north", 100, 0), "East accepted as north");
|
||||
require(!ExpansionSpatialPlacement14.followsDirection("north", 0, 0), "Zero displacement accepted");
|
||||
require(!ExpansionSpatialPlacement14.followsDirection("north", 100, -10), "Outside sector accepted");
|
||||
require(ExpansionSpatialPlacement14.followsDirection("northeast", 512, -400), "Diagonal sector rejected");
|
||||
largeContinentsAndStableRetries(origin);
|
||||
weightedQuickSizes();
|
||||
journalGeometry();
|
||||
System.out.println("ExpansionSpatialPlacement14Smoke passed: bounded Poisson draws, eight sectors, nearby satellites, 1024-block continents, weighted stable quick draws, restart-safe sector/disc journal geometry and no shared chunks");
|
||||
}
|
||||
|
||||
private static void largeContinentsAndStableRetries(ExpansionIsland origin) {
|
||||
double previousDistance = 0;
|
||||
for (int diameter : ExpansionIsland.DIAMETERS) {
|
||||
String id = "same_draw";
|
||||
var candidates = ExpansionPlacement.candidates(42, List.of(origin), id, origin.id(),
|
||||
"north", "temperate", diameter, "natural", 0);
|
||||
require(!candidates.isEmpty(), "Missing generation-14 size " + diameter);
|
||||
var selected = candidates.getFirst();
|
||||
require(selected.generation() == 14 && selected.diameter() == diameter, "Placement changed the requested size");
|
||||
double distance = ExpansionPlacement.distanceFromParent(selected, origin);
|
||||
require(distance > previousDistance, "Larger size was placed closer for an identical random draw");
|
||||
previousDistance = distance;
|
||||
var obstacle = new ExpansionIsland("obstacle", origin.id(), selected.direction(), selected.climate(),
|
||||
selected.diameter(), selected.centerX(), selected.centerZ(), ExpansionIsland.deriveSeed(42, "obstacle"),
|
||||
selected.relief(), "ready", 14);
|
||||
var alternatives = ExpansionPlacement.candidates(42, List.of(origin, obstacle), id, origin.id(),
|
||||
"north", "temperate", diameter, "natural", 0);
|
||||
require(alternatives.stream().allMatch(c -> candidates.contains(c) && c.diameter() == diameter),
|
||||
"Occupancy retry rerolls geometry or size instead of rejecting occupied draws");
|
||||
require(!alternatives.contains(selected), "Occupied first candidate survives retry");
|
||||
require(candidates.equals(ExpansionPlacement.candidates(42, List.of(origin), id, origin.id(),
|
||||
"north", "temperate", diameter, "natural", 0)), "Retry perturbs a later identical request");
|
||||
}
|
||||
try {
|
||||
ExpansionPlacement.candidates(42, List.of(ExpansionIsland.origin(724, 42, 13)), "large", "sanctuary",
|
||||
"north", "temperate", 1024, "natural", 0);
|
||||
throw new AssertionError("1024-block expansion silently enters an alpha.13 world");
|
||||
} catch (IllegalArgumentException expected) {
|
||||
require(expected.getMessage().equals("city_generation_required"), "Wrong legacy-size rejection");
|
||||
}
|
||||
}
|
||||
|
||||
private static void weightedQuickSizes() {
|
||||
int[] counts = new int[ExpansionIsland.DIAMETERS.size()];
|
||||
int changedBySeed = 0;
|
||||
for (int i = 0; i < 10_000; i++) {
|
||||
long seed = i / 100;
|
||||
String id = "island_" + i % 100;
|
||||
int diameter = ExpansionRandomSize.choose(seed, id);
|
||||
require(diameter == ExpansionRandomSize.choose(seed, id), "Repeated quick request changes its chosen size");
|
||||
int index = ExpansionIsland.DIAMETERS.indexOf(diameter);
|
||||
require(index >= 0, "Quick returned an unsupported size");
|
||||
counts[index]++;
|
||||
if (diameter != ExpansionRandomSize.choose(seed + 1, id)) changedBySeed++;
|
||||
}
|
||||
require(Arrays.stream(counts).allMatch(count -> count > 0), "Quick never selects one of its five sizes");
|
||||
require(counts[0] > counts[1] && counts[1] > counts[2] && counts[2] > counts[3] && counts[3] > counts[4],
|
||||
"Quick no longer favors progressively smaller islands: " + Arrays.toString(counts));
|
||||
require(counts[0] + counts[1] >= 6_000 && counts[0] + counts[1] <= 8_000,
|
||||
"Quick should usually select the two small island sizes");
|
||||
require(changedBySeed > 5_000, "World seed barely affects quick size selection");
|
||||
}
|
||||
|
||||
private static void journalGeometry() throws Exception {
|
||||
Path scratch = Files.createTempDirectory("sanctuary-spatial14-journal-");
|
||||
try {
|
||||
var root = ExpansionIsland.origin(724, 42, 14);
|
||||
var first = record("first", "north", 0, -640, 64, 14);
|
||||
// Physical bounding squares overlap, but the discs plus corner safety remain separate.
|
||||
var diagonal = record("diagonal", "north", 128, -752, 64, 14);
|
||||
require(Math.abs(first.centerX() - diagonal.centerX()) < first.reservedRadius() + diagonal.reservedRadius()
|
||||
&& Math.abs(first.centerZ() - diagonal.centerZ()) < first.reservedRadius() + diagonal.reservedRadius(),
|
||||
"Fixture no longer exercises overlapping bounding squares");
|
||||
require(!first.conflicts(diagonal), "Separated generation-14 discs still collide as squares");
|
||||
var journal = new ExpansionJournal(scratch.resolve("new"), 42, 724, 14);
|
||||
journal.commit(List.of(root, first, diagonal));
|
||||
journal.commit(List.of(root, first.ready(), diagonal.ready()));
|
||||
require(new ExpansionJournal(scratch.resolve("new"), 42, 724, 14).islands()
|
||||
.equals(List.of(root, first.ready(), diagonal.ready())), "Sector/disc geometry does not survive journal reload");
|
||||
byte[] before = Files.readAllBytes(scratch.resolve("new").resolve(ExpansionJournal.FILE_NAME));
|
||||
reject(() -> journal.commit(List.of(root, first.ready(), diagonal.ready(),
|
||||
record("overlap", "north", 128, -752, 64, 14))), "Overlapping disc accepted");
|
||||
reject(() -> journal.commit(List.of(root, first.ready(), diagonal.ready(),
|
||||
record("wrong_sector", "east", 0, -4096, 64, 14))), "Wrong sector accepted");
|
||||
require(Arrays.equals(before, Files.readAllBytes(scratch.resolve("new").resolve(ExpansionJournal.FILE_NAME))),
|
||||
"Rejected geometry changed a valid journal");
|
||||
var legacyRoot = ExpansionIsland.origin(724, 42, 13);
|
||||
var legacy = new ExpansionJournal(scratch.resolve("legacy"), 42, 724, 13);
|
||||
reject(() -> legacy.commit(List.of(legacyRoot, record("off_axis", "north", 128, -4096, 64, 12))),
|
||||
"Legacy exact-ray rule changed");
|
||||
} finally {
|
||||
try (var paths = Files.walk(scratch)) {
|
||||
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ExpansionIsland record(String id, String direction, int x, int z, int diameter, int generation) {
|
||||
return new ExpansionIsland(id, "sanctuary", direction, "temperate", diameter, x, z,
|
||||
ExpansionIsland.deriveSeed(42, id), "natural", "reserved", generation);
|
||||
}
|
||||
@FunctionalInterface private interface Checked { void run() throws IOException; }
|
||||
private static void reject(Checked action, String message) throws IOException {
|
||||
try { action.run(); throw new AssertionError(message); } catch (IOException expected) { }
|
||||
}
|
||||
private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); }
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.nbt.StringTag;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
|
||||
/** Native-shaped 26.3 NBT fixtures: unknown, missing and malformed data never become an empty proof. */
|
||||
public final class ExpansionVoidCertificateSmoke {
|
||||
private static final int DATA_VERSION = 5018;
|
||||
private static final ChunkPos POS = new ChunkPos(18, -7);
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
CompoundTag original = terrain();
|
||||
var proof = certify(original);
|
||||
require(!proof.absent() && proof.terrain().matches("[a-f0-9]{64}"), "FULL void must have a terrain fingerprint");
|
||||
CompoundTag dispatch = original.copy();
|
||||
CompoundTag airDispatch = new CompoundTag(); airDispatch.putString("id", "minecraft:air");
|
||||
ListTag dispatchPalette = new ListTag(); dispatchPalette.add(airDispatch); blocks(dispatch).put("palette", dispatchPalette);
|
||||
require(proof.equals(certify(dispatch)), "Native constant-string and full id-dispatch air representations must agree");
|
||||
CompoundTag volatileFields = original.copy();
|
||||
volatileFields.putLong("LastUpdate", 1_000_000); volatileFields.putLong("InhabitedTime", 20_000);
|
||||
volatileFields.putBoolean("isLightOn", false);
|
||||
section(volatileFields).putByteArray("SkyLight", new byte[2048]);
|
||||
CompoundTag halo = new CompoundTag(); halo.putByte("Y", (byte) 24); halo.putByteArray("SkyLight", new byte[2048]);
|
||||
volatileFields.getList("sections").orElseThrow().add(halo);
|
||||
require(proof.equals(certify(volatileFields)), "Valid counters and light caches must not invalidate semantic emptiness");
|
||||
require(proof.equals(ExpansionVoidCertificate.certify(POS, original, Optional.of(entities()), Optional.of(poi()), DATA_VERSION)),
|
||||
"Absent and explicitly empty independent stores must agree semantically");
|
||||
var absent = ExpansionVoidCertificate.absent(POS, Optional.empty(), Optional.empty(), DATA_VERSION);
|
||||
require(absent.absent() && absent.terrain().equals("absent") && !absent.equals(proof),
|
||||
"An absent chunk must remain distinct from an existing FULL chunk");
|
||||
|
||||
for (String key : Set.of("DataVersion", "xPos", "yPos", "zPos", "Status", "LastUpdate", "InhabitedTime",
|
||||
"sections", "block_entities", "block_ticks", "fluid_ticks", "PostProcessing", "Heightmaps", "structures")) {
|
||||
reject(tag -> tag.remove(key), "missing mandatory FULL field " + key);
|
||||
}
|
||||
reject(tag -> tag.put("LastUpdate", new CompoundTag()), "foreign payload hidden in LastUpdate");
|
||||
reject(tag -> tag.putString("InhabitedTime", "0"), "wrong inhabited-time type");
|
||||
reject(tag -> tag.putLong("InhabitedTime", -1), "negative inhabited-time counter");
|
||||
reject(tag -> tag.putLong("LastUpdate", -1), "negative last-update counter");
|
||||
reject(tag -> tag.putLong("DataVersion", DATA_VERSION), "numeric coercion of DataVersion");
|
||||
reject(tag -> tag.putDouble("xPos", POS.x()), "numeric coercion of position");
|
||||
reject(tag -> tag.putInt("xPos", POS.x() + 1), "different chunk coordinates");
|
||||
reject(tag -> tag.putInt("yPos", -4), "different vertical world range");
|
||||
reject(tag -> tag.putInt("DataVersion", DATA_VERSION - 1), "different data version");
|
||||
reject(tag -> tag.putInt("isLightOn", 1), "wrong light flag type");
|
||||
reject(tag -> tag.putByte("isLightOn", (byte) 2), "non-boolean light flag");
|
||||
reject(tag -> tag.putString("Status", "minecraft:features"), "unfinished chunk");
|
||||
reject(tag -> tag.put("foreign:attachments", new CompoundTag()), "unknown mod metadata");
|
||||
reject(tag -> tag.getCompound("structures").orElseThrow().remove("starts"), "missing structure-start record");
|
||||
reject(tag -> tag.getCompound("structures").orElseThrow().getCompound("References").orElseThrow()
|
||||
.putLongArray("sanctuary:lost_city", new long[] {1}), "structure references");
|
||||
reject(tag -> tag.getCompound("Heightmaps").orElseThrow().remove("WORLD_SURFACE"), "missing FULL heightmap");
|
||||
reject(tag -> tag.getCompound("Heightmaps").orElseThrow().putLongArray("WORLD_SURFACE", new long[36]), "wrong heightmap length");
|
||||
reject(tag -> { long[] heights = new long[37]; heights[0] = 1; tag.getCompound("Heightmaps").orElseThrow().putLongArray("WORLD_SURFACE", heights); }, "nonempty heightmap");
|
||||
reject(tag -> section(tag).putInt("Y", 0), "wrong section elevation type");
|
||||
reject(tag -> section(tag).putByte("Y", (byte) 1), "duplicate or missing section");
|
||||
reject(tag -> section(tag).putByteArray("BlockLight", new byte[2047]), "wrong light-cache size");
|
||||
reject(tag -> { CompoundTag emptyHalo = new CompoundTag(); emptyHalo.putByte("Y", (byte) 24);
|
||||
tag.getList("sections").orElseThrow().add(emptyHalo); }, "empty non-native light halo");
|
||||
reject(tag -> blocks(tag).putLongArray("data", new long[1]), "non-native single-palette data length");
|
||||
reject(tag -> blocks(tag).getList("palette").orElseThrow().set(0, StringTag.valueOf("minecraft:stone")), "solid block");
|
||||
reject(tag -> blocks(tag).getList("palette").orElseThrow().set(0, StringTag.valueOf("minecraft:water")), "fluid block");
|
||||
reject(tag -> { ListTag palette = new ListTag(); palette.add(StringTag.valueOf("minecraft:air")); palette.add(StringTag.valueOf("minecraft:stone")); blocks(tag).put("palette", palette); }, "unused nonair palette entry");
|
||||
reject(tag -> { CompoundTag old = new CompoundTag(); old.putString("Name", "minecraft:air"); ListTag palette = new ListTag(); palette.add(old); blocks(tag).put("palette", palette); }, "unsupported legacy state key");
|
||||
reject(tag -> { ListTag palette = new ListTag(); palette.add(StringTag.valueOf("minecraft:plains")); section(tag).getCompound("biomes").orElseThrow().put("palette", palette); }, "foreign biome");
|
||||
reject(tag -> tag.put("PostProcessing", new ListTag()), "missing per-section post-processing arrays");
|
||||
reject(tag -> tag.getList("block_ticks").orElseThrow().add(new CompoundTag()), "pending block tick");
|
||||
reject(tag -> tag.getList("fluid_ticks").orElseThrow().add(new CompoundTag()), "pending fluid tick");
|
||||
reject(tag -> tag.getList("block_entities").orElseThrow().add(new CompoundTag()), "saved block entity");
|
||||
|
||||
CompoundTag entity = entities();
|
||||
entity.getList("Entities").orElseThrow().add(new CompoundTag());
|
||||
fails(() -> ExpansionVoidCertificate.certify(POS, original, Optional.of(entity), Optional.empty(), DATA_VERSION), "saved entity");
|
||||
fails(() -> ExpansionVoidCertificate.absent(POS, Optional.of(entity), Optional.empty(), DATA_VERSION), "entity sidecar of absent terrain");
|
||||
CompoundTag wrongEntity = entities(); wrongEntity.putIntArray("Position", new int[] {POS.x(), POS.z() + 1});
|
||||
fails(() -> ExpansionVoidCertificate.entityFingerprint(POS, Optional.of(wrongEntity), DATA_VERSION), "misplaced entity sidecar");
|
||||
CompoundTag interest = poi();
|
||||
interest.getCompound("Sections").orElseThrow().getCompound("0").orElseThrow().getList("Records").orElseThrow().add(new CompoundTag());
|
||||
fails(() -> ExpansionVoidCertificate.certify(POS, original, Optional.empty(), Optional.of(interest), DATA_VERSION), "saved POI");
|
||||
fails(() -> ExpansionVoidCertificate.absent(POS, Optional.empty(), Optional.of(interest), DATA_VERSION), "POI sidecar of absent terrain");
|
||||
CompoundTag malformedPoi = poi();
|
||||
malformedPoi.getCompound("Sections").orElseThrow().getCompound("0").orElseThrow().putString("Valid", "true");
|
||||
fails(() -> ExpansionVoidCertificate.poiFingerprint(POS, Optional.of(malformedPoi), DATA_VERSION), "malformed POI validity flag");
|
||||
System.out.println("ExpansionVoidCertificateSmoke: native FULL schema, malformed/missing/foreign metadata refusals, semantic volatile fields and absent-sidecar contracts passed.");
|
||||
}
|
||||
|
||||
private static CompoundTag terrain() {
|
||||
CompoundTag tag = new CompoundTag();
|
||||
tag.putInt("DataVersion", DATA_VERSION); tag.putInt("xPos", POS.x()); tag.putInt("yPos", 0); tag.putInt("zPos", POS.z());
|
||||
tag.putString("Status", "minecraft:full"); tag.putLong("LastUpdate", 0); tag.putLong("InhabitedTime", 0);
|
||||
tag.putBoolean("isLightOn", true);
|
||||
for (String key : Set.of("block_entities", "block_ticks", "fluid_ticks")) tag.put(key, new ListTag());
|
||||
ListTag sections = new ListTag(); ListTag post = new ListTag();
|
||||
for (int y = 0; y < 24; y++) {
|
||||
CompoundTag section = new CompoundTag(); section.putByte("Y", (byte) y);
|
||||
CompoundTag blocks = new CompoundTag();
|
||||
ListTag palette = new ListTag(); palette.add(StringTag.valueOf("minecraft:air")); blocks.put("palette", palette); section.put("block_states", blocks);
|
||||
CompoundTag biomes = new CompoundTag(); ListTag biomePalette = new ListTag(); biomePalette.add(StringTag.valueOf("minecraft:the_void"));
|
||||
biomes.put("palette", biomePalette); section.put("biomes", biomes); sections.add(section); post.add(new ListTag());
|
||||
}
|
||||
tag.put("sections", sections); tag.put("PostProcessing", post);
|
||||
CompoundTag heightmaps = new CompoundTag();
|
||||
for (String key : Set.of("WORLD_SURFACE", "OCEAN_FLOOR", "MOTION_BLOCKING", "MOTION_BLOCKING_NO_LEAVES")) heightmaps.putLongArray(key, new long[37]);
|
||||
tag.put("Heightmaps", heightmaps);
|
||||
CompoundTag structures = new CompoundTag(); structures.put("starts", new CompoundTag()); structures.put("References", new CompoundTag());
|
||||
tag.put("structures", structures);
|
||||
return tag;
|
||||
}
|
||||
|
||||
private static CompoundTag entities() {
|
||||
CompoundTag tag = new CompoundTag(); tag.putInt("DataVersion", DATA_VERSION);
|
||||
tag.putIntArray("Position", new int[] {POS.x(), POS.z()}); tag.put("Entities", new ListTag()); return tag;
|
||||
}
|
||||
|
||||
private static CompoundTag poi() {
|
||||
CompoundTag tag = new CompoundTag(); tag.putInt("DataVersion", DATA_VERSION);
|
||||
CompoundTag section = new CompoundTag(); section.putBoolean("Valid", true); section.put("Records", new ListTag());
|
||||
CompoundTag sections = new CompoundTag(); sections.put("0", section); tag.put("Sections", sections); return tag;
|
||||
}
|
||||
|
||||
private static CompoundTag section(CompoundTag tag) { return tag.getList("sections").orElseThrow().getCompound(0).orElseThrow(); }
|
||||
private static CompoundTag blocks(CompoundTag tag) { return section(tag).getCompound("block_states").orElseThrow(); }
|
||||
private static ExpansionVoidCertificate certify(CompoundTag tag) throws IOException {
|
||||
return ExpansionVoidCertificate.certify(POS, tag, Optional.empty(), Optional.empty(), DATA_VERSION);
|
||||
}
|
||||
private static void reject(Consumer<CompoundTag> change, String reason) {
|
||||
CompoundTag tag = terrain(); change.accept(tag); fails(() -> certify(tag), reason);
|
||||
}
|
||||
@FunctionalInterface private interface Attempt { void run() throws IOException; }
|
||||
private static void fails(Attempt attempt, String reason) {
|
||||
try { attempt.run(); throw new AssertionError("Accepted " + reason); }
|
||||
catch (IOException expected) { /* Refusal must preserve the input, never repair or replace it. */ }
|
||||
}
|
||||
private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); }
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package fr.koka.sanctuary.worldgen.city;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Kind;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Parcel;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlanner.Ground;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlanner.GroundSampler;
|
||||
import java.util.Arrays;
|
||||
import java.util.EnumSet;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** Pure geometry contracts. Engine tests separately witness real noise and saved structures. */
|
||||
public final class LostCityPlannerSmoke {
|
||||
public static void main(String[] args) {
|
||||
GroundSampler flat = (x, z) -> new Ground(220, 24, true, false);
|
||||
LostCityPlan a = LostCityPlanner.plan(42, 0, 0, 724, true, flat);
|
||||
LostCityPlan b = LostCityPlanner.plan(42, 0, 0, 724, true, flat);
|
||||
LostCityPlan c = LostCityPlanner.plan(8675309, 0, 0, 724, true, flat);
|
||||
require(!a.isEmpty(), "Flat starter fixture needs a city");
|
||||
require(fingerprint(a).equals(fingerprint(b)), "Same seed has different city geometry");
|
||||
require(!fingerprint(a).equals(fingerprint(c)), "Different seed has identical city geometry");
|
||||
var district = a.districts().getFirst();
|
||||
require((district.maxX() - district.minX() + 1L) * (district.maxZ() - district.minZ() + 1L)
|
||||
< 724L * 724L / 10, "Starter district consumes too much of the island");
|
||||
require(district.parcels().stream().filter(p -> p.kind().building()).count() >= 3, "Not enough buildings");
|
||||
require(district.parcels().stream().map(Parcel::kind).distinct().count() >= 6, "City lacks varied parcel uses");
|
||||
require(!a.contains(district.minX(), 100, district.minZ()), "City biome overwrites deep caves");
|
||||
checkGeometry(a, flat);
|
||||
arrayImmutability(a);
|
||||
int gridX = district.minX() + 2, gridZ = district.minZ() + 2;
|
||||
GroundSampler shortFissure = (x, z) -> new Ground(
|
||||
x >= gridX - 2 && x <= gridX + 2 && z >= gridZ + 8 && z <= gridZ + 13 ? 90 : 220,
|
||||
24, true, false);
|
||||
LostCityPlan bridged = LostCityPlanner.plan(42, 0, 0, 724, true, shortFissure);
|
||||
require(bridged.districts().stream().flatMap(d -> d.parcels().stream()).anyMatch(p -> p.kind() == Kind.BRIDGE),
|
||||
"Short street fissure lacks supported bridge");
|
||||
checkGeometry(bridged, shortFissure);
|
||||
allArchetypes(flat, bridged);
|
||||
|
||||
for (int diameter : new int[] {64, 128, 256, 512}) {
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
LostCityPlan plan = LostCityPlanner.plan(42, 1024, -1024, diameter, false,
|
||||
(x, z) -> { calls.incrementAndGet(); return flat.sample(x, z); });
|
||||
require(!plan.isEmpty(), "Flat expansion lacks city at diameter " + diameter);
|
||||
require(calls.get() <= 16_000, "Planner exceeds column budget");
|
||||
checkGeometry(plan, flat);
|
||||
}
|
||||
GroundSampler slope = (x, z) -> new Ground(220 + Math.floorDiv(x, 9) + Math.floorDiv(z, 13), 24, true, false);
|
||||
LostCityPlan sloping = LostCityPlanner.plan(42, 0, 0, 512, true, slope);
|
||||
require(!sloping.isEmpty(), "Gently sloping terrain cannot host a city");
|
||||
checkGeometry(sloping, slope);
|
||||
// Both grades have continuous support and the same four-block rise across a lot.
|
||||
// Choosing the level from the north entrance used to reject the uphill direction.
|
||||
GroundSampler ascending = (x, z) -> new Ground(220 + Math.floorDiv(z, 4), 24, true, false);
|
||||
GroundSampler descending = (x, z) -> new Ground(220 - Math.floorDiv(z, 4), 24, true, false);
|
||||
LostCityPlan uphill = LostCityPlanner.plan(42, 0, 0, 512, true, ascending);
|
||||
LostCityPlan downhill = LostCityPlanner.plan(42, 0, 0, 512, true, descending);
|
||||
long uphillLots = lots(uphill), downhillLots = lots(downhill);
|
||||
require(uphillLots >= 5 && uphillLots == downhillLots,
|
||||
"Safe north/south grades receive asymmetric parcel acceptance: " + uphillLots + "/" + downhillLots);
|
||||
checkGeometry(uphill, ascending); checkGeometry(downhill, descending);
|
||||
|
||||
require(LostCityPlanner.plan(42, 0, 0, 724, true, (x, z) -> new Ground(220, 24, false, false)).isEmpty(), "Wet island accepted");
|
||||
require(LostCityPlanner.plan(42, 0, 0, 724, true, (x, z) -> new Ground(220, 24, true, true)).isEmpty(), "Protected island accepted");
|
||||
require(LostCityPlanner.plan(42, 0, 0, 724, true, (x, z) -> new Ground(220, 1, true, false)).isEmpty(), "Thin shell accepted");
|
||||
require(LostCityPlanner.plan(42, 0, 0, 724, true, (x, z) -> new Ground(372, 24, true, false)).isEmpty(), "City exceeds world ceiling");
|
||||
|
||||
// A narrow fissure is discovered by full footprint validation rather than the coarse probes.
|
||||
AtomicInteger probes = new AtomicInteger();
|
||||
GroundSampler fractured = (x, z) -> {
|
||||
probes.incrementAndGet();
|
||||
return Math.floorMod(x, 17) == 6 ? new Ground(90, 24, true, false) : new Ground(220, 24, true, false);
|
||||
};
|
||||
LostCityPlan constrained = LostCityPlanner.plan(42, 0, 0, 1024, true, fractured);
|
||||
require(probes.get() <= 16_000, "Failure fallback exceeds column budget");
|
||||
checkGeometry(constrained, fractured);
|
||||
System.out.println("LostCityPlannerSmoke passed: deterministic plans, bounded city coverage, terrain foundations, connected ramps, protected water/caves and column budget");
|
||||
}
|
||||
|
||||
private static void allArchetypes(GroundSampler flat, LostCityPlan bridgeWitness) {
|
||||
EnumSet<Kind> flatKinds = EnumSet.noneOf(Kind.class), undergroundKinds = EnumSet.noneOf(Kind.class);
|
||||
EnumSet<Kind> expectedFlat = EnumSet.allOf(Kind.class); expectedFlat.remove(Kind.BRIDGE);
|
||||
EnumSet<Kind> expectedUnderground = EnumSet.of(Kind.PARKING, Kind.POOL, Kind.EXCAVATION);
|
||||
for (long seed = 0; seed < 32; seed++) {
|
||||
LostCityPlan plan = LostCityPlanner.plan(seed, 0, 0, 724, true, flat);
|
||||
require(!plan.isEmpty(), "Flat reference lacks city for seed " + seed);
|
||||
for (var district : plan.districts()) for (Parcel parcel : district.parcels()) {
|
||||
flatKinds.add(parcel.kind());
|
||||
if (expectedUnderground.contains(parcel.kind()) && parcel.foundationDepth() >= 8) {
|
||||
undergroundKinds.add(parcel.kind());
|
||||
for (int z = 0; z < parcel.depth(); z++) for (int x = 0; x < parcel.width(); x++) {
|
||||
Ground ground = flat.sample(parcel.minX() + x, parcel.minZ() + z);
|
||||
require(ground.top() >= parcel.floorY() - 1
|
||||
&& ground.top() - (parcel.floorY() - 8) + 1 <= ground.solidDepth(),
|
||||
"Selected deep archetype lacks actual continuous-rock support");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require(flatKinds.equals(expectedFlat), "Some flat-ground archetypes are unreachable: " + flatKinds);
|
||||
require(undergroundKinds.equals(expectedUnderground), "Deep parking, pool or excavation is never selected: " + undergroundKinds);
|
||||
// A bridge requires a gap, so its coverage comes from the separate real planner fixture.
|
||||
EnumSet<Kind> allKinds = EnumSet.copyOf(flatKinds);
|
||||
bridgeWitness.districts().forEach(d -> d.parcels().forEach(p -> allKinds.add(p.kind())));
|
||||
require(allKinds.equals(EnumSet.allOf(Kind.class)), "Not every declared city kind can be planned");
|
||||
}
|
||||
|
||||
private static void checkGeometry(LostCityPlan plan, GroundSampler sampler) {
|
||||
for (var district : plan.districts()) for (Parcel parcel : district.parcels()) {
|
||||
require(parcel.minY() >= 0 && parcel.maxY() <= 383, "Piece outside world height");
|
||||
require(Math.abs(Math.floorDiv(parcel.minX(), 16) - district.startChunkX()) <= 7
|
||||
&& Math.abs(Math.floorDiv(parcel.maxX(), 16) - district.startChunkX()) <= 7
|
||||
&& Math.abs(Math.floorDiv(parcel.minZ(), 16) - district.startChunkZ()) <= 7
|
||||
&& Math.abs(Math.floorDiv(parcel.maxZ(), 16) - district.startChunkZ()) <= 7, "Native reference extent exceeded");
|
||||
if (parcel.kind() == Kind.BRIDGE) checkBridge(parcel, sampler);
|
||||
for (int z = 0; z < parcel.depth(); z++) for (int x = 0; x < parcel.width(); x++) {
|
||||
int floor = parcel.surfaceY(x, z);
|
||||
Ground ground = sampler.sample(parcel.minX() + x, parcel.minZ() + z);
|
||||
require(!ground.protectedTerrain(), "Parcel crosses protected water or spring");
|
||||
if (parcel.kind() != Kind.BRIDGE) {
|
||||
require(ground.dry() && ground.solidDepth() >= 4, "Foundation on water or hollow shell");
|
||||
require(ground.top() <= floor + 2, "City terrace cuts more than two surface blocks");
|
||||
require(ground.top() >= floor - parcel.foundationDepth(), "Foundation misses natural ground");
|
||||
require(ground.top() - (floor - parcel.foundationDepth()) + 1 <= ground.solidDepth(), "Foundation intrudes into cave");
|
||||
}
|
||||
if (x > 0) require(Math.abs(floor - parcel.surfaceY(x - 1, z)) <= 1, "Street height jumps by more than a block");
|
||||
if (z > 0) require(Math.abs(floor - parcel.surfaceY(x, z - 1)) <= 1, "Street height jumps by more than a block");
|
||||
if (parcel.kind() == Kind.POOL || parcel.kind() == Kind.EXCAVATION
|
||||
|| parcel.kind() == Kind.PARKING && parcel.foundationDepth() >= 8) {
|
||||
require(parcel.foundationDepth() >= 8 && ground.top() >= floor - 1
|
||||
&& ground.top() - (floor - 8) + 1 <= ground.solidDepth(), "Excavation/pool/parking lacks rock certificate");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long lots(LostCityPlan plan) {
|
||||
return plan.districts().stream().flatMap(d -> d.parcels().stream())
|
||||
.filter(p -> p.kind() != Kind.STREET && p.kind() != Kind.BRIDGE).count();
|
||||
}
|
||||
|
||||
private static void checkBridge(Parcel parcel, GroundSampler sampler) {
|
||||
require(parcel.foundationDepth() == 0, "Bridge fills the ravine underneath");
|
||||
boolean alongX = parcel.width() > parcel.depth();
|
||||
int length = alongX ? parcel.width() : parcel.depth(), gapRun = 0;
|
||||
for (int step = 0; step < length; step++) {
|
||||
boolean unsupported = false;
|
||||
for (int across = 0; across < 5; across++) {
|
||||
int x = alongX ? step : across, z = alongX ? across : step;
|
||||
Ground ground = sampler.sample(parcel.minX() + x, parcel.minZ() + z);
|
||||
if (!ground.dry() || ground.solidDepth() < 4 || ground.top() < parcel.surfaceY(x, z) - 10) unsupported = true;
|
||||
}
|
||||
if (unsupported) {
|
||||
require(step >= 2 && step <= length - 3, "Bridge has no solid abutment");
|
||||
require(++gapRun <= 8, "Bridge unsupported run too long");
|
||||
} else gapRun = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static void arrayImmutability(LostCityPlan plan) {
|
||||
Parcel road = plan.districts().getFirst().parcels().stream().filter(p -> p.surfaceHeights().length > 0).findFirst().orElseThrow();
|
||||
int original = road.surfaceY(0, 0);
|
||||
int[] heights = road.surfaceHeights(); heights[0] = 17;
|
||||
require(road.surfaceY(0, 0) == original, "Caller can mutate cached city road");
|
||||
int[] source = {220};
|
||||
Parcel test = new Parcel(Kind.STREET, 0, 0, 1, 1, 220, 0, 0, 42, 2, source);
|
||||
source[0] = 180;
|
||||
require(test.surfaceY(0, 0) == 220, "Caller retains mutable road input");
|
||||
}
|
||||
|
||||
private static String fingerprint(LostCityPlan plan) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (var district : plan.districts()) {
|
||||
result.append(district.startChunkX()).append(':').append(district.startChunkZ()).append(':').append(district.seed());
|
||||
for (Parcel p : district.parcels()) result.append('|').append(p.kind()).append(':').append(p.minX()).append(':').append(p.minZ())
|
||||
.append(':').append(p.width()).append(':').append(p.depth()).append(':').append(p.floorY()).append(':').append(p.floors())
|
||||
.append(':').append(p.facing()).append(':').append(p.seed()).append(':').append(p.foundationDepth()).append(':').append(Arrays.toString(p.surfaceHeights()));
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); }
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
package fr.koka.sanctuary.worldgen.city;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Kind;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlan.Parcel;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
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.Blocks;
|
||||
import net.minecraft.world.level.block.StairBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.BedPart;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
|
||||
/** Real block-state geometry, without creating a world: clipping, rotations and walkable floors. */
|
||||
public final class LostCityRendererSmoke {
|
||||
public static void main(String[] args) {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
run();
|
||||
System.out.println("LostCityRendererSmoke: bounded deterministic masonry, doors, stairs, bedrooms, basements, sealed pools, excavation access, park variants and suspended bridges passed.");
|
||||
}
|
||||
|
||||
/** Also callable from an already bootstrapped native GameTest. */
|
||||
public static void run() {
|
||||
for (Kind kind : Kind.values()) {
|
||||
for (int facing = 0; facing < 4; facing++) {
|
||||
for (long seed : new long[] {0, 42, -61872}) {
|
||||
int floors = kind == Kind.HALL ? 2 : 3;
|
||||
int foundation = kind == Kind.POOL || kind == Kind.EXCAVATION ? 8 : 4;
|
||||
Parcel parcel = new Parcel(kind, 0, 0, 17, 17, 170, floors, facing, seed, foundation, new int[0]);
|
||||
Capture result = render(parcel);
|
||||
require(result.blocks.equals(render(parcel).blocks), "Renderer changed for identical saved parcel");
|
||||
require(result.blocks.values().stream().noneMatch(state -> state.is(Blocks.SPAWNER)), "City introduced a spawner");
|
||||
if (kind.building()) {
|
||||
require(result.blocks.values().stream().anyMatch(state -> state.is(Blocks.STONE)), "Missing explicit stone palette");
|
||||
require(result.blocks.values().stream().anyMatch(state -> state.is(Blocks.SMOOTH_STONE)), "Missing smooth stone palette");
|
||||
Set<BlockPos> reached = walk(result, rotate(8, 1, 0, facing));
|
||||
for (int floor = 1; floor <= floors; floor++) {
|
||||
int elevation = floor * 5 + 1;
|
||||
require(reached.stream().anyMatch(pos -> pos.getY() == elevation),
|
||||
kind + " facing " + facing + " cannot reach floor/roof " + floor);
|
||||
}
|
||||
require(result.loot.size() == 1, kind + " must keep exactly one deferred, limited loot chest");
|
||||
}
|
||||
for (var entry : result.blocks.entrySet()) {
|
||||
BlockState state = entry.getValue();
|
||||
if (state.is(Blocks.BED.white()) && state.getValue(BlockStateProperties.BED_PART) == BedPart.FOOT) {
|
||||
Direction direction = state.getValue(BlockStateProperties.HORIZONTAL_FACING);
|
||||
BlockState head = result.block(entry.getKey().relative(direction));
|
||||
require(head.is(Blocks.BED.white()) && head.getValue(BlockStateProperties.BED_PART) == BedPart.HEAD,
|
||||
"Rotated bedroom has an incomplete bed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int facing = 0; facing < 4; facing++) {
|
||||
Parcel garage = new Parcel(Kind.PARKING, 0, 0, 17, 17, 170, 1, facing, 9, 8, new int[0]);
|
||||
Capture result = render(garage);
|
||||
Set<BlockPos> reached = walk(result, rotate(8, 1, 0, facing));
|
||||
require(reached.contains(rotate(8, -4, 10, facing)), "Underground garage is not reachable from its street ramp");
|
||||
require(result.block(rotate(8, -6, 10, facing)).is(Blocks.STONE), "Garage excavation removed the certified rock below its floor");
|
||||
Parcel pit = new Parcel(Kind.EXCAVATION, 0, 0, 17, 17, 170, 0, facing, 9, 8, new int[0]);
|
||||
Capture excavation = render(pit);
|
||||
require(walk(excavation, rotate(8, 1, 0, facing)).contains(rotate(8, -4, 10, facing)),
|
||||
"Excavation has no walkable stairs from the street");
|
||||
require(excavation.block(rotate(8, -6, 10, facing)).is(Blocks.STONE), "Excavation removed its certified bottom support");
|
||||
Parcel pool = new Parcel(Kind.POOL, 0, 0, 17, 17, 170, 0, facing, 9, 8, new int[0]);
|
||||
Capture water = render(pool);
|
||||
sealedPool(water, facing);
|
||||
require(walk(water, rotate(8, 1, 0, facing)).contains(rotate(8, -2, 7, facing)),
|
||||
"Pool submerged steps cannot be reached from the deck");
|
||||
}
|
||||
Set<Map<BlockPos, BlockState>> parks = new HashSet<>();
|
||||
for (Kind park : new Kind[] {Kind.PARK, Kind.PARK_GARDEN, Kind.PARK_PLAYGROUND, Kind.PARK_MEMORIAL}) {
|
||||
parks.add(render(new Parcel(park, 0, 0, 17, 17, 170, 0, 0, 42, 4, new int[0])).blocks);
|
||||
}
|
||||
require(parks.size() == 4, "Park variants must have distinct geometry");
|
||||
for (Kind kind : new Kind[] {Kind.POOL, Kind.EXCAVATION}) {
|
||||
Capture fallback = render(new Parcel(kind, 0, 0, 17, 17, 170, 0, 0, 42, 4, new int[0]));
|
||||
require(fallback.blocks.values().stream().noneMatch(state -> !state.getFluidState().isEmpty()),
|
||||
"Uncertified ground produced a wet excavation");
|
||||
require(fallback.block(new BlockPos(8, -1, 8)).is(Blocks.STONE), "Uncertified ground was excavated");
|
||||
}
|
||||
int[] heights = new int[17 * 5];
|
||||
for (int z = 0; z < 17; z++) for (int x = 0; x < 5; x++) heights[z * 5 + x] = 170 + z / 4;
|
||||
Parcel bridge = new Parcel(Kind.BRIDGE, 0, 0, 5, 17, 170, 0, 0, 33, 0, heights);
|
||||
Capture result = render(bridge);
|
||||
require(result.blocks.entrySet().stream().noneMatch(entry -> !entry.getValue().isAir()
|
||||
&& entry.getKey().getY() < bridge.surfaceY(entry.getKey().getX(), entry.getKey().getZ()) - bridge.floorY()),
|
||||
"Bridge grew a column below its suspended deck");
|
||||
Set<BlockPos> reached = walk(result, new BlockPos(2, 1, 0));
|
||||
require(reached.contains(new BlockPos(2, 5, 16)), "Stepped bridge cannot be crossed");
|
||||
}
|
||||
|
||||
private static Capture render(Parcel parcel) {
|
||||
Capture result = new Capture(parcel);
|
||||
LostCityRenderer.render(parcel, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Flood the cells a source can reach horizontally/downward, without permitting water to rise. */
|
||||
private static void sealedPool(Capture result, int facing) {
|
||||
Set<BlockPos> flooded = new HashSet<>();
|
||||
ArrayDeque<BlockPos> queue = new ArrayDeque<>();
|
||||
result.blocks.forEach((pos, state) -> {
|
||||
if (!state.getFluidState().isEmpty()) { flooded.add(pos); queue.add(pos); }
|
||||
});
|
||||
require(flooded.size() > 150, "Pool must contain a real basin of water");
|
||||
while (!queue.isEmpty()) {
|
||||
BlockPos pos = queue.removeFirst();
|
||||
require(pos.getY() >= -3 && pos.getY() <= -1, "Pool leaks vertically through its floor or coping");
|
||||
BlockPos canonical = rotate(pos.getX(), pos.getY(), pos.getZ(), (4 - facing) % 4);
|
||||
require(canonical.getX() >= 4 && canonical.getX() <= 12 && canonical.getZ() >= 5 && canonical.getZ() <= 12,
|
||||
"Pool leaks outside its continuous side walls");
|
||||
for (Direction direction : new Direction[] {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST, Direction.DOWN}) {
|
||||
BlockPos next = pos.relative(direction);
|
||||
BlockState state = result.block(next);
|
||||
if ((state.isAir() || !state.getFluidState().isEmpty()) && flooded.add(next)) queue.addLast(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Conservative full-block walking with one-block steps and two blocks of clear headroom. */
|
||||
private static Set<BlockPos> walk(Capture result, BlockPos start) {
|
||||
require(standable(result, start), "Entrance is blocked: " + start);
|
||||
Set<BlockPos> seen = new HashSet<>();
|
||||
ArrayDeque<BlockPos> queue = new ArrayDeque<>();
|
||||
queue.add(start);
|
||||
seen.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
BlockPos current = queue.removeFirst();
|
||||
for (Direction direction : new Direction[] {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST}) {
|
||||
for (int dy = -1; dy <= 1; dy++) {
|
||||
BlockPos next = current.relative(direction).offset(0, dy, 0);
|
||||
if (standable(result, next) && seen.add(next)) queue.addLast(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
private static boolean standable(Capture result, BlockPos pos) {
|
||||
if (pos.getX() < 0 || pos.getZ() < 0 || pos.getX() >= result.parcel.width() || pos.getZ() >= result.parcel.depth()) return false;
|
||||
BlockState support = result.block(pos.below());
|
||||
return (support.isSolid() || support.getBlock() instanceof StairBlock)
|
||||
&& clear(result.block(pos)) && clear(result.block(pos.above()));
|
||||
}
|
||||
|
||||
private static boolean clear(BlockState state) { return state.isAir() || state.is(Blocks.WATER); }
|
||||
|
||||
private static BlockPos rotate(int x, int y, int z, int facing) {
|
||||
return switch (facing) {
|
||||
case 0 -> new BlockPos(x, y, z);
|
||||
case 1 -> new BlockPos(16 - z, y, x);
|
||||
case 2 -> new BlockPos(16 - x, y, 16 - z);
|
||||
default -> new BlockPos(z, y, 16 - x);
|
||||
};
|
||||
}
|
||||
|
||||
private static final class Capture implements LostCityRenderer.BlockWriter {
|
||||
private final Parcel parcel;
|
||||
private final Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
private final Map<BlockPos, ResourceKey<LootTable>> loot = new HashMap<>();
|
||||
Capture(Parcel parcel) { this.parcel = parcel; }
|
||||
|
||||
@Override public void set(int x, int y, int z, BlockState state) {
|
||||
require(x >= 0 && z >= 0 && x < parcel.width() && z < parcel.depth()
|
||||
&& y >= parcel.minY() - parcel.floorY() && y <= parcel.maxY() - parcel.floorY(),
|
||||
parcel.kind() + " emitted a block outside its saved bounds at " + x + "," + y + "," + z);
|
||||
blocks.put(new BlockPos(x, y, z), state);
|
||||
}
|
||||
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long lootSeed) {
|
||||
set(x, y, z, Blocks.CHEST.defaultBlockState());
|
||||
loot.put(new BlockPos(x, y, z), table);
|
||||
}
|
||||
|
||||
BlockState block(BlockPos pos) { return blocks.getOrDefault(pos, Blocks.AIR.defaultBlockState()); }
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "0.1.0-alpha.13"
|
||||
version = "0.1.0-alpha.14"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
Reference in New Issue
Block a user