Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a633d4cac9 | ||
|
|
d6e0c144d0 | ||
|
|
d462f01e2b | ||
|
|
bb63fab388 | ||
|
|
ae4c6200db | ||
|
|
0edac29dd8 | ||
|
|
2facf6f722 | ||
|
|
c23e72b8e6 | ||
|
|
f2da922bad | ||
|
|
c049e0f2ce | ||
|
|
0969ed52da | ||
|
|
1a64fbe5b9 | ||
|
|
98fc13cf0a | ||
|
|
71d5cf7906 | ||
|
|
dd9c795695 | ||
|
|
c576d655ae | ||
|
|
1c5c2c2c7e | ||
|
|
3a6a895874 | ||
|
|
b19e93dd21 | ||
|
|
3569cf1f5f | ||
|
|
78b63be8e1 | ||
|
|
818c6753f9 | ||
|
|
332d60be96 |
+208
-1
@@ -1,6 +1,213 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0-alpha.12 — 2026-09-09
|
||||
## 0.1.0-alpha.20 — 2026-09-10
|
||||
|
||||
- Passe architecturale sur les six bâtiments urbains, les quatorze anciennes
|
||||
infrastructures, les espaces publics et les réseaux souterrains. Palettes
|
||||
propres aux usages : chêne, bouleau, granit, diorite, andésite et tuf ;
|
||||
fondations maçonnées en briques de pierre conservées.
|
||||
- Angles coupés, baies en saillie, portiques, retraits, loggias, toitures
|
||||
nervurées et verrières avec passage lumineux. Sols et plafonds détaillés,
|
||||
corniches, encadrements, murets et blocs ciselés ; bordures de routes en
|
||||
briques de tuf et accents ciselés.
|
||||
- Vitres, barreaux, clôtures, murs bas et angles d’escaliers raccordés depuis
|
||||
la géométrie complète de chaque pièce avant son découpage entre chunks.
|
||||
- Nouveau codec `sanctuary:island_v20`, journal et pièces distincts. Les mondes
|
||||
19 et antérieurs gardent leurs rendus et futurs chunks. Les plans de terrain,
|
||||
d’hydrologie et d’implantation restent ceux de la génération précédente.
|
||||
Personnaliser conserve les trois tailles et les structures expérimentales.
|
||||
|
||||
`check build assemblePack` passe, avec huit GameTests généraux. Création et
|
||||
réouverture de la ville et des infrastructures passent ; les rapports 19 restent
|
||||
identiques aux témoins publiés. Option désactivée et Petit/graine42 passent.
|
||||
JAR, MRpack et ZIP Prism vérifiés ; release et canal packwiz publiés. Deux
|
||||
synchronisations isolées puis deux dans la même instance Prism passent, avec
|
||||
925 fichiers personnels et réglages suivis conservés.
|
||||
|
||||
Voir le [catalogue illustré](docs/architecture-alpha20.md) et les
|
||||
[vérifications et limites](docs/testing-alpha20.md). Les fonctions de la bêta
|
||||
restent à construire ; aucun nouveau système économique n’est activé.
|
||||
|
||||
## 0.1.0-alpha.19 — 2026-09-09
|
||||
|
||||
- Quatorze nouvelles infrastructures abandonnées sur l’île initiale, avec pièces,
|
||||
accès, mobilier, secrets et outils vanilla associés. La présence varie selon
|
||||
la graine et le relief ; les quatorze types ont été rencontrés en tests natifs.
|
||||
- Voies de fret aux rails incomplets et chemins entre destinations réelles.
|
||||
Le témoin Moyen, graine 0, contient treize lieux et sept liaisons, dont un
|
||||
fret de 499 blocs entre dépôt et pompage. Tracés enregistrés par chunk.
|
||||
- Option FR/EN « Structures expérimentales » dans le bouton natif Personnaliser,
|
||||
trois tailles conservées. Codec `sanctuary:island_v19` et journal 19 ; les
|
||||
anciens mondes gardent leurs plans et futurs chunks. Un nouveau monde avec
|
||||
l’option désactivée conserve le socle urbain sans les expériences 17/19.
|
||||
- Commande opérateur `/sanctuary structures` pour relever les entrées.
|
||||
La ville et ses sous-sols sont conservés ; les systèmes de la bêta restent
|
||||
à construire.
|
||||
|
||||
`check build assemblePack` passe, dont les huit GameTests généraux. Les trois
|
||||
formats sont vérifiés nativement ; le témoin moyen se rouvre avec ses 112 pièces,
|
||||
13 coffres, 68 cibles accessibles et la modification témoin. Les identités 17
|
||||
restent identiques à la release publiée. JAR, MRpack et ZIP Prism vérifiés ;
|
||||
release et canal packwiz publiés. Deux synchronisations isolées puis deux dans
|
||||
la même instance Prism passent, avec 767 fichiers personnels et réglages suivis
|
||||
conservés. Voir [les preuves et limites](docs/testing-alpha19.md).
|
||||
|
||||
## 0.1.0-alpha.17 — 2026-09-09
|
||||
|
||||
- Prototype de trois réseaux oubliés sur l’île initiale : mines et ateliers,
|
||||
salles étranges, infrastructures abandonnées. Accès depuis la surface,
|
||||
couloirs et embranchements, outils vanilla utilisables, mobilier et traces de
|
||||
chantier. Placement borné, hors ville et eaux protégées, avec appuis vérifiés.
|
||||
- Option FR/EN « Structures expérimentales » dans Personnaliser, enregistrée
|
||||
avec la taille du nouveau monde. Les connexions entre réseaux, le réacteur,
|
||||
la progression et les reliques ne sont pas encore implémentés.
|
||||
- Codec `sanctuary:island_v17`, pièces natives indépendantes, journal 17.
|
||||
Les mondes 16 et antérieurs conservent leurs plans et futurs chunks.
|
||||
L’option ne supprime ni n’ajoute rétroactivement des structures.
|
||||
|
||||
`check build assemblePack` passe. Création et réouverture natives : 15 pièces,
|
||||
7 639 blocs contrôlés, 49 cibles accessibles, 12 appuis et 3 coffres conservés.
|
||||
Le cas désactivé et la sauvegarde des trois tailles passent. La génération 16
|
||||
conserve les empreintes natives et le journal des témoins publiés. Artefacts
|
||||
vérifiés, release et canal packwiz publiés. Même instance Prism synchronisée
|
||||
deux fois, 767 fichiers personnels et réglages suivis conservés. Voir
|
||||
[le protocole et les coordonnées](docs/testing-alpha17.md).
|
||||
|
||||
## 0.1.0-alpha.16 — 2026-09-09
|
||||
|
||||
- Ville compacte obligatoire sur la nouvelle île initiale, structures activées.
|
||||
Recherche naturelle puis réservation architecturale locale si nécessaire ;
|
||||
les villes des continents restent conditionnelles.
|
||||
- Grande piscine couverte avec bassin de 50 × 25 × 4 blocs, promenades et
|
||||
marches. Parkings profonds de 3 à 8 plateformes, grande salle sèche et égouts
|
||||
ouverts vers l’extérieur avec écoulements natifs. Ces équipements restent
|
||||
conditionnés par le relief, leurs fondations et un budget de recherche borné.
|
||||
- Plans d’étages alternés, sanitaires clos et lits contre les murs, avec
|
||||
circulation, orientations et appuis du mobilier contrôlés.
|
||||
- Nouveau codec `sanctuary:island_v16`, journal16 et pièces natives propres.
|
||||
Les générations publiées conservent leurs calculs, rendus et futurs chunks.
|
||||
Un nouveau monde est nécessaire pour les nouveautés ; aucune conversion.
|
||||
|
||||
`check build assemblePack` passe, ainsi que création/réouverture : 71 pièces,
|
||||
5 coffres et 74 appuis conservés. Piscine retenue et deux écoulements extérieurs
|
||||
vérifiés après 120 ticks réels. Six reliefs contrôlés, avec piscines dans trois
|
||||
cas et villes dans les six. Compatibilité 15 vérifiée contre ses empreintes
|
||||
publiées. Release et canal packwiz publiés ; même instance Prism synchronisée
|
||||
deux fois, avec les 767 fichiers personnels et réglages suivis conservés. Voir
|
||||
[les preuves et limites alpha.16](docs/testing-alpha16.md).
|
||||
|
||||
## 0.1.0-alpha.15 — 2026-09-09
|
||||
|
||||
- Six familles de bâtiments reprennent leurs intérieurs : vraies pièces et
|
||||
portes, seuils, vestibules, couloirs, escaliers, sols, plafonds et mobilier
|
||||
adapté. Les parcours et les approches des lits sont vérifiés.
|
||||
- Réseaux souterrains d’égouts, passages, garages et pool rooms ; descentes
|
||||
de surface, bassins contenus et appuis courts ancrés lorsque la roche manque.
|
||||
- Petite ville compacte, rues et carrefours jointifs, terrasses dégagées et
|
||||
ponts intégrés au réseau. Correction des charnières, sièges et appuis des
|
||||
lanternes ; grande salle sèche dans les tunnels pour une future machine.
|
||||
- Codec `sanctuary:island_v15`, journal15 et pièces natives versionnées.
|
||||
Les anciennes générations gardent leurs plans, rendus et sauvegardes.
|
||||
Le contrat de reprise certifiée du vide reste strictement lié à sa génération.
|
||||
|
||||
`check build assemblePack`, les huit tests moteur et les scénarios de création
|
||||
et réouverture passent : 76 pièces natives et cinq coffres conservés, rues et
|
||||
intérieurs accessibles, bassins contenus. Release et canal packwiz publiés ;
|
||||
même instance Prism synchronisée deux fois, 767 fichiers personnels et réglages
|
||||
suivis conservés. Le JAR d’essai anticipé est sauvegardé séparément. Voir
|
||||
[les preuves et limites alpha.15](docs/testing-alpha15.md).
|
||||
|
||||
## 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** :
|
||||
Petit (512 blocs), Moyen (724, par défaut), Grand (1 024). Le diamètre est
|
||||
enregistré dans le générateur ; confirmer ou annuler agit sur le monde à créer.
|
||||
- Île initiale issue de la densité et de l’hydrologie complète de Population,
|
||||
avec sa forêt de surface et les poches de biomes souterrains natives du
|
||||
laboratoire. Les eaux et les ressources restent dépendantes du terrain.
|
||||
- Reprise des outils d’expansion alpha.12.1 dans ce même monde : parent,
|
||||
direction, climat, taille, relief, distance facultative et recherche de sites
|
||||
libres, sans remplacer les chunks existants. Le déblocage collectif reste futur.
|
||||
- Nouveau codec `sanctuary:island_v13`, racine de génération 13 et journal
|
||||
`data/sanctuary-world-v13/expansions.json`. Les extensions restent de génération
|
||||
12. Anciens codecs et presets conservés mais cachés de la création ; aucun
|
||||
monde existant converti, agrandi ou régénéré.
|
||||
- La suite GameTest unifiée devient le défaut de `check` et `runGameTest` ; les
|
||||
essais historiques demandent `-PsanctuaryUnifiedTests=false`.
|
||||
|
||||
`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. 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)
|
||||
|
||||
- Corrige le blocage de toutes les directions lorsque du vide a déjà été
|
||||
généré : la recherche continue plus loin sur le même rayon, sans remplacer
|
||||
les chunks existants.
|
||||
- Paramètre final `distance` facultatif pour `quick`, `create`, `preview` :
|
||||
distance minimale entre centres en blocs, de 0 à 100 000 ; 0 ou omis = auto.
|
||||
- Recherche limitée à 32 positions et un budget global de lecture de 30 secondes.
|
||||
Les erreurs disque et timeouts interrompent la demande ; seul un emplacement
|
||||
occupé permet de poursuivre. La graine et le profil sont conservés.
|
||||
- Compatibilité des commandes précédentes et des mondes laboratoire alpha.12 :
|
||||
journal v1 et génération 12 inchangés, sans migration ni régénération.
|
||||
|
||||
Les contrôles moteur de création et de rechargement passent sur la graine 42,
|
||||
avec huit premiers sites occupés et distance explicite. `check build assemblePack`
|
||||
passe, avec les douze tests historiques. Release, MRpack et canal publiés ;
|
||||
la même instance Prism est synchronisée deux fois et ses 466 fichiers personnels
|
||||
et réglages suivis sont conservés. Voir [WG-14](docs/backlog.md).
|
||||
|
||||
## 0.1.0-alpha.12 — 2026-09-09 (historique)
|
||||
|
||||
- Nouveau preset Sanctuary — Laboratoire, distinct de Petit/Grand. Outils
|
||||
opérateur `quick`, `create`, `preview`, `list`, `visit`, `resume` pour tester
|
||||
|
||||
@@ -13,10 +13,262 @@ infrastructures qui permettront progressivement d'ouvrir de nouveaux continents.
|
||||
> through the action of its players.
|
||||
|
||||
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
|
||||
conserve les intentions ; le [backlog](docs/backlog.md) suit les
|
||||
tickets. Les systèmes d’économie, de progression collective et les dimensions
|
||||
décrits dans la vision ne sont pas encore implémentés. L’alpha.12 fournit des
|
||||
outils d’expansion réservés à un nouveau monde de laboratoire.
|
||||
décrits dans la vision ne sont pas encore implémentés. **L’alpha.22 est
|
||||
publiée** : le placement des structures redevient sélectif. Les palettes,
|
||||
intérieurs et filons de l’alpha.21 sont conservés. Voir [les essais22](docs/testing-alpha22.md)
|
||||
et [la distribution](docs/packwiz.md).
|
||||
|
||||
## Alpha.22 — trouver le bon terrain
|
||||
|
||||
La nouvelle génération préfère refuser une ville ou un bâtiment facultatif
|
||||
qui ne trouve pas un emplacement adapté. Plus de ville forcée sur une réserve
|
||||
artificielle ; moins de terrassement, des fondations proches de la roche et des
|
||||
chemins qui suivent le relief. La grande traversée et la salle d’expansion sont
|
||||
réservées en priorité et peuvent exister sans ville au-dessus. Les autres lieux
|
||||
n’ont pas de quota garanti.
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour utiliser ces règles. Les tailles
|
||||
512/724/1024 et le bouton natif **Personnaliser** restent inchangés ; les anciens
|
||||
mondes conservent leurs générateurs. La vision retient désormais **Minecraft
|
||||
comme archéologie d’un autre Minecraft** : chaque ruine témoigne d’une ancienne
|
||||
solution, et de ceux qui l’ont construite puis ont disparu.
|
||||
|
||||
Les trois tailles natives, la réouverture, le cas expérimental OFF et la
|
||||
compatibilité21 passent, ainsi que `check build assemblePack` et ses huit
|
||||
GameTests généraux. Le [MRpack alpha.22](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.22/Sanctuary-0.1.0-alpha.22.mrpack)
|
||||
est disponible, le canal packwiz et la même instance Prism sont synchronisés.
|
||||
Les925 fichiers personnels et réglages suivis sont conservés. Voir
|
||||
[la distribution](docs/packwiz.md).
|
||||
|
||||
## Alpha.21 — matériaux, quartiers et traversées
|
||||
|
||||
La ville revient à la pierre lisse, pierre, briques de pierre, andésite et chêne.
|
||||
Le tuf accompagne surtout les chemins et les sous-sols. Les toits et ornements
|
||||
sont raccordés à leurs appuis ; les pièces gardent leurs aménagements. La ville
|
||||
recherche des petits quartiers séparés par du terrain naturel, avec des chemins
|
||||
vers le cœur. Les lieux patrimoniaux recherchent davantage de raccords.
|
||||
|
||||
L’option expérimentale ajoute une recherche de galeries industrielles : chaussée
|
||||
cinq blocs, deux trottoirs, salles et escaliers de surface. Les placements sur
|
||||
pente sont assouplis et les destinations isolées sont prioritaires pour les chemins.
|
||||
Créer un **nouveau monde Sanctuary** pour ce générateur21. Les anciens mondes
|
||||
conservent leur génération. Les vérifications et coordonnées du
|
||||
témoin sont consignées dans [les essais](docs/testing-alpha21.md).
|
||||
|
||||
Le [MRpack alpha.21](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.21/Sanctuary-0.1.0-alpha.21.mrpack)
|
||||
est disponible. Le canal packwiz et la même instance Prism sont synchronisés ;
|
||||
les 925 fichiers personnels et réglages suivis sont conservés. Les tests de
|
||||
création/réouverture, le cas OFF et `check build assemblePack` passent.
|
||||
|
||||
## Alpha.20 — identité des bâtiments
|
||||
|
||||
La passe reprend les six bâtiments de ville, les quatorze infrastructures,
|
||||
les parcs et les sous-sols. Bois de chêne et de bouleau, granit, diorite et tuf
|
||||
identifient les usages ; biseaux, baies, corniches, portiques et verrières
|
||||
donnent du relief aux volumes. Les routes reçoivent des bordures de briques
|
||||
de tuf et des blocs ciselés. Les vitres et grilles se raccordent entre chunks.
|
||||
|
||||
Créer un **nouveau monde Sanctuary, Moyen, graine 0**, avec **Structures
|
||||
expérimentales** activées pour retrouver le témoin. Les anciens mondes gardent
|
||||
leur génération. Le bouton natif **Personnaliser** et ses trois tailles restent
|
||||
identiques. Voir le [catalogue illustré](docs/architecture-alpha20.md) et les
|
||||
[essais alpha.20](docs/testing-alpha20.md). `check build assemblePack`, les essais natifs et les artefacts sont vérifiés ;
|
||||
la [release](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.20)
|
||||
et le canal packwiz sont publiés. La même instance Prism est synchronisée
|
||||
deux fois, avec 925 fichiers personnels et réglages suivis conservés.
|
||||
|
||||
## Alpha.19 — anciennes infrastructures
|
||||
|
||||
Le chantier ajoute une première passe des quatorze lieux du
|
||||
[catalogue](docs/structures-audit.md), avec leurs accès, des petits butins vanilla
|
||||
et des voies de fret ou chemins abandonnés entre destinations réelles.
|
||||
L’option **Structures expérimentales** reste enregistrée à la création du monde.
|
||||
La nouvelle génération garde les anciennes sauvegardes intactes. Aucun système
|
||||
de progression, mailbox, caméra ou réacteur n’est activé.
|
||||
Les trois tailles sont vérifiées en génération native ; création et réouverture
|
||||
conservent les structures et les coffres. `check build assemblePack` passe.
|
||||
Pour voir le grand fret : **Moyen, graine 0**, option activée ;
|
||||
`/sanctuary structures` liste les entrées. Voir [les preuves et la carte](docs/testing-alpha19.md).
|
||||
La [release alpha.19](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.19)
|
||||
et le canal packwiz sont publiés. La même instance Prism est synchronisée deux
|
||||
fois, avec 767 fichiers personnels et réglages suivis conservés.
|
||||
|
||||
## Alpha.17 — prototype des réseaux oubliés
|
||||
|
||||
La nouvelle génération expérimentale recherche trois petits réseaux sur l’île
|
||||
initiale : mines et ateliers, salles étranges, installations techniques
|
||||
abandonnées. Chaque réseau possède une entrée de surface et des salles reliées,
|
||||
avec mobilier, outils vanilla et traces de travaux interrompus. Les continents
|
||||
conservent leur génération urbaine sans recevoir ces réseaux supplémentaires.
|
||||
|
||||
Dans **Personnaliser**, l’option **Structures expérimentales** accompagne les
|
||||
trois tailles habituelles. Elle est enregistrée avec chaque nouveau monde ;
|
||||
elle permet de comparer un monde avec les nouveaux lieux et un autre avec le
|
||||
socle urbain seul. La présence des réseaux dépend des emplacements admissibles
|
||||
trouvés dans un budget borné. Les connexions entre familles restent à construire.
|
||||
|
||||
Le prototype demande un **nouveau monde**. Les mondes 16 et antérieurs gardent
|
||||
leur génération. Revenir au binaire 16 ne permet pas d’ouvrir un monde 17 : les
|
||||
mondes d’essai restent séparés. Voir [le contrat alpha.17](docs/testing-alpha17.md).
|
||||
Le réacteur, la progression et les reliques restent prévus pour la suite.
|
||||
|
||||
`check build assemblePack`, création/réouverture et option désactivée passent.
|
||||
Les empreintes natives de la génération 16 restent identiques aux témoins publiés.
|
||||
La [release alpha.17](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.17)
|
||||
et le canal packwiz sont publiés ; la même instance Prism est synchronisée deux
|
||||
fois, avec 767 fichiers personnels et réglages suivis conservés. Pour voir les
|
||||
trois réseaux, créer un monde **Moyen, graine 0**, option activée ;
|
||||
`/locate structure sanctuary:forgotten_networks` indique une entrée de surface.
|
||||
|
||||
## Alpha.16 — Sanctuary habitée avant nous
|
||||
|
||||
Une ville compacte apparaît sur la nouvelle île de départ lorsque les structures
|
||||
sont activées. Les étages varient leurs plans ; les sanitaires sont clos et les
|
||||
lits adossés aux murs. Sous la ville, la génération recherche une piscine couverte
|
||||
de **50 × 25 blocs**, des parkings de **3 à 8 plateformes**, une grande salle
|
||||
sèche et des égouts qui s’ouvrent vers l’extérieur avec de vrais écoulements.
|
||||
Chaque équipement dépend du relief et des appuis : les six îles testées ont une
|
||||
ville, mais seules trois accueillent la grande piscine. Les continents conservent
|
||||
leurs villes conditionnelles.
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour la génération 16. Les mondes publiés
|
||||
jusqu’à l’alpha.15 gardent leur génération, y compris dans leurs futurs chunks.
|
||||
Le bouton natif **Personnaliser** conserve Petit 512, Moyen 724 par défaut et
|
||||
Grand 1 024. Le [protocole alpha.16](docs/testing-alpha16.md) donne les preuves,
|
||||
les limites et les coordonnées du monde **Moyen, graine 0**, qui contient la
|
||||
piscine, huit niveaux de parking, la salle et l’égout ouvert.
|
||||
|
||||
`check build assemblePack`, la création et la réouverture natives passent.
|
||||
La compatibilité 15 est vérifiée contre les empreintes de la version publiée.
|
||||
La [release alpha.16](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.16)
|
||||
est disponible ; deux synchronisations de la même instance Prism conservent
|
||||
les 767 fichiers personnels et réglages suivis.
|
||||
Les futurs donjons formeront plusieurs réseaux parfois connectés, avec des
|
||||
installations encore utilisables et des chantiers interrompus. Le réacteur,
|
||||
les reliques et leurs récompenses restent à construire.
|
||||
|
||||
## Alpha.15 — ville compacte et réseaux
|
||||
|
||||
Les bâtiments de Lost City reçoivent des plans intérieurs détaillés : pièces,
|
||||
portes, vestibules, couloirs, escaliers, chambres, cuisines et salles d’eau.
|
||||
Les égouts relient des salles et équipements souterrains, avec garages, pool
|
||||
rooms et accès depuis la surface. Les bâtiments se regroupent en une
|
||||
petite ville compacte avec rues, carrefours et entrées reliés. Les ponts et
|
||||
viaducs prolongent le réseau ; le terrain est dégagé dans l’emprise urbaine.
|
||||
Une grande salle souterraine prépare la future machine d’expansion.
|
||||
|
||||
Le biome Lost City est conservé car il pilote réellement les biomes urbains
|
||||
et leurs apparitions de zombies vanilla. La nouvelle génération **15** demande
|
||||
un **nouveau monde Sanctuary** ; les mondes 12/13/14 gardent leur génération.
|
||||
Les tailles et le bouton natif **Personnaliser** restent identiques. Le
|
||||
[protocole alpha.15](docs/testing-alpha15.md) consigne les preuves et les limites. Les tests de création et de réouverture
|
||||
passent, ainsi que `check build assemblePack`. La
|
||||
[release alpha.15](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.15)
|
||||
est publiée ; la même instance Prism est synchronisée deux fois, avec 767
|
||||
fichiers personnels et réglages suivis conservés. Créer un **nouveau monde**,
|
||||
y compris après le JAR d’essai anticipé15. La machine d’expansion et ses cartes
|
||||
préremplies restent prévues pour un prochain ticket.
|
||||
|
||||
## 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
|
||||
|
||||
À la création d’un **nouveau monde**, sélectionner **Sanctuary**, puis utiliser
|
||||
le bouton natif **Personnaliser** pour choisir la taille de l’île :
|
||||
|
||||
| Taille | Diamètre nominal |
|
||||
| --- | --- |
|
||||
| Petit | 512 blocs |
|
||||
| Moyen, par défaut | 724 blocs |
|
||||
| Grand | 1 024 blocs |
|
||||
|
||||
**Terminé** conserve le choix ; **Annuler** revient sans l’appliquer. La hauteur
|
||||
reste de 384 blocs. Ces tailles décrivent une emprise, sans promettre un nombre
|
||||
de joueurs, une aire constructible ou un stock de minerais.
|
||||
|
||||
L’île reprend les forêts, le relief flottant et les plans d’eau complets du
|
||||
générateur précédent : bassins, rivière, terrasses et cascades restent liés
|
||||
aux possibilités naturelles du terrain. Les poches de cavités natives de Minecraft
|
||||
du laboratoire rejoignent cette île. Les commandes opérateur
|
||||
d’expansion, avec leur distance facultative et leur recherche d’emplacements
|
||||
libres, sont accessibles dans ce même nouveau monde. Les déblocages par la
|
||||
production et l’XP restent à construire.
|
||||
|
||||
Les anciens choix Petit/Grand et Laboratoire sont cachés à la création, tout
|
||||
en restant chargeables avec leurs paramètres enregistrés. **Aucun monde
|
||||
existant n’est converti ni régénéré.** Le [guide alpha.13](docs/alpha13-worldgen.md)
|
||||
détaille la création, les expansions et la séparation des sauvegardes.
|
||||
|
||||
`check build assemblePack`, les tests client, les trois tailles et les scénarios
|
||||
d’expansion/rechargement passent. Moyen atteint 63,66 secondes pour la création
|
||||
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
|
||||
|
||||
@@ -63,7 +315,7 @@ sur les trois graines de référence, avec 1 800 ticks de fluides. Ces résultat
|
||||
ne valident pas par avance l’incrément suivant. Les observations et leurs
|
||||
limites figurent dans [Validation](docs/testing.md).
|
||||
|
||||
## Alpha.9 : cavités vivantes et terrasses d’eau
|
||||
## Alpha.9, historique : cavités vivantes et terrasses d’eau
|
||||
|
||||
L’incrément ajoute des cavités luxuriantes et lumineuses sous l’île, des
|
||||
secteurs à spéléothèmes, des géodes d’améthyste et des poches de soufre moins
|
||||
@@ -129,7 +381,7 @@ en jeu reste à construire. Voir [Expansion et ressources](docs/expansion.md).
|
||||
Les détails du terrain et de ses limites sont dans [Génération](docs/worldgen.md),
|
||||
et la provenance dans [Audit 26.2](docs/migration-26.2.md).
|
||||
|
||||
## Alpha.11 : Petit, Grand et génération plus rapide
|
||||
## Alpha.11, historique : Petit, Grand et génération plus rapide
|
||||
|
||||
Les anciens formats sont trop grands et éloignent les constructions. Le
|
||||
nouveau choix de départ se limite à **Petit** et **Grand**, avec **Grand par
|
||||
@@ -160,7 +412,7 @@ de la même instance sont consignées dans [Distribution](docs/packwiz.md).
|
||||
L’alpha.11 est publiée ; la même instance Prism a été synchronisée deux fois,
|
||||
avec les 364 fichiers personnels et réglages suivis conservés.
|
||||
|
||||
## Alpha.12 — laboratoire d’expansion
|
||||
## Alpha.12, historique — laboratoire d’expansion
|
||||
|
||||
Un troisième preset **Sanctuary — Laboratoire** permet de tester les expansions
|
||||
dans un **nouveau monde**, sur la même version Minecraft. Les choix Petit et
|
||||
@@ -199,6 +451,25 @@ passent. L’alpha.12, son MRpack et le canal packwiz sont publiés ; la même
|
||||
instance Prism a été synchronisée deux fois, avec les 466 fichiers personnels
|
||||
et réglages suivis conservés. Voir [Distribution](docs/packwiz.md).
|
||||
|
||||
## Alpha.12.1, historique — distance et emplacements libres
|
||||
|
||||
Le correctif ajoute une distance minimale facultative à la fin des commandes :
|
||||
|
||||
```text
|
||||
/sanctuary expansion quick north autumn 128 peaks 2048
|
||||
/sanctuary expansion create desert sanctuary east arid 128 natural 4096
|
||||
```
|
||||
|
||||
La distance se mesure entre les centres depuis le parent choisi. Sans distance,
|
||||
le placement reste automatique. Les emplacements déjà générés sont maintenant
|
||||
écartés en cherchant plus loin dans la même direction, sans changer le climat
|
||||
ni réécrire les chunks. Les mondes laboratoire alpha.12 peuvent être conservés :
|
||||
le journal et la génération restent compatibles. Voir le [guide](docs/alpha12-laboratory.md).
|
||||
|
||||
`check build assemblePack`, les scénarios moteur et les tests de distance passent.
|
||||
L’alpha.12.1 et son MRpack sont publiés ; la même instance Prism est synchronisée
|
||||
deux fois, avec 466 fichiers personnels et réglages suivis conservés.
|
||||
|
||||
## Versions et prérequis
|
||||
|
||||
Au 8 septembre 2026, la cible disponible est **26.3-pre-2**. Le dépôt ne prétend
|
||||
@@ -213,7 +484,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.12, publiée et synchronisée |
|
||||
| Sanctuary / pack | 0.1.0-alpha.21 |
|
||||
|
||||
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
|
||||
@@ -234,13 +505,15 @@ cd sanctuary-beta
|
||||
```
|
||||
|
||||
`check` inclut les tests de forme et le serveur de test automatisé de Fabric,
|
||||
qui charge le vrai preset Sanctuary dans un monde de développement neuf.
|
||||
qui charge par défaut le nouveau générateur unifié Sanctuary dans un monde
|
||||
de développement neuf. Les suites historiques utilisent explicitement
|
||||
`-PsanctuaryUnifiedTests=false`.
|
||||
Les scénarios couverts et les essais multijoueurs restant à effectuer sont
|
||||
décrits dans [Validation](docs/testing.md).
|
||||
|
||||
Résultats :
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.12.jar` : mod à installer avec
|
||||
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.21.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).
|
||||
@@ -258,7 +531,8 @@ Lancer le client de développement :
|
||||
./gradlew :sanctuary:runClient
|
||||
```
|
||||
|
||||
Créer un **nouveau monde**, puis sélectionner le type de monde **Sanctuary**.
|
||||
Créer un **nouveau monde**, sélectionner le type **Sanctuary**, puis
|
||||
**Personnaliser** pour changer la taille Moyen retenue par défaut.
|
||||
L'installation du mod seule ne transforme pas un monde Minecraft ordinaire.
|
||||
|
||||
Pour un serveur de développement, lancer `./gradlew :sanctuary:runServer`, puis
|
||||
|
||||
@@ -66,13 +66,37 @@ avec un camp abandonné ; aucune structure n’est garantie sur chaque graine.
|
||||
```
|
||||
|
||||
`quick` part toujours de Sanctuary et attribue un identifiant `island_N`.
|
||||
`create <id> <parent> <direction> <climate> <diameter> <relief>` ouvre une branche
|
||||
`create <id> <parent> <direction> <climate> <diameter> <relief> [distance]` ouvre une branche
|
||||
nommée depuis n’importe quelle île prête. `preview` emploie les mêmes paramètres ;
|
||||
il propose des coordonnées mais ne valide ni l’occupation des chunks ni le sol.
|
||||
Répéter un `create` identique rend la même réservation ; un autre profil sous
|
||||
le même identifiant est refusé. `resume <id>` reprend une préparation interrompue.
|
||||
Après une erreur du journal, il faut rétablir le stockage puis recharger le monde.
|
||||
|
||||
Depuis l’**alpha.12.1**, `quick`, `create` et `preview` acceptent une distance
|
||||
minimale facultative à la fin, de 0 à 100 000 blocs. Elle mesure la distance
|
||||
entre les centres du parent et de l’enfant, y compris en diagonale. L’alignement
|
||||
des chunks et les marges du terrain peuvent éloigner légèrement le résultat.
|
||||
Une valeur de 0, ou son omission, laisse le placement automatique.
|
||||
|
||||
```text
|
||||
/sanctuary expansion quick north autumn 128 peaks 2048
|
||||
/sanctuary expansion create desert sanctuary east arid 128 natural 4096
|
||||
/sanctuary expansion preview glacier sanctuary northeast glacial 256 canyon 2048
|
||||
```
|
||||
|
||||
Si une zone est déjà générée, `create` et `quick` essaient plus loin dans la
|
||||
même direction, sur 32 positions maximum avec un budget total de lecture de
|
||||
30 secondes. Le climat, le relief et la graine restent ceux de la demande.
|
||||
`preview` propose seulement le premier emplacement géométrique ; le résultat
|
||||
réel peut être plus loin. Les erreurs de lecture interrompent la recherche.
|
||||
Les anciens appels restent valides. Un identifiant déjà créé garde sa position ;
|
||||
demander sous le même nom une distance supérieure à sa distance réelle est refusé.
|
||||
|
||||
Cette correction utilise le même journal v1 et les mêmes descripteurs de
|
||||
génération 12 : le monde laboratoire alpha.12 actuel peut être conservé. Aucun
|
||||
ancien chunk n’est régénéré ; la recherche réserve uniquement une nouvelle zone.
|
||||
|
||||
| Profil de commande | Surface recherchée |
|
||||
| --- | --- |
|
||||
| `temperate` | Chênes, bouleaux, clairières et fleurs |
|
||||
@@ -127,7 +151,7 @@ La réservation ajoute 32 blocs de décoration et 176 blocs de halo moteur autou
|
||||
de l’enveloppe de terrain. Les petites îles sont donc encore assez éloignées :
|
||||
le test de diamètre 64 place une première île à 800 blocs du centre de Sanctuary,
|
||||
puis une branche à 544 blocs de son parent. Un emplacement déjà exploré est
|
||||
refusé ; le laboratoire ne réécrit pas le vide pour rapprocher les îles.
|
||||
écarté ; depuis l’alpha.12.1, la recherche continue plus loin sans réécrire ce vide.
|
||||
Il existe une limite de 64 régions, origine comprise, et de ±100 000 blocs pour
|
||||
leurs centres. La vérification initiale d’occupation est synchrone et peut
|
||||
suspendre brièvement le serveur pendant les lectures disque ; la préparation
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# Alpha.13 — un monde Sanctuary unifié
|
||||
|
||||
**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.
|
||||
|
||||
La cible reste **Minecraft 26.3-pre-2 / Fabric**. Ce guide concerne un nouveau
|
||||
monde alpha.13. Les anciens mondes conservent leur génération.
|
||||
|
||||
## Créer son monde
|
||||
|
||||
1. Créer un **nouveau monde**, puis sélectionner le type **Sanctuary**.
|
||||
2. Ouvrir le bouton natif **Personnaliser**.
|
||||
3. Choisir la taille, puis cliquer sur **Terminé** avant de créer le monde.
|
||||
|
||||
| Taille | Diamètre nominal de l’île initiale |
|
||||
| --- | --- |
|
||||
| Petit | 512 blocs |
|
||||
| Moyen, choix par défaut | 724 blocs |
|
||||
| Grand | 1 024 blocs |
|
||||
|
||||
**Annuler** revient sans appliquer le changement. La hauteur reste de 384 blocs,
|
||||
de `Y=0` à `Y=383`. Le diamètre est une référence pour la forme : trous, surplombs,
|
||||
fragments et contour irrégulier dépendent de la graine. Il ne mesure pas une
|
||||
surface entièrement constructible et ne fixe pas un nombre de joueurs.
|
||||
|
||||
La liste des types de monde contient un seul choix Sanctuary ; les types
|
||||
Minecraft habituels restent disponibles. Petit, Moyen et Grand sont des
|
||||
réglages de ce même choix. L’ancien Grand alpha.11, de 724 blocs, correspond
|
||||
au diamètre nominal du nouveau Moyen.
|
||||
|
||||
Sur un serveur neuf, le preset suivant crée un monde de taille Moyen :
|
||||
|
||||
```properties
|
||||
level-type=sanctuary:sanctuary
|
||||
level-name=sanctuary-dev
|
||||
level-seed=42
|
||||
```
|
||||
|
||||
Choisir un dossier de monde neuf pour un essai. Le bouton **Personnaliser**
|
||||
configure la création en solo ; il ne modifie pas les fichiers d’un serveur
|
||||
existant. Installer le mod ou changer `level-type` ne transforme pas une partie.
|
||||
|
||||
## L’île de départ
|
||||
|
||||
Le nouveau monde reprend le générateur complet de l’île initiale : forêts de
|
||||
chênes et de bouleaux, clairières, affleurements, strates rocheuses et corniches
|
||||
intérieures. Son relief flottant conserve les trous et les surplombs, sans
|
||||
ajouter de noyau cylindrique pour garantir un terrain à l’origine.
|
||||
|
||||
Les plans hydrologiques de Population sont repris : étangs et lacs, recherche
|
||||
de rivière, bassins étagés et leurs débordements, sources et cascades, poche et
|
||||
coulées de lave. Le placement dépend des supports réellement présents dans la
|
||||
roche. Un plan qui ne trouve pas de site admissible reste absent ; aucune
|
||||
rivière, terrasse ou ressource n’est garantie sur chaque graine. Les eaux en
|
||||
terrasses permettent la nage verticale sans ajouter de culture de riz.
|
||||
|
||||
Les poches souterraines natives du laboratoire rejoignent cette île : cavités
|
||||
luxuriantes, à spéléothèmes ou soufrées, avec leurs décorations Minecraft.
|
||||
Il s’agit toujours des profondeurs de l’Overworld, pas de la dimension des
|
||||
cavernes prévue dans la vision. L’île garde sa palette tempérée en surface ;
|
||||
Dappled Forest reste disponible dans les expansions au climat automnal.
|
||||
|
||||
L’intégration protège les volumes déclarés par les plans d’eau et de lave
|
||||
contre les écritures de décoration suivantes. Cette protection concerne les
|
||||
plans de génération, sans mettre les blocs à l’abri des actions des joueurs.
|
||||
Les preuves moteur et leurs limites sont suivies dans [Validation](testing.md).
|
||||
|
||||
## Ajouter une île
|
||||
|
||||
Les commandes demandent les commandes autorisées en solo ou la permission
|
||||
opérateur sur le serveur. Elles reprennent les outils alpha.12.1 dans le nouveau
|
||||
monde Sanctuary. L’économie, les recherches et les contributions collectives
|
||||
ne conditionnent pas encore leur utilisation.
|
||||
|
||||
```text
|
||||
/sanctuary expansion quick north autumn 128 peaks
|
||||
/sanctuary expansion list
|
||||
/sanctuary expansion visit island_1
|
||||
/sanctuary expansion create oasis island_1 east arid 64 natural 2048
|
||||
```
|
||||
|
||||
`quick` ouvre une île depuis Sanctuary et lui attribue un identifiant `island_N`.
|
||||
L’exemple de visite suppose qu’il s’agit de la première réservation ; `list`
|
||||
indique l’identifiant et l’état réels. La visite attend que l’île soit prête.
|
||||
`create` choisit un nom et un parent déjà prêt pour prolonger une branche.
|
||||
La création ne téléporte personne implicitement.
|
||||
|
||||
Les nouveaux continents choisissent leur propre direction, climat, diamètre
|
||||
**64/128/256/512** et relief **natural/peaks/canyon/volcano**. La direction ne
|
||||
contraint pas le climat. L’autocomplétion fournit les huit directions et les
|
||||
douze profils : `temperate`, `boreal`, `glacial`, `cold_humid`, `humid`, `tropical`,
|
||||
`warm`, `arid`, `dry`, `cold_dry`, `autumn`, `cherry`.
|
||||
|
||||
Le dernier nombre facultatif est une **distance minimale entre les centres**
|
||||
du parent et de l’enfant, entre 0 et 100 000 blocs. Une valeur de 0 ou son
|
||||
omission laisse le placement automatique. Les marges et l’alignement sur les
|
||||
chunks peuvent éloigner le centre retenu.
|
||||
|
||||
Si le premier site est occupé, la recherche continue plus loin dans la même
|
||||
direction : au plus 32 positions et 30 secondes de lecture au total. Même du
|
||||
vide déjà généré compte comme occupé. Une erreur disque arrête la demande ;
|
||||
elle n’autorise pas à remplacer les chunks concernés.
|
||||
|
||||
```text
|
||||
/sanctuary expansion preview glacier sanctuary northeast glacial 256 canyon 2048
|
||||
/sanctuary expansion resume island_1
|
||||
```
|
||||
|
||||
`preview` calcule une proposition géométrique sans activer l’île et sans vérifier
|
||||
l’occupation ou les fondations. `resume` reprend une réservation interrompue,
|
||||
avec sa graine et ses paramètres conservés. Le système ne fournit aucune commande
|
||||
de régénération des régions déjà créées.
|
||||
|
||||
Les îles ajoutées gardent la génération d’expansion alpha.12 : leurs climats,
|
||||
reliefs, décorations et structures compatibles. Elles ne reçoivent pas
|
||||
systématiquement la grande rivière ou les terrasses de l’île initiale. Villages,
|
||||
temples, cabanes de marais et camps abandonnés dépendent du biome, du diamètre
|
||||
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
|
||||
`sanctuary:island_v13`. Sa source de biomes est également enregistrée sous
|
||||
`sanctuary:island_v13`. Le générateur sérialise `initial_diameter`, limité à
|
||||
512, 724 ou 1 024 ; une valeur absente prend 724. Ce champ appartient à la
|
||||
sauvegarde créée, et ne suit pas les changements ultérieurs de défaut du pack.
|
||||
|
||||
Pour l’île initiale, les réglages internes `sanctuary:unified_5`,
|
||||
`sanctuary:unified_10` et `sanctuary:unified_20` correspondent respectivement
|
||||
aux diamètres 512, 724 et 1 024. Les suffixes sont des références de calcul,
|
||||
pas des capacités affichées. Ils reprennent la densité Population et son
|
||||
hydrologie avec le générateur et le `RandomState` propres à l’origine.
|
||||
|
||||
La source combine la palette Population de départ et le sélecteur de cavités
|
||||
natives du laboratoire, avec les mêmes conditions de sélection de ces poches.
|
||||
Les îles enfants conservent le sélecteur climatique du laboratoire. Les 34
|
||||
biomes natifs de cette palette et les dix biomes Population sont déclarés
|
||||
avant la première expansion ; TerraMix n’est pas une dépendance téléchargée.
|
||||
|
||||
Le nouveau journal est enregistré dans le dossier du monde :
|
||||
|
||||
```text
|
||||
data/sanctuary-world-v13/expansions.json
|
||||
```
|
||||
|
||||
Son schéma reste **1**, avec une racine de **génération 13** et des extensions
|
||||
de **génération 12**. Les identifiants, parents, graines, paramètres, positions
|
||||
et états d’ouverture sont conservés côté serveur. La racine et le diamètre
|
||||
initial ne sont pas modifiables par une commande d’expansion.
|
||||
|
||||
Les mondes du laboratoire alpha.12/12.1 gardent leur codec et leur journal
|
||||
`data/sanctuary-expansion-v1/expansions.json`. Les générations Population et
|
||||
les autres générations antérieures gardent leurs paramètres enregistrés.
|
||||
Les presets historiques sont conservés mais cachés dans la création publique.
|
||||
**Aucun monde n’est converti, agrandi, réduit ou régénéré par l’alpha.13.**
|
||||
Le [guide alpha.12](alpha12-laboratory.md) est une référence historique, pas une
|
||||
instruction de sélectionner à nouveau le laboratoire pour cette version.
|
||||
|
||||
## Contrôles de développement
|
||||
|
||||
`./gradlew check build assemblePack` et `:sanctuary:runGameTest` sélectionnent
|
||||
par défaut la suite unifiée. Son diamètre de test se choisit avec
|
||||
`-PsanctuaryTestDiameter=512`, `724` ou `1024` ; il ne change pas le défaut public.
|
||||
|
||||
Les suites Population historiques demandent `-PsanctuaryUnifiedTests=false`,
|
||||
avec leur profil `-PsanctuaryTestPlayers=5`, `10`, `20` ou `100`. Les essais du
|
||||
laboratoire historique utilisent aussi ce drapeau, avec
|
||||
`-PsanctuaryExpansionTests=true`. Ces processus travaillent dans les dossiers
|
||||
de développement ignorés, jamais dans les sauvegardes personnelles.
|
||||
|
||||
Les validations et mesures des alpha.11, .12 et .12.1 restent historiques.
|
||||
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 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).
|
||||
@@ -0,0 +1,115 @@
|
||||
# Architecture de l’alpha.20
|
||||
|
||||
Cette passe donne une identité visuelle aux lieux déjà planifiés : volumes,
|
||||
façades, sols, plafonds et mobilier. Elle réutilise les emprises et les accès
|
||||
certifiés des générations précédentes. Les bâtiments de ville et les sites
|
||||
expérimentaux restent inscrits dans leurs parcelles de 17 × 17 blocs ; leurs
|
||||
corniches et leurs retraits restent à l’intérieur de cette limite.
|
||||
|
||||
Les nouveaux rendus demandent un **nouveau monde de génération 20**. Les mondes
|
||||
19 et antérieurs conservent leurs bâtiments, y compris dans les futurs chunks.
|
||||
Le bouton **Personnaliser**, les trois tailles et l’option **Structures
|
||||
expérimentales** restent disponibles. Le catalogue n’impose pas que chaque lieu
|
||||
apparaisse sur chaque île.
|
||||
|
||||

|
||||
|
||||
*Vues des blocs réellement émis par les générateurs, affichés avec les modèles
|
||||
Minecraft, sans terrain. Ce montage est une prévisualisation géométrique, pas
|
||||
une capture du client en jeu ; il montre une sélection du catalogue.*
|
||||
|
||||
## Les six bâtiments de Lost City
|
||||
|
||||
Les étages conservent leurs plans alternés, leurs pièces et leurs escaliers.
|
||||
Les sanitaires sont clos, les lits adossés à de vrais murs, et les lampes
|
||||
reposent sur des blocs complets. Les aménagements de toit décrits ci-dessous
|
||||
possèdent des points d’accès vérifiés par le smoke du renderer.
|
||||
|
||||
| Fonction | Forme et façade | Espaces et détails construits |
|
||||
| --- | --- | --- |
|
||||
| Immeuble | Angles avant coupés, baies en saillie, montants de chêne écorcé et bandeaux de tuf. | Parquets alternant chêne et bouleau, logements avec cuisine et chambre ; jardinières, portique de bois et table sur le toit. |
|
||||
| Hôtel | Portique et travées encadrées de diorite polie, garde-corps et bandeaux en bouleau ; pavillon de toit en retrait. | Réception, chambres, salons, sanitaires et buanderie ; petit local de linge et bancs sur le toit. |
|
||||
| Bibliothèque | Grandes baies, angles coupés et lames verticales en bouleau écorcé ; lanterneau vitré avec ouverture à travers le toit et le plafond du dernier étage. | Rayonnages, archives, tables et lutrins ; sols délimitant les espaces de lecture et terrasse de lecture sur le toit. |
|
||||
| Restaurant | Encadrements de granit poli, vitrines et auvents associant chêne et bouleau. | Salles à manger et cuisines réparties par étage, garde-manger et sanitaires ; tables sur le toit et sorties d’extraction maçonnées. |
|
||||
| Caserne | Piles épaisses de briques de tuf, soubassements ciselés et fenêtres grillagées. | Dortoirs, bureau de service, réserves d’équipement et sanitaires ; cage d’escalier coiffée d’un pavillon accessible. |
|
||||
| Hall | Travées monumentales, piliers ciselés, corniches de diorite et fronton de toit en gradins. | Billetterie, bureaux, salle avec scène sur deux niveaux, galerie protégée, salles de réunion et archives. |
|
||||
|
||||
## Les quatorze anciennes infrastructures
|
||||
|
||||
Chaque site conserve ses seuils, son accès de service et son coffre de découverte.
|
||||
Les fonctions ci-dessous sont exprimées par les pièces et les blocs vanilla
|
||||
présents ; elles ne constituent pas de nouveaux systèmes de progression.
|
||||
|
||||
| Lieu | Architecture et usage visibles |
|
||||
| --- | --- |
|
||||
| Gare de fret | Halle aux angles coupés, lanterneau vitré et long auvent de quai en chêne ; voie, salle d’attente et petit bureau de signalisation. |
|
||||
| Poste et centre de tri | Façade de granit, accents de diorite et toiture en retrait ; guichet, rangées de tonneaux, passage de tri et bureau. |
|
||||
| Dépôt communal | Grand entrepôt nervuré de pierre, toiture industrielle à trois travées vitrées en dents de scie ; rails, chargement, caisses et mezzanine d’inventaire. |
|
||||
| Cartothèque d’expédition | Corps en bouleau, petites ailes différenciées et balcon arrière abrité ; table de cartographie, lutrin, archives et réserve. |
|
||||
| Observatoire et météo | Tour à pans coupés, contreforts de diorite et couronne ouverte portée par des nervures ; deux volées d’escalier, salle d’instruments et plateforme d’observation. |
|
||||
| Atelier ferroviaire | Large couverture à nervures et vitrages, piles de granit ; deux voies, fosse d’entretien avec escalier, établi, fourneau, meule et petite réserve. |
|
||||
| Station de pompage et château d’eau | Bâtiment bas, conduites figurées par des grilles et murets, cuve haute sur quatre piles ciselées ; galerie d’entretien et réserve sèche. L’eau reste dans la cuve. |
|
||||
| Sous-station | Cour clôturée, supports et grilles autour des équipements, locaux bas à toiture nervurée ; commandes vanilla et tranchée d’entretien accessible. |
|
||||
| Serre et graineterie | Verrière haute rythmée par des nervures de bouleau ; cultures, deux points d’irrigation contenus, allée de tuf et remise de rempotage. |
|
||||
| Quai sur le vide | Platelage en chêne, garde-corps à piliers sculptés, abris bas et grue de bois à chaîne ; attente, débarcadère et réserve. |
|
||||
| Relais radio et maison du gardien | Petite maison de chêne avec toiture à deux matières et cheminée ; antenne haute à traverses, plateforme accessible et chambre du gardien. |
|
||||
| Club photo et chambre noire | Façade claire de diorite, corniche sculptée et toiture arrière en retrait ; salle du club, chambre noire, bacs et espace de séchage à chaînes. |
|
||||
| Vestiaires et salles étranges | Entrée basse et étroite suivie d’une salle haute à colonnes répétées ; casiers, sols carrelés, alcôve et petite pièce de service. |
|
||||
| Refuge de chantier | Cabane surélevée sur piles ciselées, toiture inclinée et ossature inachevée ; couchage, établi, escalier extérieur et trappe avec échelle vers la réserve inférieure. |
|
||||
|
||||
Les voies de fret gardent leur ballast, leurs traverses, leurs courbes et leurs
|
||||
rails manquants. Les chemins reçoivent des bordures de briques de tuf et des
|
||||
accents ciselés ; leurs pentes et leurs destinations restent celles du plan
|
||||
sauvegardé.
|
||||
|
||||
## Espaces publics et équipements extérieurs
|
||||
|
||||
| Espace | Détails présents en génération 20 |
|
||||
| --- | --- |
|
||||
| Rues et ponts | Chaussée d’andésite polie, bordures de tuf poli, bandes de briques de tuf ponctuées de blocs ciselés ; marches sur les pentes, piles de garde-corps et grilles sur les ponts. Les tabliers n’ajoutent pas de colonnes vers le vide. |
|
||||
| Parc arboré | Allées en croix, bancs de chêne à accoudoirs minéraux, lampadaires et petite couronne de bouleau à côté des arbres existants. |
|
||||
| Jardin | Quatre parterres bordés de diorite, angles de tuf sculpté, portique de bois et promenade centrale. |
|
||||
| Aire de jeux | Bac à sable bordé de bouleau, portique de balançoire, plateforme de jeu avec marches et garde-corps, bancs et éclairage. |
|
||||
| Place | Sol organisé en couronnes de diorite, granit et tuf ; fontaine sèche à coupe basse et piédestal sculpté. |
|
||||
| Mémorial | Sol minéral à lignes de tuf, marches autour du socle, colonne mêlant tuf et briques de pierre sculptées. |
|
||||
| Marché | Croisement piéton, quatre étals avec pieds ciselés, poteaux de chêne et couvertures alternant dalles de chêne et de bouleau. |
|
||||
| Parking | Rampe et stationnements conservés ; piliers d’andésite, assises ciselées, bandeau en tuf, grilles de ventilation et lampes de plafond. Il reste enterré seulement lorsque le sol est certifié. |
|
||||
| Piscine extérieure | Bassin et marches immergées conservés, margelles de diorite, chaises longues et auvent de bouleau près des bacs de douche. |
|
||||
| Excavation | Escalier central vers la fosse, barrières, piles sculptées, poutre sur appuis, établi et meule ; le chantier reste limité à la roche certifiée. |
|
||||
|
||||
## Sous la ville et réseaux oubliés
|
||||
|
||||
| Ensemble | Traitement architectural |
|
||||
| --- | --- |
|
||||
| Grande piscine couverte | Bassin, escaliers et colonnade conservés ; parois de calcite, bandeaux de diorite, chapiteaux ciselés et nervures hautes répétées. |
|
||||
| Parkings souterrains | Plateformes et rampes conservées ; fondations maçonnées et bandeaux de tuf ponctués de motifs ciselés. |
|
||||
| Grande salle sèche | Boiseries de chêne, assises de tuf, murs clairs, corniches et bancs de chêne ; le socle central reste sans machine de progression. |
|
||||
| Égouts et sorties d’eau | Briques de tuf, motifs ciselés et corniches hautes ; les canaux, passerelles et écoulements existants sont conservés. |
|
||||
| Passages et accès | Liserés de granit ou de tuf, seuils et accès verticaux conservés. |
|
||||
| Mines, ateliers et réserves oubliés | Poutres de chêne, tuf, sols bordés de granit, établi et outillage vanilla ; rails, réserves et plateforme de travaux interrompus. |
|
||||
| Archives et salles étranges | Parquets de bouleau, boiseries, rayonnages ; ailleurs, carrelages de calcite et diorite, colonnes et niches répétées. |
|
||||
| Salles techniques oubliées | Nervures minérales, corniches et encadrements ciselés autour des seuils ; pompes figurées, grilles et postes de commande vanilla. |
|
||||
|
||||
## Matériaux et raccords
|
||||
|
||||
Les fondations des nouveaux bâtiments et chemins sont en **briques de pierre**.
|
||||
La stone et la smooth stone restent présentes ; l’andésite, la diorite et le
|
||||
granit différencient les sols et les usages. Le tuf existe brut, poli, en
|
||||
briques et sous ses deux formes ciselées. Les dalles, escaliers inversés,
|
||||
murets, grilles et trappes donnent de l’épaisseur aux corniches, aux appuis de
|
||||
fenêtre et aux équipements. Le chêne et le bouleau apparaissent comme poteaux,
|
||||
boiseries, parquets, charpentes et couvertures selon les lieux.
|
||||
|
||||
Le rendu 20 calcule les raccords des vitres, barreaux, clôtures, murets et
|
||||
angles d’escaliers à partir de tous les blocs de la pièce, après sa rotation
|
||||
et avant son découpage entre chunks. Une fenêtre peut ainsi rejoindre son
|
||||
véritable encadrement, même lorsque le chargement découpe le bâtiment.
|
||||
|
||||
Les smokes des bâtiments de ville et des espaces extérieurs passent, notamment
|
||||
pour les accès, les sanitaires, les lits, les lampes, les rampes et les bassins.
|
||||
Les essais natifs de création et de réouverture de la ville et des infrastructures
|
||||
passent ; leurs résultats et les contrôles de compatibilité sont suivis dans [les essais alpha.20](testing-alpha20.md).
|
||||
|
||||
Aucune caméra, mailbox, machine d’expansion ou production électrique dédiée
|
||||
n’est ajoutée par cette passe. Les meubles utilisables restent les blocs
|
||||
vanilla, et les butins de découverte restent partagés et non renouvelés.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Alpha.21 — palettes et couvertures
|
||||
|
||||
La ville revient à une base commune : pierre lisse, pierre, briques de pierre,
|
||||
andésite et chêne. Les façades, seuils, corniches et verrières gardent leurs
|
||||
formes ; les pièces conservent leurs fonctions et leur circulation. La serre
|
||||
emploie le bouleau comme bois propre. Le tuf accompagne les routes et les
|
||||
parties souterraines.
|
||||
|
||||

|
||||
|
||||
Ces vues sont calculées depuis les blocs produits par les renderers21 et les
|
||||
modèles/textures de Minecraft 26.3-pre-2. Elles montrent des variantes choisies,
|
||||
sans terrain, éclairage ni végétation de la partie. Ce ne sont pas des captures
|
||||
du client et elles ne prouvent pas les raccords avec le relief.
|
||||
|
||||
Les couvertures de l’atelier, du dépôt, de la serre, du relais radio et du refuge
|
||||
reposent sur des murs ou charpentes. L’ancien plafond opaque sous leur nouvelle
|
||||
couverture est retiré. Les vérifications contrôlent aussi les petits appuis des
|
||||
excavations, piscines, rampes de garage et ornements.
|
||||
|
||||

|
||||
|
||||
La seconde planche coupe volontairement les murs pour voir les dessous des toits.
|
||||
Les détails de validation et la graine d’essai sont dans [le protocole](testing-alpha21.md).
|
||||
+384
@@ -453,6 +453,390 @@ dans [Laboratoire alpha.12](alpha12-laboratory.md).
|
||||
Ce ticket ne clôture pas par avance les océans, toute la palette de structures
|
||||
ou la progression collective prévus par WG-04 à WG-07 et la vision.
|
||||
|
||||
### WG-14 — Distance et recherche d’un emplacement libre — alpha.12.1
|
||||
|
||||
**Branche :** `codex/expansion-free-space`.
|
||||
|
||||
**Problème :** les huit premiers emplacements peuvent contenir du vide déjà
|
||||
sauvegardé. L’alpha.12 refuse alors toutes les directions au lieu de chercher
|
||||
plus loin, et ne propose aucun paramètre de distance.
|
||||
|
||||
**Contrat et résultat attendu :**
|
||||
|
||||
- Distance minimale facultative en fin de `quick`, `create` et `preview`, en
|
||||
blocs entre les centres du parent et de l’enfant ; 0 ou omission = automatique.
|
||||
- Rechercher au plus 32 positions sur le même rayon en dépassant les zones
|
||||
occupées ; conserver le budget global de lecture de 30 secondes.
|
||||
- Seule une occupation certaine autorise un nouvel essai. Une erreur disque,
|
||||
un timeout ou un terrain sans sol ne doivent pas devenir un nouveau tirage.
|
||||
- Préserver les anciennes commandes, les identifiants, la graine de chaque île,
|
||||
les descripteurs de génération 12 et le journal v1. La correction s’applique
|
||||
aux mondes laboratoire alpha.12 sans conversion. Les régions existantes
|
||||
sont inchangées ; seules de nouvelles réservations peuvent être ajoutées.
|
||||
- Vérifier les huit premières directions occupées, la création plus loin,
|
||||
la distance demandée depuis une île fille et les chunks sauvegardés après
|
||||
redémarrage. Fournir les retours FR/EN et la distribution packwiz/MRpack.
|
||||
|
||||
**État :** correction implémentée, smokes et scénarios moteur de création/rechargement
|
||||
réussis. `check build assemblePack` et douze tests historiques réussis.
|
||||
Release, MRpack et canal packwiz publiés ; même instance Prism synchronisée
|
||||
deux fois, avec 466 fichiers personnels et réglages suivis conservés.
|
||||
|
||||
### WG-15 — Unifier Sanctuary et ses options — alpha.13
|
||||
|
||||
**Branche :** `codex/unified-worldgen`.
|
||||
|
||||
- Un seul type public Sanctuary, bouton Personnaliser avec Petit (512), Moyen
|
||||
(724, défaut), Grand (1 024). Choix enregistré dans le générateur ; Annuler
|
||||
conserve les réglages. Les types vanilla restent disponibles.
|
||||
- Réunir le relief, les forêts, minerais, rivières, bassins, terrasses et lave
|
||||
de Sanctuary avec les cavités vanilla luxuriantes, à spéléothèmes et soufrées
|
||||
du laboratoire, en protégeant les volumes hydrologiques pendant la décoration.
|
||||
- Les outils d’expansion alpha.12.1 fonctionnent dans les nouveaux mondes unifiés.
|
||||
Même contrat opérateur, réservation de zones nouvelles et refus du vide exploré.
|
||||
- Nouveau codec et origine génération 13 ; les anciens codecs et journaux restent
|
||||
chargeables. Aucun monde personnel n’est ouvert ou converti, aucun chunk régénéré.
|
||||
- Vérifier les trois tailles, cavités réellement décorées et fluides après ticks,
|
||||
persistance/rechargement, UI client, build et distribution dans la même instance.
|
||||
|
||||
**État :** implémenté et vérifié : bouton natif, paramètres sur disque, huit tests
|
||||
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. 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.
|
||||
|
||||
### WG-18 — Lost City, ville compacte et réseaux souterrains — alpha.15
|
||||
|
||||
**Branche :** `codex/lost-city-interiors`.
|
||||
|
||||
**Contrat :** reprendre les bâtiments avec des pièces aménagées et une
|
||||
circulation lisible, puis rendre les équipements souterrains et franchissements
|
||||
réellement découvrables dans une petite ville dense et reliée.
|
||||
|
||||
- Premier chantier : plans intérieurs propres aux appartements, hôtels,
|
||||
bibliothèques, restaurants, casernes et halls ; seuil, portes, vestibule,
|
||||
couloirs, escaliers, sols, plafonds, chambres et détails d'usage.
|
||||
- Deuxième chantier en parallèle : égouts procéduraux, pool rooms et garages
|
||||
reliés par des passages, avec accès depuis la surface. Les portions exposées
|
||||
au vide exigent des chaînes ou des pylônes ancrés dans la roche, de longueur
|
||||
bornée. Préserver les eaux naturelles et contenir les bassins construits.
|
||||
- Troisième chantier : une grille urbaine compacte, avec lots, rues et carrefours
|
||||
jointifs. Les lots sans accès au réseau sont refusés ; les terrasses et
|
||||
dégagements restent dans l’emprise urbaine certifiée. Les ponts et viaducs
|
||||
prolongent ce réseau. Aucun équipement ne doit flotter sans appui.
|
||||
- Retour de l’essai anticipé : corriger les charnières, les dossiers des sièges
|
||||
et les lanternes ; contrôler les directions après rotation et leur appui sur
|
||||
des blocs complets. Une grande salle sèche accessible dans les tunnels prépare
|
||||
la future machine d’expansion, sans l’activer dans cet incrément.
|
||||
- Le biome `sanctuary:lost_city` est conservé : il est effectivement utilisé
|
||||
pour les parcelles urbaines, la décoration et les apparitions naturelles.
|
||||
- Nouvelle génération **15**, codec `sanctuary:island_v15`, journal propre
|
||||
`data/sanctuary-world-v15/expansions.json`. Les mondes 12/13/14 conservent leurs
|
||||
codecs, plans et rendus historiques, y compris les chunks encore non générés.
|
||||
Les nouvelles pièces ont un identifiant et des paramètres persistants propres.
|
||||
Aucun monde personnel n'est ouvert, converti ou régénéré pour cette livraison.
|
||||
- La reprise certifiée du vide conserve le contrat WG-17 dans le journal15,
|
||||
avec une identité de génération stricte ; aucun certificat14 n'autorise15.
|
||||
- Vérifier parcours, portes et lits, géométrie/supports, eau après ticks, témoins
|
||||
natifs des équipements, biomes et persistance après réouverture. Exécuter
|
||||
`check build assemblePack`, publier un artefact immuable, avancer packwiz et
|
||||
synchroniser deux fois la même instance Prism avec conservation des réglages.
|
||||
|
||||
**État : livré et vérifié.** Les tests détaillés et les scénarios natifs de
|
||||
création/réouverture passent, avec 76 pièces conservées. `check build assemblePack`
|
||||
final passe. Release, canal packwiz et même instance Prism à jour ; deux
|
||||
synchronisations réussies et 767 fichiers personnels/réglages suivis conservés.
|
||||
Voir [les preuves](testing-alpha15.md) et [le reçu](packwiz.md).
|
||||
|
||||
### WG-19 — Sanctuary habitée avant nous — alpha.16
|
||||
|
||||
**Branche :** `codex/sanctuary-ruins-alpha16`.
|
||||
|
||||
**Contrat :** conserver l’alpha.15 publiée et corriger les ruines pour que
|
||||
Sanctuary Island possède un quartier identifiable et des infrastructures
|
||||
profondes qui donnent l’impression d’une occupation antérieure.
|
||||
|
||||
- Une zone de ville est obligatoire sur la nouvelle île initiale ; les
|
||||
continents restent soumis à leur profil et au terrain. Garder une ville
|
||||
compacte, ses rues continues et la salle souterraine existante.
|
||||
- Remplacer les minuscules bassins des pool rooms par de grandes piscines
|
||||
souterraines, avec volumes lisibles, allées et accès découvrables.
|
||||
- Prolonger des égouts jusqu’à de vraies ouvertures extérieures, avec sources
|
||||
et écoulements natifs contrôlés ; les fuites vers le vide sont souhaitées.
|
||||
- Ajouter sous la ville des plateformes de parking profondes, des ouvertures
|
||||
et des circulations en descente. Les volumes, supports et coûts de génération
|
||||
restent bornés ; ne pas fabriquer une masse cylindrique sous l’île.
|
||||
- Varier les plans et usages entre étages ; sanitaires clos et raccordés aux
|
||||
cloisons, lits adossés à un mur, orientations et passages cohérents.
|
||||
- Nouvelle génération16, codecs et pièces propres : les mondes publiés jusqu’à15
|
||||
gardent leurs plans, rendus, journaux et chunks futurs. Aucun monde personnel
|
||||
n’est converti ou régénéré. Tests dans de nouveaux mondes de développement.
|
||||
- Le réacteur, les nouvelles familles de donjons, les reliques et leurs règles
|
||||
de butin restent à concevoir par questions à partir de la vision. Ne pas
|
||||
implémenter le réacteur ni activer la progression dans cet incrément.
|
||||
- Valider la présence et l’accès des piscines, les exutoires après ticks réels,
|
||||
les parcours, la variation des étages, la persistance et la compatibilité15.
|
||||
Exécuter `check build assemblePack`, publier l’alpha.16 et synchroniser la même
|
||||
instance Prism selon le canal packwiz et ses sauvegardes ciblées.
|
||||
|
||||
**État : livré et vérifié.** Création et
|
||||
réouverture natives réussies, 71 pièces conservées, piscine de 50 × 25 et
|
||||
parking à huit niveaux démontrés. Les six reliefs contrôlés ont une ville ; les
|
||||
équipements restent conditionnels. `check build assemblePack` passe et la
|
||||
compatibilité15 conserve les empreintes publiées. Voir [les preuves](testing-alpha16.md).
|
||||
Release et canal packwiz publiés ; même instance Prism synchronisée deux fois,
|
||||
avec les 767 fichiers personnels et réglages suivis conservés. Voir [le reçu](packwiz.md).
|
||||
Les choix des futurs réseaux et donjons sont consignés dans la vision.
|
||||
|
||||
### WG-20 — Réseaux oubliés, prototype réversible — alpha.17
|
||||
|
||||
**Branche :** `codex/structures-experiment-alpha17`.
|
||||
|
||||
**Contrat :** rendre explorables les trois familles validées : mines et ateliers,
|
||||
lieux étranges physiques dans l’île, infrastructures abandonnées. Les traces
|
||||
privilégient des installations utilisables et des chantiers interrompus.
|
||||
|
||||
- Nouveau générateur 17 et option « Structures expérimentales » dans le bouton
|
||||
natif Personnaliser. La taille et l’option sont enregistrées avec le nouveau
|
||||
monde ; aucune activation rétroactive ou suppression de structures existantes.
|
||||
- Conserver les codecs, pièces, plans et futurs chunks des générations publiées,
|
||||
dont 16. La ville 16 reste le socle de 17 ; les nouveaux réseaux disposent de
|
||||
leurs pièces natives, emprises protégées et calculs bornés propres.
|
||||
- Trois petits réseaux recherchés sur l’île initiale, avec accès de surface,
|
||||
salles, galeries et mobilier. Les continents ne reçoivent pas ce prototype.
|
||||
Les connexions entre réseaux seront évaluées après les premières implantations.
|
||||
- Ne pas implémenter de réacteur, progression, reliques ou règles de récompenses
|
||||
personnelles. Les coffres éventuels utilisent des tables vanilla différées.
|
||||
- Tester terrain/supports, connexions internes, accès, réglage sauvegardé,
|
||||
placement natif et réouverture. Livrer le pack et synchroniser l’instance
|
||||
existante selon le protocole packwiz. Donner graines et coordonnées d’essai.
|
||||
|
||||
**État : livré en alpha.17.** Création/réouverture, option désactivée,
|
||||
compatibilité 16 et `check build assemblePack` passent. Release et canal packwiz
|
||||
publiés, même instance Prism synchronisée deux fois ; les 767 fichiers personnels
|
||||
et réglages suivis sont conservés. Voir [le reçu](packwiz.md) et
|
||||
[les coordonnées d’essai](testing-alpha17.md).
|
||||
Retour arrière par branche/artefact et option désactivée
|
||||
pour un autre nouveau monde. Aucun retour binaire vers 16 dans une sauvegarde 17
|
||||
n’est déclaré compatible ; les mondes d’essai restent séparés.
|
||||
|
||||
### WG-21 — Audit des structures et des anciennes circulations
|
||||
|
||||
**Branche :** `codex/structures-audit`.
|
||||
|
||||
**Demande :** avant de poursuivre l’implémentation, proposer des structures
|
||||
contemporaines, des axes abandonnés à l’échelle de l’île et leurs liens possibles
|
||||
avec l’expansion, la mailbox et les objets utiles cachés dans des lieux secrets.
|
||||
|
||||
**État : audit documenté, propositions à discuter.** Le
|
||||
[catalogue et schéma de relations](structures-audit.md) distinguent les trois
|
||||
petits ensembles actuels des futurs tracés entre destinations, proposent des
|
||||
voies de fret dégradées et des usages pour les bâtiments. Les outils vanilla
|
||||
servent d’idées de butin ; la caméra et les systèmes restent pour la bêta.
|
||||
Aucune structure, règle de butin, version binaire ou instance Prism modifiée.
|
||||
La sélection des lieux et la réalisation des axes feront l’objet du chantier
|
||||
suivant ; ce catalogue n’est pas une liste d’ajouts tous validés.
|
||||
|
||||
### WG-22 — Anciennes infrastructures et liaisons — alpha.19
|
||||
|
||||
**Branche :** `codex/heritage-worldgen-alpha19`.
|
||||
|
||||
**Demande validée :** première passe de toutes les nouvelles structures du
|
||||
catalogue WG-21 et de leurs liaisons avant la bêta, en conservant la possibilité
|
||||
de revenir à une génération sans ce chantier.
|
||||
|
||||
- Ajouter les quatorze lieux avec architectures, entrées, pièces, mobilier et
|
||||
petits butins vanilla associés à leur usage. Présence selon le terrain ;
|
||||
relever les lieux réellement générés et donner les coordonnées des témoins.
|
||||
- Planifier des voies de fret et chemins entre destinations réelles, avec
|
||||
sections abandonnées lisibles, appuis et passages vérifiés. Rechercher les
|
||||
raccords aux anciennes installations dans un budget borné.
|
||||
- Garder une majorité naturelle à l’île ; conserver le terrain, l’hydrologie,
|
||||
la ville et les réseaux existants comme base. Première île uniquement.
|
||||
- Nouveau codec 19, option expérimentale sauvegardée à la création du monde.
|
||||
Option désactivée : socle urbain seul. Les mondes 17 et antérieurs gardent
|
||||
leur génération, leurs pièces et leurs futurs chunks. Aucune conversion,
|
||||
suppression de structures ou rétrogradation d’une sauvegarde 19.
|
||||
- Les bâtiments préparent les futurs services ; mailbox, deposit boxes,
|
||||
caméra, réacteur, progression et récompenses personnelles restent pour la
|
||||
bêta. Les coffres de cette passe sont partagés et utilisent du butin vanilla.
|
||||
- Vérifier créations/réouvertures, option désactivée, anciens mondes, supports,
|
||||
circulations et vrais placements. `check build assemblePack`, artefacts,
|
||||
publication packwiz et mise à jour de la même instance Prism.
|
||||
|
||||
**État : livré en alpha.19 ; code, artefacts et synchronisation Prism vérifiés.** Voir
|
||||
[les essais natifs et la carte des lieux](testing-alpha19.md). Version 19 demandée
|
||||
explicitement ; aucune version 18 publiée
|
||||
dans ce dépôt à l’ouverture du chantier. Le catalogue est maintenant accepté
|
||||
pour une première passe, sans imposer sa présence exhaustive sur chaque graine.
|
||||
|
||||
### WG-23 — Identité architecturale et blocs détaillés — alpha.20
|
||||
|
||||
**Branche :** `codex/architecture-alpha20`.
|
||||
|
||||
**Demande :** donner aux bâtiments une identité contemporaine lisible selon
|
||||
leur fonction, corriger les vitres isolées et détailler formes, façades et
|
||||
circulations. Employer du tuf, ses briques et variantes ciselées, du chêne,
|
||||
du bouleau, du granit, de la diorite et de l’andésite ; conserver les fondations
|
||||
en briques de pierre. Ajouter des blocs sculptés et du relief architectural.
|
||||
|
||||
- Reprendre les quatorze lieux expérimentaux, les bâtiments de Lost City,
|
||||
leurs espaces extérieurs et leurs sous-sols ; palettes par usage, volumes
|
||||
en retrait, biseaux, baies, corniches, liserés et mobilier adapté.
|
||||
- Résoudre les connexions des vitres, grilles, clôtures et murs ainsi que les
|
||||
angles d’escaliers depuis la géométrie complète, avant le découpage par chunk.
|
||||
- Dessiner les bordures de routes avec les briques de tuf et le tuf ciselé,
|
||||
en conservant la circulation et les tracés sauvegardés.
|
||||
- Garder les plans de placement et les emprises certifiées ; génération 20
|
||||
et types de pièces distincts. Les mondes 19 et antérieurs gardent leurs
|
||||
rendus, y compris dans les futurs chunks. Aucune rénovation rétroactive.
|
||||
- Garder le bouton natif Personnaliser, les trois tailles et l’option des
|
||||
structures expérimentales pour les nouveaux mondes.
|
||||
- Vérifier les états réels des blocs, les accès, les supports, les coffres,
|
||||
la création/réouverture et la compatibilité avec la version 19 publiée.
|
||||
Livrer le pack 20 par le canal stable et synchroniser la même instance Prism.
|
||||
|
||||
**État : livré en alpha.20 ; code, artefacts et même instance Prism vérifiés.** Voir
|
||||
[le contrat et les essais alpha.20](testing-alpha20.md).
|
||||
|
||||
### WG-24 — Palettes sobres, toits portés et galeries — alpha.21
|
||||
|
||||
**Branche :** `codex/palettes-reseaux-alpha21`.
|
||||
|
||||
Reprendre les extérieurs sans changer les plans intérieurs approuvés : smooth
|
||||
stone, stone brick, stone, andésite et chêne dominants en ville ; un bois cohérent
|
||||
par bâtiment unique et tuf surtout au sol et sous terre. Corriger les toits
|
||||
suspendus et les anciens plafonds superposés, avec appuis vérifiables.
|
||||
|
||||
Étendre la ville par au plus deux petits quartiers séparés de terrain naturel,
|
||||
reliés par des chemins praticables ; rapprocher les destinations patrimoniales
|
||||
par un graphe de chemins. Ajouter des galeries industrielles abandonnées :
|
||||
chaussée de cinq blocs, deux trottoirs, quelques grandes salles et deux accès
|
||||
par escaliers depuis la surface, en respectant les eaux et structures existantes.
|
||||
|
||||
Ajouter des fronts rocheux et de petits filons intacts dans les galeries et
|
||||
leurs salles de maintenance : ressources vanilla réellement minables, sans
|
||||
régénération de butin ni remplacement des blocs déjà extraits. Fournir un MRpack
|
||||
pour l’essai séparé demandé.
|
||||
|
||||
Assouplir les refus de placement : admettre les pentes, des volumes partiellement
|
||||
enfouis et des appuis adaptés ; rechercher des détours et prioriser les lieux
|
||||
isolés pour les chemins. Garder une ville fragmentée sans socle géant.
|
||||
|
||||
Nouvelle génération21 réservée aux nouveaux mondes ; tous les codecs et rendus
|
||||
20 et antérieurs restent disponibles. Tailles512/724/1024, Moyen par défaut ;
|
||||
option expérimentale enregistrée. Aucun monde personnel modifié. Vérifier les
|
||||
pièces natives, accès, connexions, supports, création/réouverture, ancien monde20,
|
||||
puis livrer le pack stable21 et synchroniser la même instance Prism.
|
||||
|
||||
**État : livré en alpha.21 ; MRpack, release et canal packwiz publiés, même instance Prism synchronisée deux fois. Tests, sauvegardes et limites documentés dans `docs/testing-alpha21.md`.**
|
||||
|
||||
### WG-25 — Placement sélectif et archéologie — alpha.22
|
||||
|
||||
**Branche :** `codex/placement-selectif-alpha22`.
|
||||
|
||||
Annule pour les nouveaux mondes l’assouplissement de WG-24. Chercher un terrain
|
||||
vraiment adapté et accepter l’absence d’une structure plutôt que de forcer son
|
||||
implantation. La ville ne reçoit plus de réservation artificielle de secours.
|
||||
Bâtiments et chemins limitent terrassement et remblai. Exception explicite :
|
||||
la grande traversée et la salle d’expansion sont des éléments de base, à placer
|
||||
en priorité indépendamment de la ville ; les égouts peuvent être ailleurs.
|
||||
Les galeries gardent leur génie civil et la salle reçoit un accès autonome.
|
||||
Les recherches restent bornées.
|
||||
|
||||
Conserver les palettes, les intérieurs, les filons et le rendu21. Nouveau codec22
|
||||
pour les nouveaux mondes, sans migration ni retouche des mondes précédents.
|
||||
Vérifier l’admission sur les bons terrains, les refus sur les mauvais, la création
|
||||
native, la réouverture et la compatibilité21. Livrer le MRpack et mettre à jour
|
||||
le canal et la même instance Prism en conservant les données personnelles.
|
||||
|
||||
Inscrire dans la vision « Minecraft comme archéologie d’un autre Minecraft » :
|
||||
chaque ruine garde la trace d’une solution ancienne. Les neuf personnages sont
|
||||
ceux nommés dans la vision ; la découverte et les rôles restent à définir.
|
||||
Ce cadre narratif ne constitue pas une nouvelle mécanique implémentée.
|
||||
|
||||
**État : livré en alpha.22 ; MRpack, release et canal packwiz publiés, même instance Prism synchronisée deux fois.** Voir [les essais22](testing-alpha22.md).
|
||||
|
||||
## 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,408 @@
|
||||
{
|
||||
"version": "0.1.0-alpha.12.1",
|
||||
"date": "2026-09-09",
|
||||
"minecraft": "26.3-pre-2",
|
||||
"java": 25,
|
||||
"generation": 12,
|
||||
"journal_schema": 1,
|
||||
"base_commit": "40a86588b5f83fbc7bd2728eb9b439baa1fc9ba5",
|
||||
"scope": "Development-world engine tests; no personal world, live player teleportation or client rendering. Process timings are not performance benchmarks.",
|
||||
"normalization": "world_directory fields replaced with <development_game_test_world>; other diagnostic fields unchanged.",
|
||||
"build": {
|
||||
"command": "./gradlew check build assemblePack",
|
||||
"passed": true,
|
||||
"required_historical_engine_tests": 12,
|
||||
"seed": 0,
|
||||
"profile": 10,
|
||||
"log_sha256": "72f2173023e5407391d7e0e528012fcda9a314c981acfea462918c36ffa7bf90"
|
||||
},
|
||||
"artifact": {
|
||||
"path": "mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.12.1.jar",
|
||||
"sha256": "c9df4da956cfa54581b3fde21de006bf8ac3ec025b8dd0daa5d060bcb70aabd4"
|
||||
},
|
||||
"first_process_diagnostic_sha256": "3ef35faf09130409135879ad3a4be4ceba4f5644a57ae9cdcec4cd4d6e84c06a",
|
||||
"reload_diagnostic_sha256": "401f2e6739de9efd3668aa27c5ef45a6e81e1acfde44442bf101e5ac6397074a",
|
||||
"fresh_world": {
|
||||
"seed": 42,
|
||||
"process_id": 5743,
|
||||
"generator_version": 12,
|
||||
"islands": [
|
||||
{
|
||||
"centre": [
|
||||
0,
|
||||
-1600
|
||||
],
|
||||
"diameter": 64,
|
||||
"upper_biome": "minecraft:jungle",
|
||||
"climate": "tropical",
|
||||
"landing": [
|
||||
-4,
|
||||
171,
|
||||
-1604
|
||||
],
|
||||
"landing_biome": "minecraft:jungle",
|
||||
"parent": "sanctuary",
|
||||
"id": "alpha"
|
||||
},
|
||||
{
|
||||
"centre": [
|
||||
2048,
|
||||
-1600
|
||||
],
|
||||
"diameter": 64,
|
||||
"upper_biome": "minecraft:desert",
|
||||
"climate": "arid",
|
||||
"landing": [
|
||||
2048,
|
||||
198,
|
||||
-1600
|
||||
],
|
||||
"landing_biome": "minecraft:desert",
|
||||
"parent": "alpha",
|
||||
"id": "beta"
|
||||
}
|
||||
],
|
||||
"scope": "Fresh laboratory world; real FULL chunks and native structure loot, asynchronous activation, branching, journal re-read and loaded void admission refusal. Journal re-read is not a process restart. The parent block witness represents a player edit; no player teleport, multiplayer or client rendering is simulated.",
|
||||
"registered_command_permission_checks": true,
|
||||
"parent_witness": [
|
||||
-16,
|
||||
248,
|
||||
-16
|
||||
],
|
||||
"root_landing": [
|
||||
-16,
|
||||
242,
|
||||
-16
|
||||
],
|
||||
"initial_occupied_directions": 8,
|
||||
"journal_reloaded_regions": 3,
|
||||
"native_structure": {
|
||||
"container": [
|
||||
28,
|
||||
247,
|
||||
73
|
||||
],
|
||||
"loot_table": "minecraft:barrels/abandoned_camp_barrel",
|
||||
"full_chunks": 12,
|
||||
"placed_block": "Block{minecraft:barrel}[facing=south,open=false]",
|
||||
"structure": "minecraft:abandoned_camp_forest",
|
||||
"mode": "natural candidate accepted by production ExpansionStructures, native decoration"
|
||||
},
|
||||
"restart_snapshot": {
|
||||
"world_directory": "<development_game_test_world>",
|
||||
"gold_witness": [
|
||||
2048,
|
||||
204,
|
||||
-1600
|
||||
],
|
||||
"journal_sha256": "21136a5e8f1857b10ef5673281c8a3a4568ae032641995c206ed2031e112aa5b",
|
||||
"islands": [
|
||||
{
|
||||
"id": "sanctuary",
|
||||
"parent": "sanctuary",
|
||||
"direction": "north",
|
||||
"climate": "temperate",
|
||||
"diameter": 512,
|
||||
"center_x": 0,
|
||||
"center_z": 0,
|
||||
"seed": 42,
|
||||
"relief": "natural",
|
||||
"status": "ready",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "alpha",
|
||||
"parent": "sanctuary",
|
||||
"direction": "north",
|
||||
"climate": "tropical",
|
||||
"diameter": 64,
|
||||
"center_x": 0,
|
||||
"center_z": -1600,
|
||||
"seed": 390175152143173586,
|
||||
"relief": "natural",
|
||||
"status": "ready",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "beta",
|
||||
"parent": "alpha",
|
||||
"direction": "east",
|
||||
"climate": "arid",
|
||||
"diameter": 64,
|
||||
"center_x": 2048,
|
||||
"center_z": -1600,
|
||||
"seed": 6281303726624369227,
|
||||
"relief": "natural",
|
||||
"status": "ready",
|
||||
"generation": 12
|
||||
}
|
||||
],
|
||||
"generator_codec": {
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"type": "sanctuary:expansion_lab"
|
||||
},
|
||||
"blocked_sites": [
|
||||
{
|
||||
"id": "blocked_north",
|
||||
"parent": "sanctuary",
|
||||
"direction": "north",
|
||||
"climate": "boreal",
|
||||
"diameter": 64,
|
||||
"center_x": 0,
|
||||
"center_z": -800,
|
||||
"seed": 6170446579001265223,
|
||||
"relief": "natural",
|
||||
"status": "reserved",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "blocked_northeast",
|
||||
"parent": "sanctuary",
|
||||
"direction": "northeast",
|
||||
"climate": "boreal",
|
||||
"diameter": 64,
|
||||
"center_x": 800,
|
||||
"center_z": -800,
|
||||
"seed": 2312608842580142629,
|
||||
"relief": "natural",
|
||||
"status": "reserved",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "blocked_east",
|
||||
"parent": "sanctuary",
|
||||
"direction": "east",
|
||||
"climate": "boreal",
|
||||
"diameter": 64,
|
||||
"center_x": 800,
|
||||
"center_z": 0,
|
||||
"seed": -5785143794938635053,
|
||||
"relief": "natural",
|
||||
"status": "reserved",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "blocked_southeast",
|
||||
"parent": "sanctuary",
|
||||
"direction": "southeast",
|
||||
"climate": "boreal",
|
||||
"diameter": 64,
|
||||
"center_x": 800,
|
||||
"center_z": 800,
|
||||
"seed": 6543995692374257629,
|
||||
"relief": "natural",
|
||||
"status": "reserved",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "blocked_south",
|
||||
"parent": "sanctuary",
|
||||
"direction": "south",
|
||||
"climate": "boreal",
|
||||
"diameter": 64,
|
||||
"center_x": 0,
|
||||
"center_z": 800,
|
||||
"seed": 9109857537375437182,
|
||||
"relief": "natural",
|
||||
"status": "reserved",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "blocked_southwest",
|
||||
"parent": "sanctuary",
|
||||
"direction": "southwest",
|
||||
"climate": "boreal",
|
||||
"diameter": 64,
|
||||
"center_x": -800,
|
||||
"center_z": 800,
|
||||
"seed": 7977138842745944844,
|
||||
"relief": "natural",
|
||||
"status": "reserved",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "blocked_west",
|
||||
"parent": "sanctuary",
|
||||
"direction": "west",
|
||||
"climate": "boreal",
|
||||
"diameter": 64,
|
||||
"center_x": -800,
|
||||
"center_z": 0,
|
||||
"seed": 8731833603567083481,
|
||||
"relief": "natural",
|
||||
"status": "reserved",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "blocked_northwest",
|
||||
"parent": "sanctuary",
|
||||
"direction": "northwest",
|
||||
"climate": "boreal",
|
||||
"diameter": 64,
|
||||
"center_x": -800,
|
||||
"center_z": -800,
|
||||
"seed": 6470159394492078415,
|
||||
"relief": "natural",
|
||||
"status": "reserved",
|
||||
"generation": 12
|
||||
}
|
||||
],
|
||||
"samples": [
|
||||
{
|
||||
"resolved_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
|
||||
"base_column_sha256": "646af3da3a327ab15d229b7fe2c328cc10eae7fce31d6b24c60f3be643bc1095",
|
||||
"base_height": 242,
|
||||
"stored_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
|
||||
"point": [
|
||||
-16,
|
||||
242,
|
||||
-16
|
||||
],
|
||||
"id": "sanctuary",
|
||||
"stored_column_sha256": "a291039b2ed502c828b4f1ec97fe111f49f62e69b5589e05116d9b1931f461a4"
|
||||
},
|
||||
{
|
||||
"resolved_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
|
||||
"base_column_sha256": "4ec4238d357426e34a1decfd817fc96c6b596758e98ffc1d98906a1db8d5b707",
|
||||
"base_height": 204,
|
||||
"stored_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
|
||||
"point": [
|
||||
-4,
|
||||
171,
|
||||
-1604
|
||||
],
|
||||
"id": "alpha",
|
||||
"stored_column_sha256": "9866d275b53719e434d20996e2c061f4342e431c41d671216781d1f00c204ff1"
|
||||
},
|
||||
{
|
||||
"resolved_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
|
||||
"base_column_sha256": "4df3dd476f41a8c6b0e0273b9ebe5cbba4dec6cb22001c553c15d4f6b255c555",
|
||||
"base_height": 198,
|
||||
"stored_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
|
||||
"point": [
|
||||
2048,
|
||||
198,
|
||||
-1600
|
||||
],
|
||||
"id": "beta",
|
||||
"stored_column_sha256": "246bc9b1827effb58159dbc540c655f2d218079a49263d19ac786cf116557093"
|
||||
}
|
||||
],
|
||||
"world_flushed_before_shutdown": true
|
||||
},
|
||||
"native_structure_starts_seen": 1,
|
||||
"structure_start_chunks_inspected": 106,
|
||||
"ticks": 1470,
|
||||
"elapsed_ms": 41563.165541,
|
||||
"passed": true
|
||||
},
|
||||
"reloaded_world": {
|
||||
"seed": 42,
|
||||
"process_id": 5830,
|
||||
"scope": "A second Java process opens the same saved development chunks and expansion journal, and verifies a gold block written and flushed before the first shutdown. GameTestServer reconstructs its world settings from the preset; the prior generator codec is decoded separately here and its base columns are compared. This is not a normal-client world-selection test or a multiplayer/teleportation test.",
|
||||
"saved_only_collision_refusals": 8,
|
||||
"previous_process_id": 5743,
|
||||
"world_directory": "<development_game_test_world>",
|
||||
"gold_witness": [
|
||||
2048,
|
||||
204,
|
||||
-1600
|
||||
],
|
||||
"samples": [
|
||||
{
|
||||
"stored_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
|
||||
"point": [
|
||||
-16,
|
||||
242,
|
||||
-16
|
||||
],
|
||||
"id": "sanctuary",
|
||||
"stored_column_sha256": "a291039b2ed502c828b4f1ec97fe111f49f62e69b5589e05116d9b1931f461a4",
|
||||
"resolved_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
|
||||
"base_column_sha256": "646af3da3a327ab15d229b7fe2c328cc10eae7fce31d6b24c60f3be643bc1095",
|
||||
"base_height": 242
|
||||
},
|
||||
{
|
||||
"stored_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
|
||||
"point": [
|
||||
-4,
|
||||
171,
|
||||
-1604
|
||||
],
|
||||
"id": "alpha",
|
||||
"stored_column_sha256": "9866d275b53719e434d20996e2c061f4342e431c41d671216781d1f00c204ff1",
|
||||
"resolved_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
|
||||
"base_column_sha256": "4ec4238d357426e34a1decfd817fc96c6b596758e98ffc1d98906a1db8d5b707",
|
||||
"base_height": 204
|
||||
},
|
||||
{
|
||||
"stored_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
|
||||
"point": [
|
||||
2048,
|
||||
198,
|
||||
-1600
|
||||
],
|
||||
"id": "beta",
|
||||
"stored_column_sha256": "246bc9b1827effb58159dbc540c655f2d218079a49263d19ac786cf116557093",
|
||||
"resolved_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
|
||||
"base_column_sha256": "4df3dd476f41a8c6b0e0273b9ebe5cbba4dec6cb22001c553c15d4f6b255c555",
|
||||
"base_height": 198
|
||||
}
|
||||
],
|
||||
"regions": 3,
|
||||
"native_container": [
|
||||
28,
|
||||
247,
|
||||
73
|
||||
],
|
||||
"saved_generator_codec_decoded": true,
|
||||
"after_reload_expansion": [
|
||||
0,
|
||||
1600
|
||||
],
|
||||
"after_reload_landing": [
|
||||
-4,
|
||||
153,
|
||||
1596
|
||||
],
|
||||
"final_regions": 4,
|
||||
"passed": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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."
|
||||
}
|
||||
+56
-32
@@ -1,24 +1,45 @@
|
||||
# Climats, expansion et ressources
|
||||
|
||||
Ce document fixe les décisions de conception pour la suite de Sanctuary.
|
||||
L’alpha.12 fournit un laboratoire avec registre persistant et commandes opérateur,
|
||||
suivant le [contrat de laboratoire](alpha12-laboratory.md). Le déblocage par
|
||||
recherches et contributions reste à implémenter. Le relevé de ressources décrit plus bas
|
||||
est un outil de développement séparé, exécuté dans le monde jetable des GameTests.
|
||||
Ce document distingue les outils de génération et la progression collective
|
||||
encore à construire. L’alpha.13 réunit l’île de départ et
|
||||
les commandes d’expansion dans le choix public **Sanctuary**. Le
|
||||
[guide alpha.13](alpha13-worldgen.md) décrit leur utilisation. Les versions
|
||||
alpha.12/12.1 et leur [laboratoire](alpha12-laboratory.md) restent historiques.
|
||||
Le déblocage par recherches, XP et contributions n’est pas encore implémenté.
|
||||
|
||||
## Taille de l’île initiale — alpha.11
|
||||
## Taille initiale et extensions — alpha.13
|
||||
|
||||
Deux choix de départ sont proposés : **Petit** et **Grand**, Grand par défaut.
|
||||
Petit garde un diamètre nominal de 512 blocs ; Grand vise environ 724 blocs,
|
||||
soit deux fois l’aire nominale de Petit. La hauteur reste de 384 blocs. Les
|
||||
repères internes 5/10 servent au calcul de surface, sans nom de capacité affiché
|
||||
ni promesse sur le nombre de joueurs, les minerais ou l’aire constructible.
|
||||
Le bouton natif **Personnaliser** propose **Petit** (512 blocs de diamètre
|
||||
nominal), **Moyen** (724, par défaut) et **Grand** (1 024). La hauteur reste de
|
||||
384 blocs. Ce choix configure un nouveau monde ; il ne promet ni nombre de
|
||||
joueurs, ni quantité de ressources, ni aire entièrement constructible.
|
||||
|
||||
Cette taille se choisit pour un nouveau monde et ne remplace pas le futur
|
||||
déblocage collectif de continents. Grand utilise `population_10` ; les anciennes
|
||||
sauvegardes 5/20/100 conservent leurs réglages et leurs dimensions. Les formats
|
||||
20/100 sont cachés de la création. Les mesures de l’alpha.10 conservées plus bas
|
||||
restent historiques ; les contrôles alpha.11 sont dans [Validation](testing.md).
|
||||
L’île initiale reprend le relief, les forêts et l’hydrologie de Population,
|
||||
avec les poches de cavités natives issues du laboratoire. Les îles ajoutées
|
||||
conservent les tailles **64/128/256/512**, les douze profils climatiques et les
|
||||
quatre reliefs d’expansion. Leur génération ne reprend pas automatiquement
|
||||
la grande rivière et les terrasses de l’île initiale.
|
||||
|
||||
Les commandes opérateur `quick`, `create`, `preview`, `list`, `visit` et
|
||||
`resume` reprennent le fonctionnement alpha.12.1. Le paramètre final de distance
|
||||
est facultatif ; les sites occupés sont écartés en cherchant plus loin dans la
|
||||
même direction. Même un chunk de vide déjà généré est protégé. Les recherches
|
||||
collectives et les objets de déblocage ne sont pas nécessaires à ces outils.
|
||||
|
||||
La racine alpha.13 est de génération 13 ; les extensions restent de génération
|
||||
12, dans le journal `data/sanctuary-world-v13/expansions.json` du nouveau monde.
|
||||
Les anciens mondes gardent leurs codecs, leurs paramètres et leur journal.
|
||||
Les choix séparés Petit/Grand et Laboratoire sont désormais cachés à la
|
||||
création. Aucun monde existant n’est converti ou redimensionné.
|
||||
|
||||
## Tailles et mesures historiques
|
||||
|
||||
L’alpha.11 proposait Petit (512) et Grand (724), avec Grand par défaut ; ses
|
||||
repères internes 5/10 ne correspondaient pas à une capacité multijoueur promise.
|
||||
Les références `population_5/10/20/100` restent celles des sauvegardes historiques.
|
||||
Les mesures alpha.3 à alpha.11 conservées ci-dessous décrivent uniquement les
|
||||
versions et les emprises observées à leur date. Les contrôles de l’alpha.13,
|
||||
les rechargements et le benchmark Moyen sont consignés dans [Validation](testing.md).
|
||||
|
||||
## Les huit directions et les branches climatiques
|
||||
|
||||
@@ -130,12 +151,15 @@ Une graine peut donc manquer d’une ressource ; la présence de tout le parcour
|
||||
Minecraft n’est pas garantie. Les structures donnant accès à l’End et leur lien
|
||||
avec l’expansion restent à concevoir.
|
||||
|
||||
## Relevé de développement disponible
|
||||
## Relevé de développement — générateurs historiques
|
||||
|
||||
`ResourceSurveyDiagnostics`, dans `src/gametest`, lit les chunks au statut
|
||||
`FULL`, après le terrain, les matériaux et les décorations. Il ne figure pas
|
||||
dans le JAR Sanctuary distribué et n'ajoute aucune commande joueur ou admin.
|
||||
La tâche repart d'un monde GameTest neuf, jamais d'une sauvegarde personnelle.
|
||||
Depuis l’alpha.13, `runGameTest` et `check` sélectionnent la suite unifiée par
|
||||
défaut. Les commandes ci-dessous demandent explicitement une suite historique ;
|
||||
elles ne constituent pas un relevé du nouveau monde unifié.
|
||||
|
||||
Le relevé standard inspecte **neuf chunks**, soit un carré de 48×48 blocs autour
|
||||
du chunk du spawn naturel, sur toute la hauteur de la dimension. Il prépare
|
||||
@@ -146,13 +170,13 @@ n'est inclus dans ces comptes de blocs.
|
||||
|
||||
```sh
|
||||
# Contrôle standard : neuf chunks inspectés, total local uniquement.
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryResourceSurvey=sample --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryResourceSurvey=sample --console=plain
|
||||
|
||||
# Relevé de toute l'enveloppe déclarée de Sanctuary, sur un monde jetable neuf.
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=island --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=island --console=plain
|
||||
|
||||
# Désactiver seulement le relevé pour un autre diagnostic.
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryResourceSurvey=off --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryResourceSurvey=off --console=plain
|
||||
```
|
||||
|
||||
Les modes sont transmis à la propriété JVM de test
|
||||
@@ -162,7 +186,7 @@ ce dernier par défaut ; `20` et `100` servent à la compatibilité historique.
|
||||
Le mode `island` calcule l’enveloppe du profil actif, avec 16 blocs de marge de
|
||||
décoration et un halo de préparation d’un chunk. Les valeurs ci-dessous sont
|
||||
les bornes calculées lors de l’alpha.10, pas des relevés exécutés ni un nombre
|
||||
de chunks déjà validé pour le nouveau Grand :
|
||||
de chunks déjà validé pour l’ancien Grand alpha.11 :
|
||||
|
||||
| Profil | Chunks comptés par `island` | Chunks FULL préparés avec halo |
|
||||
| --- | --- | --- |
|
||||
@@ -257,16 +281,16 @@ essai client concurrent pendant environ 23 secondes. Elle décrit une machine
|
||||
chargée et cette exécution, pas une performance garantie sur d'autres machines.
|
||||
Le temps de préparation est distinct du coût du comptage par palettes.
|
||||
|
||||
## Contrat du prochain ticket d'expansion
|
||||
## Contrat de progression collective restant à construire
|
||||
|
||||
Avant toute activation de continent, enregistrer côté serveur son identifiant,
|
||||
sa direction parmi les huit, son centre, son emprise, sa graine dérivée, sa
|
||||
version de génération, son profil climatique et son état de déblocage.
|
||||
Le relevé des ressources intervient après la génération effective de sa région,
|
||||
avec une complétude explicite, indépendamment des contributions qui l'ont ouverte.
|
||||
Le registre et la réservation des alpha.12/12.1 sont repris dans l’alpha.13 :
|
||||
identifiant, parent, direction, centre, emprise, graine, génération, climat et
|
||||
statut sont enregistrés côté serveur. Les chunks existants, y compris le vide,
|
||||
sont protégés pendant la recherche d’une nouvelle région.
|
||||
|
||||
Les zones déjà explorées dans le vide peuvent contenir des chunks sauvegardés
|
||||
ou des constructions. L'activation exige donc un contrat de réservation ou de
|
||||
matérialisation qui les protège ; elle ne peut pas régénérer silencieusement
|
||||
les chunks existants. Les règles de contribution, distances, tailles,
|
||||
réservations, migrations et prix ne sont pas décidées par ce diagnostic.
|
||||
Le prochain travail de progression doit relier ces ouvertures aux recherches,
|
||||
aux contributions et à leurs coûts. Un éventuel relevé de ressources intervient
|
||||
après la génération réelle, avec une complétude explicite ; il ne choisit pas
|
||||
une graine pour remplir un stock attendu. Les prix, les objets de déblocage et
|
||||
les règles de contribution restent à concevoir. Aucun contrat de migration
|
||||
ou de régénération d’anciennes parties n’est fourni par ce diagnostic.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 306 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
+383
-2
@@ -13,11 +13,392 @@ 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.22 publiée — 10 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.22](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.22)
|
||||
est publiée depuis le commit source `d6e0c14`. Le canal stable est au commit
|
||||
`0019178`. Le JAR, le
|
||||
[MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.22/Sanctuary-0.1.0-alpha.22.mrpack)
|
||||
et le ZIP d’amorçage Prism ont été vérifiés par téléchargement public.
|
||||
SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
10d7ef32ac2e81a977ea4854503bafa29e0bc10929984b76df01d6f5f61f9056
|
||||
```
|
||||
|
||||
Les bâtiments de surface recherchent des terrains adaptés ; la grande traversée
|
||||
et la salle d’expansion restent des éléments de base, indépendants d’une ville.
|
||||
Les galeries peuvent chercher un détour et de nouveaux accès. Le hall peut
|
||||
accueillir des égouts ailleurs dans l’île. Les trois tailles natives, le cas OFF,
|
||||
la réouverture et la comparaison21 passent. `check build assemblePack` passe
|
||||
avec75 tâches et les huit GameTests généraux. Voir [les essais22](testing-alpha22.md).
|
||||
|
||||
Deux synchronisations isolées puis deux dans la même instance Prism réussissent.
|
||||
Un seul JAR Sanctuary alpha.22 est actif ; les **925 fichiers personnels et
|
||||
réglages suivis** gardent leurs hashes. Le dossier reste
|
||||
`Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ». Le JAR21 et le suivi
|
||||
packwiz sont sauvegardés sous `sanctuary-backups/before-0.1.0-alpha.22/`.
|
||||
Reçus locaux : `build/alpha22-isolated-validation.json` et
|
||||
`build/alpha22-prism-validation.json`.
|
||||
|
||||
Pour tester : **nouveau monde Sanctuary, Moyen, graine0**. Les portiques de la
|
||||
traversée sont `-170 253 -98` et `170 254 -98` ; la trappe du hall indépendant
|
||||
est `2 252 0`. `/sanctuary structures` liste les lieux. Les villes et bâtiments
|
||||
facultatifs peuvent être refusés. Les graines vérifiées ne prouvent pas la
|
||||
présence des infrastructures sur toutes les graines ; les recherches restent
|
||||
bornées. Aucun monde personnel n’a été ouvert, converti ou régénéré. Aucun
|
||||
essai client visuel ni essai Windows natif n’est revendiqué.
|
||||
|
||||
## Alpha.21 publiée — 10 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.21](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.21)
|
||||
est publiée depuis le commit source `bb63fab`. Le canal stable utilisait cette version au commit
|
||||
`8002fce`. Le JAR, le
|
||||
[MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.21/Sanctuary-0.1.0-alpha.21.mrpack)
|
||||
et le ZIP d’amorçage Prism ont été vérifiés. SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
e528e42d99f9e1ea221f780ea089b49b42a1a4e9e24e4ddd001d045183202c21
|
||||
```
|
||||
|
||||
Palettes sobres, toitures portées, quartiers reliés, placements patrimoniaux
|
||||
assouplis et premières galeries industrielles avec fronts rocheux et filons.
|
||||
Voir le [catalogue](architecture-alpha21.md) et les [essais](testing-alpha21.md).
|
||||
`check build assemblePack`, les huit GameTests généraux, les créations et
|
||||
réouvertures natives passent. Le cas expérimental OFF passe. Les fichiers
|
||||
historiques et les témoins natifs de la génération20 sont conservés.
|
||||
|
||||
Deux synchronisations isolées puis deux dans la même instance Prism réussissent.
|
||||
Un seul JAR Sanctuary alpha.21 est actif ; les **925 fichiers personnels et
|
||||
réglages suivis** gardent leurs hashes. Le dossier reste
|
||||
`Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ». Le JAR20 et le suivi
|
||||
packwiz sont sauvegardés sous `sanctuary-backups/before-0.1.0-alpha.21/`.
|
||||
Reçus locaux : `build/alpha21-isolated-validation.json` et
|
||||
`build/alpha21-prism-validation.json`.
|
||||
|
||||
Pour tester : **nouveau monde Sanctuary, Moyen, graine0**, **Structures
|
||||
expérimentales** activées dans **Personnaliser**. Entrées des galeries :
|
||||
`-135 253 -178` et `-135 249 162`. `/sanctuary structures` liste les lieux.
|
||||
Les raccords dépendent du relief ; certains sites restent isolés et les réseaux
|
||||
souterrains ne sont pas tous reliés. Aucun monde personnel n’a été ouvert,
|
||||
converti ou régénéré. Aucun essai Windows natif ni essai visuel client n’est
|
||||
revendiqué. Une sauvegarde21 nécessite le binaire21 ou un futur binaire compatible.
|
||||
|
||||
## Alpha.20 publiée — 10 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.20](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.20)
|
||||
est publiée depuis le commit source `0edac29`. Le canal stable utilisait cette
|
||||
version au commit `4e0967d`. Le JAR, le
|
||||
[MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.20/Sanctuary-0.1.0-alpha.20.mrpack)
|
||||
et le ZIP d’amorçage Prism ont été vérifiés par téléchargement public.
|
||||
SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
9ea10576e641d83c13e1277960a375448a22d416a98a6b327c4d6d2a204c423a
|
||||
```
|
||||
|
||||
Passe de détails sur six bâtiments de ville, quatorze infrastructures,
|
||||
espaces publics et réseaux souterrains : palettes propres aux usages,
|
||||
boiseries, tuf ciselé, corniches, biseaux, verrières et raccords des vitres.
|
||||
Voir le [catalogue illustré](architecture-alpha20.md).
|
||||
|
||||
`check build assemblePack` passe, avec huit GameTests généraux. Les créations
|
||||
et réouvertures natives conservent les structures, accès, supports et coffres.
|
||||
Le bassin et les sorties d’égout restent fonctionnels. Les rapports de la
|
||||
version19 restent identiques à ceux de la release publiée. Le mode désactivé
|
||||
et une petite île passent ; les quatorze infrastructures sont rencontrées sur
|
||||
les deux témoins. Les limites des raccords au terrain sont détaillées dans
|
||||
[les preuves alpha.20](testing-alpha20.md).
|
||||
|
||||
Deux synchronisations isolées puis deux dans la même instance Prism Mac
|
||||
réussissent. Un seul JAR Sanctuary alpha.20 est actif ; les **925 fichiers
|
||||
personnels et réglages suivis** conservent leurs hashes. Le dossier reste
|
||||
`Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ». Le JAR19 et le suivi
|
||||
packwiz sont sauvegardés dans `sanctuary-backups/before-0.1.0-alpha.20/`.
|
||||
Reçus locaux : `build/alpha20-isolated-validation.json` et
|
||||
`build/alpha20-prism-validation.json`.
|
||||
|
||||
Pour voir cette architecture, créer un **nouveau monde Sanctuary, Moyen,
|
||||
graine 0**, avec **Structures expérimentales** activées dans **Personnaliser**.
|
||||
`/sanctuary structures` liste les entrées. Petit, graine42, possède la
|
||||
cartothèque à `-101 251 -25`. L’option désactivée d’un autre nouveau monde
|
||||
conserve la ville sans les réseaux expérimentaux. Les anciens mondes gardent
|
||||
leur génération ; aucune sauvegarde personnelle n’a été ouverte, convertie ou
|
||||
régénérée. Une sauvegarde20 ne se rétrograde pas vers le binaire19.
|
||||
|
||||
Les vues du catalogue viennent des modèles et ne sont pas des captures de
|
||||
partie. Aucun essai client visuel ni essai Windows natif n’a été effectué.
|
||||
Les nouvelles fonctions de la bêta restent à construire.
|
||||
|
||||
## Alpha.19 publiée — 9 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.19](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.19)
|
||||
est publiée depuis le commit source `c23e72b`. Le canal stable utilisait alors
|
||||
cette version, au commit `ea883b7`. JAR, [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.19/Sanctuary-0.1.0-alpha.19.mrpack)
|
||||
et ZIP d’amorçage Prism ont été vérifiés par téléchargement public.
|
||||
SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
a159b00a2778020800e81381e0608495a4d7909f1f853f0b8812cd4017865b0b
|
||||
```
|
||||
|
||||
Première passe de quatorze architectures et de leurs voies de fret ou chemins
|
||||
abandonnés sur l’île initiale. Les trois tailles sont vérifiées nativement ;
|
||||
les quatorze types ont chacun été rencontrés et parcourus sur ces témoins.
|
||||
Créer un **nouveau monde Sanctuary, Moyen, graine 0**, puis garder
|
||||
**Structures expérimentales** activées dans **Personnaliser** : ce témoin
|
||||
contient treize lieux et sept liaisons, dont un fret de 499 blocs.
|
||||
`/sanctuary structures` relève les entrées. La présence des bâtiments et les
|
||||
raccords dépendent du relief ; voir [les preuves et la carte](testing-alpha19.md).
|
||||
|
||||
`check build assemblePack` et les huit GameTests généraux passent.
|
||||
Création et réouverture natives conservent 112 pièces et 13 coffres ; 68 cibles
|
||||
sont accessibles. Le mode désactivé passe. Les empreintes de la génération 17
|
||||
restent identiques aux preuves publiées, y compris après réouverture.
|
||||
|
||||
Deux synchronisations isolées puis deux dans la même instance Prism Mac
|
||||
réussissent. Un seul JAR Sanctuary alpha.19 est actif ; les **767 fichiers
|
||||
personnels et réglages suivis** conservent leurs hashes. Aucune sauvegarde
|
||||
personnelle n’a été ouverte, convertie ou régénérée. Le dossier reste
|
||||
`Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ».
|
||||
|
||||
Le JAR 17 et le suivi packwiz sont sauvegardés dans
|
||||
`sanctuary-backups/before-0.1.0-alpha.19/`. Reçus locaux ignorés :
|
||||
`build/alpha19-isolated-validation.json` et `build/alpha19-prism-validation.json`.
|
||||
|
||||
Pour comparer, créer un autre nouveau monde avec l’option désactivée : le socle
|
||||
urbain demeure, sans les expériences 17/19. Cette option ne supprime pas de
|
||||
structures dans une sauvegarde existante. Les anciens mondes conservent leur
|
||||
génération ; une sauvegarde 19 ne doit pas être ouverte avec l’ancien binaire 17.
|
||||
Les bâtiments préparent les futurs usages, mais mailbox, caméra, réacteur et
|
||||
progression restent pour la bêta. Aucun essai visuel dans le client ni essai
|
||||
Windows natif n’a été effectué.
|
||||
|
||||
## Alpha.17 publiée — 9 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.17](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.17)
|
||||
est publiée depuis le commit source `0969ed5`. Le canal stable utilisait alors cette
|
||||
version, au commit `6e0c297`. JAR, [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.17/Sanctuary-0.1.0-alpha.17.mrpack)
|
||||
et ZIP d’amorçage Prism ont été vérifiés par téléchargement public.
|
||||
SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
9ab7a9f03a46bc940d6bc014de85c6935eb5ba21f11ceea9b1222a9dae1faaec
|
||||
```
|
||||
|
||||
L’option **Structures expérimentales** de **Personnaliser** ajoute trois familles
|
||||
de petits réseaux sur les nouvelles îles initiales : mines et ateliers, salles
|
||||
étranges, installations abandonnées. Le choix est enregistré avec la taille.
|
||||
Le témoin **Moyen, graine 0**, option activée, possède trois réseaux accessibles
|
||||
depuis la surface. Voir [les preuves et coordonnées](testing-alpha17.md).
|
||||
|
||||
`check build assemblePack`, les huit GameTests et les trois smokes dédiés passent.
|
||||
Création et réouverture natives conservent 15 pièces, 3 coffres, 12 appuis et le
|
||||
bloc témoin ; 49 cibles sont accessibles. Le cas désactivé passe. La génération 16
|
||||
conserve les empreintes de ses structures et de son journal publiés.
|
||||
|
||||
Deux synchronisations isolées puis deux dans la même instance Prism Mac
|
||||
réussissent. Un seul JAR Sanctuary alpha.17 est actif ; les **767 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`, affiché « Sanctuary Beta ».
|
||||
|
||||
Le JAR 16 et le suivi packwiz sont sauvegardés dans
|
||||
`sanctuary-backups/before-0.1.0-alpha.17/`. Reçus locaux ignorés :
|
||||
`build/alpha17-isolated-validation.json` et `build/alpha17-prism-validation.json`.
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour cet essai. L’option désactivée permet
|
||||
un autre nouveau monde sans les réseaux supplémentaires ; elle ne retire pas
|
||||
de blocs d’un monde existant. Les anciennes sauvegardes gardent leur génération.
|
||||
Ne pas ouvrir une sauvegarde 17 avec le binaire 16. Les connexions entre réseaux,
|
||||
le réacteur, les reliques et la progression restent à construire. Aucun essai
|
||||
Windows natif ni revue visuelle dans le client n’a été effectué.
|
||||
|
||||
## Alpha.16 publiée — 9 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.16](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.16)
|
||||
est publiée depuis le commit source `98fc13c`. Le canal stable utilisait alors cette
|
||||
version, au commit `927c862`. JAR, [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.16/Sanctuary-0.1.0-alpha.16.mrpack)
|
||||
et ZIP d’amorçage Prism ont été vérifiés par téléchargement public.
|
||||
SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
5504018c46300accf01478dbc8bfb8e216395a5435cf7d7f501005cbc65b8e43
|
||||
```
|
||||
|
||||
`check build assemblePack`, les huit GameTests et les scénarios natifs de
|
||||
création/réouverture passent. Six reliefs contrôlés possèdent une ville ; les
|
||||
grands équipements souterrains restent conditionnés par les emplacements
|
||||
admissibles trouvés dans le budget de génération. Le témoin Moyen, graine 0,
|
||||
contient la piscine de 50 × 25, huit niveaux de parking, la grande salle sèche
|
||||
et un égout ouvert avec écoulement vérifié. Voir [les preuves et coordonnées](testing-alpha16.md).
|
||||
|
||||
Deux synchronisations isolées puis deux dans la même instance Prism Mac
|
||||
réussissent. Un seul JAR Sanctuary alpha.16 est actif ; les **767 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`, affiché « Sanctuary Beta ».
|
||||
|
||||
Le JAR15 et le suivi packwiz sont sauvegardés dans
|
||||
`sanctuary-backups/before-0.1.0-alpha.16/`. Les reçus locaux ignorés sont
|
||||
`build/alpha16-isolated-validation.json` et `build/alpha16-prism-validation.json` ;
|
||||
les empreintes avant/après figurent aussi dans la sauvegarde ciblée de l’instance.
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour les nouveautés16. Les mondes publiés
|
||||
jusqu’à15 gardent leur génération, y compris leurs futurs chunks. La machine
|
||||
d’expansion, le réacteur et les nouvelles familles de donjons restent prévus.
|
||||
Aucun accès à l’instance Windows n’a eu lieu pour cette livraison.
|
||||
|
||||
## Alpha.15 publiée — 9 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.15](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.15)
|
||||
est publiée depuis le commit source `dd9c795`. Le canal stable utilisait alors cette
|
||||
version, au commit `6cf54ee`. JAR, [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.15/Sanctuary-0.1.0-alpha.15.mrpack)
|
||||
et ZIP d’amorçage Prism ont été vérifiés par téléchargement public.
|
||||
SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
52253f386d30530cc718d4b90ac6953cd4790b2125c78b74599eebff944824f2
|
||||
```
|
||||
|
||||
`check build assemblePack`, les huit GameTests et les scénarios natifs de
|
||||
création/réouverture passent. Deux synchronisations isolées puis deux dans la
|
||||
même instance Prism Mac réussissent. Un seul JAR Sanctuary alpha.15 est actif ;
|
||||
les **767 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`, affiché « Sanctuary Beta ».
|
||||
|
||||
Le JAR d’essai15 ajouté manuellement avant publication est conservé dans
|
||||
`sanctuary-backups/alpha15-manual-preview/`, hors de `mods/`. Le JAR14 et le
|
||||
suivi packwiz sont sauvegardés dans `sanctuary-backups/before-0.1.0-alpha.15/`.
|
||||
Les reçus locaux ignorés sont `build/alpha15-isolated-validation.json` et
|
||||
`build/alpha15-prism-validation.json` ; les empreintes avant/après figurent
|
||||
aussi dans la sauvegarde ciblée de l’instance.
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour la ville compacte et ses sous-sols,
|
||||
y compris après l’essai anticipé15. Les mondes publiés12/13/14 gardent leurs
|
||||
générateurs. La salle d’expansion est présente lorsque le terrain le permet ;
|
||||
sa future machine et les cartes préremplies ne sont pas implémentées.
|
||||
Voir [les preuves, coordonnées et limites](testing-alpha15.md).
|
||||
Aucun accès à l’instance Windows n’a eu lieu pour cette livraison.
|
||||
|
||||
## 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 utilisait alors 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 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 :
|
||||
|
||||
```text
|
||||
c9df4da956cfa54581b3fde21de006bf8ac3ec025b8dd0daa5d060bcb70aabd4
|
||||
```
|
||||
|
||||
Deux synchronisations isolées puis deux passages dans la même instance Prism
|
||||
ont réussi ; le second laisse les fichiers gérés identiques. Un seul JAR
|
||||
Sanctuary alpha.12.1 est actif, avec **466 fichiers personnels et réglages suivis
|
||||
conservés**, y compris les mondes et les mods tiers. Aucun monde n’a été ouvert
|
||||
ou régénéré. La sauvegarde ciblée est dans
|
||||
`sanctuary-backups/before-0.1.0-alpha.12.1/` de l’instance existante ; reçus locaux
|
||||
`build/alpha121-isolated-validation.json` et `build/alpha121-prism-validation.json`.
|
||||
|
||||
WG-14 ajoute la distance et le contournement automatique des zones occupées.
|
||||
Les mondes laboratoire alpha.12 restent utilisables sans conversion : le journal
|
||||
v1 et les descripteurs de génération 12 sont conservés. Voir le [guide](alpha12-laboratory.md).
|
||||
|
||||
## Alpha.12 publiée — 9 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.12](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.12)
|
||||
est publiée depuis le commit source `cdfbba0`. Le canal stable pointe sur cette
|
||||
version. Le JAR, le [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.12/Sanctuary-0.1.0-alpha.12.mrpack)
|
||||
est publiée depuis le commit source `cdfbba0`. Le canal stable utilisait alors
|
||||
cette version. Le JAR, le [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.12/Sanctuary-0.1.0-alpha.12.mrpack)
|
||||
et le ZIP d’amorçage Prism ont été téléchargés publiquement et vérifiés contre
|
||||
les artefacts locaux. SHA-256 du JAR :
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# Structures contemporaines et anciennes circulations de Sanctuary
|
||||
|
||||
Audit de conception du 9 septembre 2026, après l’essai de l’alpha.17.
|
||||
Ticket WG-21 ; branche `codex/structures-audit`.
|
||||
**Propositions à discuter : aucune nouvelle génération ni règle de progression
|
||||
n’est implémentée par ce document.** Le pack reste en alpha.17.
|
||||
|
||||
## Ce que produit actuellement le mod
|
||||
|
||||
L’alpha.17 recherche au maximum trois petits ensembles, un par famille. Chaque
|
||||
ensemble rassemble trois à cinq pièces autour d’un accès de surface, avec des
|
||||
salles généralement de 9 × 9 blocs. Leur position est cherchée indépendamment.
|
||||
Les rails sont des tronçons intérieurs de la galerie minière. Les rues et ponts
|
||||
de Lost City desservent son quartier compact. Aucun itinéraire ne relie ces
|
||||
ensembles à l’échelle de l’île : le manque d’axes perçu en jeu correspond donc
|
||||
au fonctionnement actuel.
|
||||
|
||||
Les ateliers possèdent des outils et équipements vanilla utilisables. Les
|
||||
coffres emploient les tables de maison des plaines et de cartographe ; le mod
|
||||
n’a pas encore de distribution de trésors conçue pour chaque lieu secret.
|
||||
La mailbox, les deposit boxes, la caméra, la machine d’expansion et les
|
||||
récompenses personnelles restent des systèmes futurs.
|
||||
|
||||
Sources locales : [planificateur17](../mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsPlanner17.java),
|
||||
[rendu17](../mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsRenderer17.java),
|
||||
[preuves alpha.17](testing-alpha17.md), [vision](vision.md).
|
||||
|
||||
## Ligne de conception retenue après cet audit
|
||||
|
||||
**Minecraft comme archéologie d’un autre Minecraft.** Steve, Ari, Sunny, Kai,
|
||||
Zuri, Alex, Mefe, Makena, Noor sont les personnages disparus de la partie. Ils
|
||||
ont découvert quelque chose de révolutionnaire et ont continué parce que ça
|
||||
marchait, jusqu’au moment où ça n’a plus marché.
|
||||
|
||||
> Ils n’ont pas ouvert quelque chose qu’ils n’auraient pas dû ouvrir. Ils ont
|
||||
> construit quelque chose qu’ils ne savaient plus refermer.
|
||||
|
||||
Chaque ruine doit conserver la trace d’une ancienne solution : une infrastructure
|
||||
dont l’auteur a progressivement disparu derrière son fonctionnement. Ce canon
|
||||
ne définit pas la découverte, les rôles individuels ou une catastrophe confirmée.
|
||||
Il guide les prochaines structures ; il n’ajoute aucun personnage, récit jouable
|
||||
ou système de progression aux versions déjà livrées. L’état alpha.17 décrit
|
||||
ci-dessus reste le constat historique de cet audit.
|
||||
|
||||
Pour valider la conception d’un lieu, relever concrètement :
|
||||
|
||||
- **Un usage ancien lisible** dans les pièces, les outils et les circulations.
|
||||
- **Une implantation fonctionnelle** expliquée par le terrain, une ressource
|
||||
ou une destination réellement présente.
|
||||
- **Des traces d’entretien, d’extension ou de chantier** qui montrent comment
|
||||
l’installation a été utilisée et adaptée.
|
||||
- **Un lien futur à la progression**, explicitement décrit comme une intention
|
||||
tant que son système et ses interactions ne sont pas implémentés.
|
||||
|
||||
## Faire comprendre les usages avant d’introduire leurs systèmes
|
||||
|
||||
Chaque lieu devrait répondre à trois questions : pourquoi était-il construit
|
||||
ici, par où arrivaient ses occupants ou ses marchandises, et que peut en faire
|
||||
un joueur aujourd’hui ? Une plateforme de chargement, un vestiaire, des rayonnages
|
||||
et une sortie de service racontent davantage son usage qu’une pièce portant un nom.
|
||||
|
||||
La chaîne proposée pour l’expansion est : **observer → préparer une expédition
|
||||
→ rassembler des ressources → ouvrir un continent → construire ses liaisons**.
|
||||
Les lieux rendent ces activités compréhensibles. Ils n’imposent pas encore une
|
||||
campagne, un coût ou un ordre obligatoire de déblocage.
|
||||
|
||||
La poste rend lisible la différence entre recevoir personnellement un colis et
|
||||
contribuer à un stock collectif dans le dépôt. La salle d’expansion déjà présente
|
||||
peut devenir le point où ces efforts collectifs prennent sens. Une future
|
||||
mailbox ne doit pas être confondue avec un coffre ordinaire décoré en alpha.
|
||||
Ces bâtiments pourraient présenter les services en bêta sans obliger les joueurs
|
||||
à revenir au même guichet pour chaque action ; les règles d’usage restent ouvertes.
|
||||
|
||||
## Catalogue proposé
|
||||
|
||||
Les objets cités sont des **idées de butin**, pas des ajouts de cette livraison.
|
||||
Les variantes d’une même famille peuvent se remplacer selon la graine et le
|
||||
relief ; ce catalogue ne demande pas de tout placer sur chaque île.
|
||||
|
||||
| Lieu | Forme, pièces et implantation | Découverte en alpha à concevoir | Lien possible avec la bêta |
|
||||
| --- | --- | --- | --- |
|
||||
| **Gare de marchandises** | Quai bas, petite halle, balance, guichet, poste d’aiguillage et voie de garage ; à la jonction ville/industrie. | Comprendre la ligne abandonnée, trouver un wagonnet et quelques rails dans la réserve. | Transport des productions vers le dépôt collectif ; réparations faites par les joueurs. |
|
||||
| **Centre postal et atelier de tri** | Hall public, comptoir, casiers individuels, salle de tri, quai arrière, bureau du facteur. | Suivre physiquement le passage d’un colis de l’entrée à son casier ; boussole dans le bureau fermé. | Découverte de la mailbox personnelle et de la livraison des achats. |
|
||||
| **Halle du dépôt communal** | Grande porte, travées numérotées, aire de déchargement, mezzanine d’inventaire et atelier annexe. | Stock modeste, outils de manutention suggérés par le décor, chantier de rayonnages interrompu. | Contributions via les deposit boxes, stocks collectifs et préparation d’expansions. |
|
||||
| **Bureau des expéditions et cartothèque** | Salle de préparation, table de cartographie, vestiaire, réserves de voyage, balcon orienté vers le vide. | Boussole, papier et cartes vierges ; comparer les directions depuis le balcon. | Relevés de ressources, choix de climat et préparation des départs. Les cartes préremplies restent un travail distinct. |
|
||||
| **Observatoire et station météo** | Petite tour sur une crête, plateforme dégagée, salle des instruments, bureau abrité. | Une longue-vue vanilla dans une armoire repérable ; observation du ciel et du relief. | Constellations, signatures climatiques et futur instrument d’observation éventuel. |
|
||||
| **Dépôt de maintenance ferroviaire** | Hangar court avec fosse d’entretien, établis, pièces détachées, vestiaire et établi personnel caché. | Réparer des tronçons, récupérer un outil utile ; retrouver le travail interrompu d’un ancien occupant. | Production, maintenance et futures machines. |
|
||||
| **Station de pompage et château d’eau** | Prise d’eau, bassin, galerie de vannes, passerelles, réservoir et exutoire ; implantation dictée par l’eau et les altitudes. | Traverser les bassins, rejoindre un local sec par une coursive ; seau ou canne à pêche. | Eau pour les ateliers et cultures. Aucun système de pompage actif n’est supposé déjà disponible. |
|
||||
| **Sous-station et galerie de maintenance** | Cour technique, salle de distribution, chemins de câbles, tranchée laissée ouverte et pièce de service. | Horloge dans le poste de garde, redstone modeste dans l’atelier ; repérer les départs de galeries. | Préparer visuellement les infrastructures à réactiver. Ni réacteur ni alimentation obligatoire de l’expansion décidés ici. |
|
||||
| **Serre municipale et graineterie** | Serre partiellement effondrée, tables de rempotage, irrigation, remise et chambre de conservation. | Graines vanilla adaptées au lieu, pot de fleurs, outils de culture. | Récoltes localisées et cuisine d’**It’s Alive !** ; pas de plantes futures distribuées en alpha. |
|
||||
| **Quai d’expédition au bord du vide** | Plateforme ancrée, hangar, rambarde, grue inachevée et petite salle d’attente. | Point de vue et lieu de départ évident ; accès depuis la ville ou le dépôt. | Bateaux volants, Happy Ghast, biplans, ziplines ou ponts construits par les joueurs. Aucune île future présumée en face. |
|
||||
| **Relais radio et cabane du gardien** | Antenne sobre sur un promontoire, escalier d’entretien, logement minuscule et réserve sous le plancher. | Repère visible de loin ; boussole ou horloge, passage discret vers un atelier. | Réseau de services et rendez-vous collectifs, si ce rôle est retenu. |
|
||||
| **Club photo et laboratoire caché** | Petite boutique ou salle associative, réserve, chambre noire et séchoir ; accès secondaire depuis une cour ou un hôtel. | Un atelier secret identifiable, avec une récompense vanilla provisoire. | Lieu naturel pour découvrir une **caméra en bêta** ; aucune caméra factice ou promesse de butin inexistant en alpha. |
|
||||
| **Vestiaires et salles de service impossibles** | Derrière les grandes piscines : vestiaires, laverie, couloir de livraison, puis vaste salle carrelée hors proportion. | Étrangeté progressive, passages secondaires, récompense au bout d’un détour lisible. | Donjons physiques et secrets ; distincts de la dimension Backrooms des objets perdus. |
|
||||
| **Refuge de chantier dans un ouvrage inachevé** | Cabane dans une pile de viaduc, atelier suspendu avec ancrages, escalier arrêté à une plateforme et réserves laissées sur place. | Un lit, un outil et une installation récupérable ; sentir qu’un autre joueur s’était établi ici. | Réappropriation par les joueurs, bases individuelles et chemins à terminer. |
|
||||
|
||||
## Des axes à l’échelle de l’île
|
||||
|
||||
Une **ancienne ligne de fret**, quelques embranchements et un chemin de service
|
||||
peuvent suffire à donner une organisation au territoire. Son tracé suit la pente,
|
||||
entre brièvement dans la roche et franchit les ravins avec des ouvrages ponctuels.
|
||||
La majorité de l’île reste naturelle. Une longue voie ne signifie pas une grande
|
||||
gare ni une grande ville supplémentaire.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
M[Mine] --- A[Atelier et maintenance]
|
||||
A --- G[Gare de marchandises]
|
||||
G --- D[Dépôt communal]
|
||||
G --- P[Centre postal]
|
||||
D --- Q[Quai d'expédition]
|
||||
P --- C[Cartothèque]
|
||||
C --- O[Observatoire]
|
||||
D -. passage technique .- E[Salle d'expansion existante]
|
||||
E -. galerie de service .- H[Pompage et égouts]
|
||||
H -. accès secondaire .- S[Salles étranges]
|
||||
```
|
||||
|
||||
Ce schéma décrit les relations proposées, pas un plan rigide à reproduire ni
|
||||
des liaisons toutes ferroviaires. Mine, atelier, gare et dépôt partagent la ligne
|
||||
de fret ; les autres raccords peuvent être des rues, sentiers, escaliers ou
|
||||
galeries. Les réseaux techniques et les lieux étranges gardent leurs itinéraires
|
||||
propres, avec seulement quelques intersections utiles.
|
||||
|
||||
Pour une rupture crédible, il faut concevoir **le trajet complet entre deux
|
||||
destinations présentes**, puis son état d’abandon :
|
||||
|
||||
- **Rails arrachés** : le ballast et les traverses montrent le chemin à réparer.
|
||||
- **Petit pont effondré** : les culées et une pile conservée indiquent la portée
|
||||
manquante ; un détour permet de rejoindre l’autre côté.
|
||||
- **Tunnel éboulé** : les deux portails suivent le même axe ; une galerie de
|
||||
maintenance donne un autre parcours lorsque le relief le permet.
|
||||
- **Travaux jamais terminés** : terrassement, échafaudage, matériaux et butoir
|
||||
forment une fin intentionnelle. Un chantier peut réellement s’arrêter là.
|
||||
- **Voie envahie** : végétation et débris alternent avec des sections utilisables.
|
||||
|
||||
Les coupures ne doivent pas reproduire l’aspect d’un raccord de chunks cassé.
|
||||
Les rails qui subsistent ont un support ; les tronçons réparables suivent des
|
||||
pentes et courbes praticables en wagonnet. Un souterrain conserve des appuis et
|
||||
un passage pour le joueur. Les raccords impossibles sont abandonnés proprement,
|
||||
sans publier une porte donnant dans la roche ni annoncer une gare inexistante.
|
||||
|
||||
Une liaison vers un nouveau continent reste à construire après son ouverture.
|
||||
Le quai exprime la possibilité de partir ; il ne prédit pas un terrain encore
|
||||
absent, sa position exacte ou la route que choisiront les joueurs.
|
||||
|
||||
## Récompenser la découverte d’un lieu secret
|
||||
|
||||
Un secret devrait offrir **un objet principal cohérent avec son ancien usage** :
|
||||
longue-vue dans l’observatoire, boussole dans la cartothèque, horloge dans le poste
|
||||
d’aiguillage, outil dans l’atelier. Quelques fournitures accompagnent cet objet.
|
||||
Le plaisir vient de trouver un équipement utile tôt et de comprendre son origine ;
|
||||
un objet vanilla fabriquable ne devient pas artificiellement une relique unique.
|
||||
|
||||
L’accès se lit dans le décor : fenêtre donnant sur une pièce sans porte visible,
|
||||
escalier derrière une étagère déplacée, trappe de maintenance, conduit débouchant
|
||||
sur un établi éclairé. La pièce peut être cachée sans exiger de casser tous les
|
||||
murs au hasard. Des variations portent sur le plan, l’accès et l’usage, pas
|
||||
seulement sur la couleur d’une salle identique.
|
||||
|
||||
Le bâti est lui-même une récompense récupérable. Les rails, équipements d’atelier,
|
||||
blocs de fer et autres matériaux du décor doivent compter dans l’inventaire
|
||||
des ressources réellement générées ; une infrastructure trop généreuse pourrait
|
||||
remplacer tout le minage du début de partie. Cela se mesure après génération.
|
||||
|
||||
Pour un premier essai de butin en alpha, les coffres pourraient rester des
|
||||
conteneurs vanilla partagés, sans renouvellement automatique. La coexistence
|
||||
des **reliques collectives uniques** et des **récompenses personnelles** nécessite
|
||||
le contrat de bêta. L’accès à la mailbox ou à l’expansion ne devrait pas dépendre
|
||||
d’un objet ordinaire que le premier explorateur peut emporter et perdre.
|
||||
|
||||
## Ordre de travail proposé
|
||||
|
||||
1. Relier une destination existante et une nouvelle destination avec un axe
|
||||
visible à l’échelle de l’île : atelier/mines, gare et dépôt. Vérifier les
|
||||
trajets, les supports et quelques ruptures réparables en jeu.
|
||||
2. Donner à la ville une poste et une cartothèque ; placer un observatoire dans
|
||||
le relief, avec des accès logiques et un premier atelier secret.
|
||||
3. Raccorder ponctuellement les galeries techniques aux égouts, piscines et
|
||||
salles étranges existantes. Garder des secteurs indépendants et des accès
|
||||
secondaires plutôt que transformer tous les sous-sols en un unique donjon.
|
||||
4. Définir puis essayer les petites tables de butin par usage avant d’introduire
|
||||
les objets et règles de la bêta.
|
||||
|
||||
Avant d’écrire ce générateur, valider la place de la ligne de fret, les premiers
|
||||
lieux retenus et le rôle des ruptures : réparations libres, détours ou chantiers
|
||||
à prolonger. Les essais doivent distinguer un axe planifié, ses tronçons réellement
|
||||
générés, ceux qui sont praticables et ceux qui sont volontairement interrompus.
|
||||
La recherche des raccords doit rester bornée, sans explorer ou charger toute
|
||||
l’île pour chercher un passage. Les mondes publiés gardent leur génération ;
|
||||
aucune transformation d’une sauvegarde existante n’est autorisée par cet audit.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,182 @@
|
||||
# Alpha.15 — intérieurs, sous-sols et ville compacte
|
||||
|
||||
**État : code, génération et distribution validés.**
|
||||
Ticket [WG-18](backlog.md). `check build assemblePack` et les huit tests moteur
|
||||
passent sur les sources finales, ainsi que la création et la réouverture
|
||||
natives de Lost City. Le reçu de publication figure dans [Distribution](packwiz.md).
|
||||
|
||||
## Contrat de génération
|
||||
|
||||
Minecraft **26.3-pre-2**, Java **25**, dépendances épinglées inchangées.
|
||||
Le mod et le pack passent à `0.1.0-alpha.15`. Un **nouveau monde Sanctuary**
|
||||
utilise `sanctuary:island_v15` et son journal propre
|
||||
`data/sanctuary-world-v15/expansions.json`.
|
||||
Le bouton natif **Personnaliser** conserve Petit 512, Moyen 724 par défaut et
|
||||
Grand 1 024. La densité du terrain, les forêts et l’hydrologie naturelle ne sont
|
||||
pas remplacées par cette passe urbaine.
|
||||
|
||||
Le JAR d’essai anticipé du 9 septembre, avant publication de l’alpha.15,
|
||||
contenait encore les fragments dispersés. Les essais de cette nouvelle
|
||||
composition se font dans un **monde neuf** ; aucune réparation ou régénération
|
||||
des mondes créés avec ce prototype n’est exécutée. Le binaire d’essai est
|
||||
conservé séparément avant synchronisation de l’instance.
|
||||
|
||||
Les mondes 12/13/14 conservent leurs codecs, paramètres, journaux, plans et
|
||||
rendus, même pour leurs chunks encore non générés. Le preset 14 reste enregistré
|
||||
mais caché dans la création. Aucun monde personnel n’est ouvert, converti ou
|
||||
régénéré. Les nouvelles pièces persistantes ont leurs propres identifiants :
|
||||
`sanctuary:lost_city_parcel_v15`, `sanctuary:lost_city_underground_v15` et
|
||||
`sanctuary:lost_city_support_v15`.
|
||||
|
||||
Le biome `sanctuary:lost_city` reste utilisé : les parcelles acceptées pilotent
|
||||
la carte des biomes urbains et les apparitions naturelles de zombies vanilla.
|
||||
Les cavités gardent leurs biomes ; un égout souterrain ne transforme pas toute
|
||||
la colonne en biome urbain. Il n’y a ni villageois générés, ni spawner, ni
|
||||
classes de zombies de la future bêta.
|
||||
|
||||
## Aménagements
|
||||
|
||||
Les appartements, hôtels, bibliothèques, restaurants, casernes et halls ont
|
||||
des usages distincts. Seuil et vestibule rejoignent un couloir, une cage
|
||||
d’escalier et des pièces séparées par de vraies portes. Sols, plafonds,
|
||||
chambres, salles d’eau, cuisines, réserves, réception, rayonnages, dortoirs,
|
||||
auditorium et galerie suivent le type du bâtiment. Les détails d’usure doivent
|
||||
préserver les passages. Le butin reste différé, avec un coffre par bâtiment.
|
||||
|
||||
Les réseaux souterrains cherchent une descente depuis la surface, des égouts,
|
||||
des passages, des garages et des pool rooms. La roche et les volumes d’eau
|
||||
naturelle décident de leur admission. Les parties exposées demandent des
|
||||
appuis courts certifiés : pylônes ou chaînes réellement ancrés. Les bassins
|
||||
construits ont un fond, des parois et un accès ; ils ne doivent pas se vider
|
||||
dans les corridors. Les modules voisins ne se superposent pas. Une grande salle sèche accessible
|
||||
prépare une future machine d’expansion ; la machine et ses cartes ne font pas
|
||||
partie de cette livraison.
|
||||
|
||||
Le retour de l’essai anticipé remplace les fragments dispersés par une grille
|
||||
compacte : lots, rues et carrefours sont jointifs, avec un réseau accessible
|
||||
depuis chaque entrée. Les routes sont dégagées sur huit blocs de hauteur ;
|
||||
les terrasses sont certifiées dans leur emprise et les ponts prolongent le
|
||||
réseau. Les sièges, charnières et appuis des lanternes sont revus. Les emplacements admissibles
|
||||
restent conditionnels ; aucun équipement n’est promis sur toute graine.
|
||||
|
||||
## Vérification reproductible
|
||||
|
||||
Utiliser seulement les mondes de développement ignorés. Exécuter les processus
|
||||
Minecraft séquentiellement et faire suivre chaque création de sa réouverture,
|
||||
avant un autre test qui nettoie ce dossier de développement.
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:lostCityInteriors15Smoke :sanctuary:lostCityUnderground15Smoke --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryLostCity15Tests=true -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryLostCity15Tests=true -PsanctuaryExpansionReload=true -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
./gradlew check build assemblePack -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
```
|
||||
|
||||
La suite historique `-PsanctuaryLostCityTests=true` choisit explicitement la
|
||||
génération 14. Les suites de reprise certifiée du vide utilisent la génération
|
||||
15 par défaut ; la réouverture doit être exécutée deux fois après leur création.
|
||||
Les certificats sont liés à leur génération : un certificat ou journal 14 ne
|
||||
peut pas autoriser une expansion 15. Le [contrat WG-17](testing-alpha14.md)
|
||||
reste applicable dans chaque journal, avec inspection stricte avant écriture.
|
||||
|
||||
Les résultats finaux préciseront graine, version, coordonnées, pièces natives,
|
||||
eau après ticks, conservation après réouverture et durée des commandes.
|
||||
|
||||
### Compatibilité historique déjà vérifiée
|
||||
|
||||
La création de génération 14 avec le binaire alpha.15 passe le 9 septembre
|
||||
2026 (`build/alpha15-legacy14-create.log`, processus 22663, 2 min 57 s).
|
||||
Sur graine 0, racine 724 et extension `city_gardens` 256, les deux starts NBT,
|
||||
les inventaires de blocs, les coffres différés et le journal ont exactement les
|
||||
mêmes empreintes ou contenus que la livraison alpha.14. La réouverture du même
|
||||
binaire dans le processus 23083 passe (`build/alpha15-legacy14-reload.log`, 57 s),
|
||||
avec **38 pièces**, **5 coffres non ouverts**, le bloc témoin et le journal
|
||||
conservés. Cette réouverture exclut la recompilation : elle vérifie les mêmes
|
||||
binaires que la création, pendant le développement séparé du nouveau planner15.
|
||||
Les classes historiques de planification et de rendu ne sont pas modifiées.
|
||||
Les rapports sont conservés dans `build/alpha15-evidence/legacy14-*.json`.
|
||||
|
||||
Le premier test moteur15 a vérifié les accès des bâtiments et réseaux, puis a
|
||||
échoué sur une hypothèse insuffisante de son ordonnanceur de fluides. Le test
|
||||
attend maintenant que les chunks et leurs entités soient prêts, observe un
|
||||
écoulement natif dans des témoins temporaires fermés, puis contrôle les bassins
|
||||
naturels après des ticks mesurés. Ces témoins sont séparés des équipements
|
||||
naturellement générés et supprimés avant sauvegarde.
|
||||
Les essais sur modèles contrôlés seront distingués des équipements effectivement
|
||||
générés sur terrain naturel. La validation Windows native reste à effectuer.
|
||||
|
||||
### Contrôles ciblés du retour d’essai anticipé
|
||||
|
||||
Les smokes exécutés le 9 septembre passent avec les classes Minecraft exactes :
|
||||
les six familles d’intérieurs dans quatre orientations contrôlent la collision
|
||||
des portes ouvertes, le côté des dossiers des sièges et l’appui plein des
|
||||
lanternes (`build/alpha15-targeted-furnishings.log`). Le rendu de surface15
|
||||
corrige aussi les bancs et ajoute les marches sur le sol existant, leur sommet
|
||||
rejoignant le niveau voisin. Dégagement des rues, volumes de protection,
|
||||
grande salle, boucle alternative et fondations souterraines passent dans
|
||||
`build/alpha15-targeted-utilities.log`. Ces essais contrôlés ne remplacent pas
|
||||
le scénario natif final.
|
||||
|
||||
Le scénario de reprise certifiée du vide de génération15 passe sa création et
|
||||
ses deux réouvertures dans des JVM séparées : **154 chunks FULL**, tous éclairés
|
||||
correctement, et bloc témoin conservé. Les refus de bloc, coffre, entité ou tick
|
||||
présents et la conservation des régions face à une mutation étrangère passent.
|
||||
Rapports : `build/alpha15-evidence/void-reuse-*.json`. Ces preuves précèdent la
|
||||
révision compacte du seul placement urbain ; le test complet final reste exigé.
|
||||
|
||||
### Ville compacte : création finale vérifiée
|
||||
|
||||
La création finale passe sur la graine **0**, île **724**, le 9 septembre
|
||||
2026 : `build/alpha15-final-city-create.log`, processus **26229**, **3 min 19 s**
|
||||
pour les quatre smokes puis le scénario moteur. Cette durée inclut les contrôles,
|
||||
les prospects et la préparation d’un continent ; ce n’est pas un benchmark de
|
||||
chargement joueur ou de rendu.
|
||||
|
||||
- Racine : un quartier natif au chunk **(-2, 3)**, cinq lots (immeuble, hôtel,
|
||||
bibliothèque, jardin et place), **33 pièces de rues/carrefours**, **52 pièces
|
||||
natives** avec le sous-sol. Les cinq entrées rejoignent le même réseau ;
|
||||
**393 sections** de rues sont traversées sur les seules rues et seuils.
|
||||
L’immeuble est accessible en **(-30, 252, 21)** sur cette graine.
|
||||
- Sous la racine : un accès, quatre collecteurs, sept passages, une pool room
|
||||
et une grande salle sèche. Le socle et les quatre secteurs de la salle sont
|
||||
accessibles depuis la trappe de surface.
|
||||
- Continent témoin **256**, graine **8737523517608206795**, centre **(2112, 320)** :
|
||||
restaurant et immeuble, réseau de rues, pont de 17 blocs, garage et pool room
|
||||
souterrains. **24 pièces natives** et **5 appuis souterrains** inspectés.
|
||||
Le pont est parcouru de **(2104, 225, 309)** à **(2120, 224, 309)**.
|
||||
- Les biomes urbains et le spawn naturel de zombies sont vérifiés ; aucun
|
||||
spawner ni villageois ajouté. Cinq coffres restent à butin différé.
|
||||
- Les bassins souterrains sont contrôlés après **120 ticks de monde mesurés** ;
|
||||
19 témoins temporaires indépendants prouvent l’exécution native des fluides,
|
||||
puis sont retirés avant sauvegarde. La piscine de surface est vérifiée dans
|
||||
le premier monde compact, avant la dernière affectation des lots : ses
|
||||
**207 sources** sont conservées, et neuf cellules au-dessus des marches se
|
||||
remplissent normalement sans fuite (216 cellules d’eau au total).
|
||||
|
||||
Le témoin final contient un pont court et des appuis souterrains. Les grands
|
||||
viaducs sur piles construites restent validés sur terrains contrôlés ; ils ne
|
||||
sont pas présentés comme observés dans ce monde naturel. Aucune famille de
|
||||
bâtiments, piscine, garage, salle ou pont n’est garantie sur chaque graine.
|
||||
Rapport : `build/alpha15-evidence/final-city-create.json` ; le monde compact
|
||||
précédent est conservé dans `compact-first-create.json`.
|
||||
|
||||
La réouverture finale du même monde passe dans un autre processus (**26452**,
|
||||
`build/alpha15-final-city-reload.log`, **51 s**) : **76 pièces natives**, **5 coffres
|
||||
à butin différé**, **108 points de parcours intérieurs** et **5 appuis** conservés.
|
||||
Les sept entrées restent reliées aux rues, le bloc témoin **(-38, 252, 21)** est
|
||||
présent et le journal garde exactement ses octets. Rapport :
|
||||
`build/alpha15-evidence/final-city-reload.json`.
|
||||
|
||||
`check build assemblePack` final passe en **2 min 6 s** le 9 septembre 2026,
|
||||
avec les **huit tests moteur requis** et tous les smokes, dont les quatre
|
||||
contrôles15 (`build/alpha15-final-check-build-pack.log`). Le JAR et le pack
|
||||
sont construits depuis les mêmes sources que le scénario final. Le changement
|
||||
reste réservé à15 ; la revue finale ne trouve aucune modification des quatre
|
||||
classes de planification/rendu/pièce historiques14.
|
||||
|
||||
La release immuable `v0.1.0-alpha.15`, source `dd9c795`, et le canal `6cf54ee`
|
||||
sont publiés. JAR, MRpack et amorçage sont vérifiés ; deux installations isolées
|
||||
et deux synchronisations de la même instance Prism réussissent. Les **767**
|
||||
fichiers personnels et réglages suivis restent identiques. L’ancien JAR d’essai
|
||||
est conservé séparément ; un seul JAR Sanctuary est actif. Voir le
|
||||
[reçu de livraison](packwiz.md#alpha15-publiée--9-septembre-2026).
|
||||
@@ -0,0 +1,144 @@
|
||||
# Alpha.16 — Sanctuary habitée avant nous
|
||||
|
||||
**État : publiée et installée dans la même instance Prism.** Ticket WG-19,
|
||||
branche `codex/sanctuary-ruins-alpha16`. Minecraft 26.3-pre-2, Fabric Loader 0.19.5,
|
||||
Fabric API 0.160.0+26.3 et Java 25, comme l’alpha.15.
|
||||
|
||||
## Contrat de génération
|
||||
|
||||
La génération 16 demande un **nouveau monde Sanctuary**. Le choix public reste
|
||||
unique, avec le bouton natif Personnaliser : Petit 512, Moyen 724 par défaut,
|
||||
Grand 1024. Les codecs `island_v13`, `island_v14`, `island_v15`, leurs pièces
|
||||
natives, journaux, biomes et calculs de chunks futurs sont conservés. Aucun
|
||||
monde personnel n’est converti, ouvert par les tests ou régénéré.
|
||||
|
||||
Lorsque les structures sont activées, l’île de départ reçoit une ville
|
||||
compacte. Une recherche naturelle est suivie, si nécessaire, d’une réservation
|
||||
architecturale locale 49 × 49 : rues et bâtiments reliés au-dessus d’un socle
|
||||
creux, avec appuis de maçonnerie certifiés. Cette réservation peut dégager le
|
||||
terrain et remplacer l’eau dans sa seule emprise, avant le placement des pièces.
|
||||
Elle ne modifie pas la densité de toute l’île et ne s’applique pas aux continents.
|
||||
Les expansions conservent leur profil et leur placement soumis au terrain.
|
||||
|
||||
La passe souterraine comprend une piscine couverte avec bassin de 50 × 25
|
||||
blocs et quatre blocs de profondeur, des égouts ouverts et des parkings de
|
||||
3, 4, 6 ou 8 plateformes selon les appuis disponibles. Une grande salle sèche
|
||||
peut compléter le réseau. Chaque équipement dépend des volumes et appuis
|
||||
certifiés sur le terrain ; le tableau
|
||||
ci-dessous distingue les témoins natifs des cas de modèle. Les anciens petits
|
||||
bassins de la génération 15 restent inchangés.
|
||||
|
||||
Les plans des étages alternent réellement. Les sanitaires ont des parois
|
||||
complètes, leur porte et leur plafond ; les lits sont adossés aux murs avec
|
||||
une rive latérale accessible. Portes, sièges et lanternes conservent leurs
|
||||
contrôles d’orientation et d’appui.
|
||||
|
||||
## Vérifications
|
||||
|
||||
- Les cinq smokes 16 passent : intérieurs, planification, fondation, protection
|
||||
et sous-sols. Ils vérifient notamment les quatre orientations, les plans
|
||||
d’étages, les sanitaires, les lits et les appuis.
|
||||
- Matrice de planification moteur : les trois tailles 512/724/1024 × graines 0/1
|
||||
passent. Une ville de 3 à 8 lots, avec rues et entrées reliées dans les six cas.
|
||||
Ces six reliefs trouvent une implantation naturelle ; le recours architectural
|
||||
est couvert séparément par les terrains adverses, le rendu borné et le NBT
|
||||
des smokes. Rapport : `build/alpha16-evidence/planning-matrix.json`.
|
||||
- Création native 16, graine 0, taille Moyenne, puis réouverture dans une seconde
|
||||
JVM : **71 pièces, 5 coffres différés, 107 cibles de parcours et 74 appuis**
|
||||
conservés entre l’île et une expansion de 256 blocs. Les hashes des starts,
|
||||
le journal et le bloc d’or témoin restent identiques. Processus 29685 puis 29853.
|
||||
Rapports : `build/alpha16-evidence/final-city-create.json` et
|
||||
`final-city-reload.json` ; logs `build/alpha16-third-city-create.log` et
|
||||
`alpha16-city-reload.log`.
|
||||
- Après **120 ticks du monde réellement observés**, la piscine garde ses
|
||||
**4 976 cellules d’eau** ; les deux égouts observés déversent de l’eau au-delà
|
||||
de leur bouche. Dix-neuf témoins temporaires indépendants prouvent l’activité
|
||||
effective du moteur des fluides, puis sont supprimés avant sauvegarde. Les
|
||||
cascades atteignent des corniches : elles ne sont pas toutes libres jusqu’au
|
||||
vide inférieur. Aucun test ne présente une simple attente comme un écoulement.
|
||||
- Compatibilité 15 : création puis réouverture sous le mod 16 réussies,
|
||||
76 pièces natives, 5 coffres, 108 cibles de parcours et 5 appuis conservés.
|
||||
Les SHA-256 des deux starts et du journal sont exactement ceux de l’alpha.15
|
||||
publiée. Les fichiers de génération 15 restent inchangés. Le preset caché
|
||||
`sanctuary_v15.json` est identique à l’ancien preset public, SHA-256
|
||||
`6c4a91eede4b9978526d6de6289dbc7394ed4114ebcae9f779fac3ce7e7d887e`.
|
||||
Rapports : `build/alpha16-evidence/legacy15-create.json` et `legacy15-reload.json`.
|
||||
- `./gradlew check build assemblePack` passe en 2 min 18 s : huit GameTests,
|
||||
tous les smokes et index du pack vérifiés. Log :
|
||||
`build/alpha16-final-check-build-pack.log`.
|
||||
- JAR, MRpack et ZIP d’amorçage vérifiés : même version et même JAR, index et
|
||||
hashes cohérents, aucun code de test embarqué. SHA-256 du JAR :
|
||||
`5504018c46300accf01478dbc8bfb8e216395a5435cf7d7f501005cbc65b8e43`.
|
||||
|
||||
### Distribution des sous-sols sur six reliefs
|
||||
|
||||
Un second relevé utilise le vrai bruit Minecraft et les protections hydrologiques,
|
||||
sans générer les chunks de la ville. Il contrôle les volumes, les fondations et
|
||||
les limites de calcul ; les fluides et la persistance restent prouvés par le
|
||||
scénario natif ci-dessus. Rapport : `build/alpha16-evidence/underground-matrix.json`.
|
||||
|
||||
| Diamètre | Graine | Piscine | Grande salle | Parking | Égout ouvert |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 512 | 0 | Absente | Présente | 8 niveaux | Présent |
|
||||
| 512 | 1 | Absente | Présente | 8 niveaux | Présent |
|
||||
| 724 | 0 | Présente | Présente | 8 niveaux | Présent |
|
||||
| 724 | 1 | Absente | Présente | 8 niveaux | Présent |
|
||||
| 1024 | 0 | Présente | Absente | 8 niveaux | Présent |
|
||||
| 1024 | 1 | Présente | Présente | 8 niveaux | Présent |
|
||||
|
||||
La piscine est donc présente dans trois cas sur six, la salle dans cinq cas.
|
||||
Les six réseaux ont un accès depuis la surface. Ce petit échantillon ne donne
|
||||
pas une fréquence statistique ni une garantie pour les autres graines. Le
|
||||
budget souterrain atteint son plafond de 420 000 sondes dans quatre cas ;
|
||||
la recherche s’arrête alors en conservant les équipements déjà certifiés.
|
||||
Une aile absente ne prouve donc pas l’absence de tout emplacement compatible :
|
||||
une autre solution peut se trouver au-delà du budget de recherche.
|
||||
La passe souterraine prend 2,9 à 7,0 secondes après la planification de surface
|
||||
sur cette machine ; cela ne mesure ni le rendu client ni le chargement complet.
|
||||
|
||||
## Repères pour l’essai en jeu
|
||||
|
||||
Créer un nouveau monde **Sanctuary, Moyen, graine 0**, structures activées.
|
||||
Les coordonnées suivantes proviennent des blocs réellement générés et relus.
|
||||
Elles ne constituent pas une migration d’un monde précédent.
|
||||
|
||||
| Lieu | Repère X / Y / Z |
|
||||
| --- | --- |
|
||||
| Entrée de l’immeuble | −30 / 252 / 21 |
|
||||
| Trappe depuis la rue | **−19 / 252 / 41** |
|
||||
| Promenade de la piscine | −45 / 230 / −11 |
|
||||
| Grande salle sèche | −42 / 231 / 20 |
|
||||
| Parking, niveau supérieur | 10 / 230 / 20 |
|
||||
| Parking, niveau inférieur | 10 / 118 / 20 |
|
||||
| Bouche de l’égout inférieur | **−50 / 117 / 20** |
|
||||
|
||||
Le bassin remplit X −44 à 5, Y 225 à 228, Z −23 à 1. Les plateformes du
|
||||
parking sont à Y 117, 133, 149, 165, 181, 197, 213 et 229. Le réseau est
|
||||
accessible depuis la trappe de rue. Sur ce témoin, la cascade atteint une
|
||||
corniche à Y 113. L’expansion de référence `city16_0_2`, graine
|
||||
9111000990819250332, possède aussi une bouche à 1958 / 202 / −651.
|
||||
|
||||
Les équipements restent conditionnés par leurs volumes et appuis ; seule la
|
||||
zone de ville initiale est garantie par le recours architectural. Aucun essai Windows natif ni revue visuelle en client n’a été effectué pour
|
||||
cet incrément ; les vérifications ci-dessus proviennent du moteur serveur Mac. Le pack doit encore être validé en jeu par Koka.
|
||||
|
||||
## Livraison
|
||||
|
||||
Le tag `v0.1.0-alpha.16` vise le commit source `98fc13c` ; le canal packwiz
|
||||
est au commit `927c862`. Les trois artefacts publics sont vérifiés par
|
||||
téléchargement, puis deux installations isolées et deux synchronisations de
|
||||
la même instance Prism passent. Un seul JAR alpha.16 est actif ; les 767
|
||||
fichiers personnels et réglages suivis conservent leurs hashes. Aucun monde
|
||||
personnel ouvert ou régénéré. Les reçus et la sauvegarde ciblée sont décrits
|
||||
dans [Distribution](packwiz.md).
|
||||
|
||||
## Suite validée avec Koka, hors alpha.16
|
||||
|
||||
Développer les trois familles : mines et ateliers, lieux étranges,
|
||||
infrastructures à réactiver. Plusieurs réseaux distincts se connectent parfois.
|
||||
Les installations encore utilisables et les chantiers interrompus racontent
|
||||
l’occupation passée par d’autres joueurs. Les back rooms de ces donjons sont
|
||||
physiquement dans l’île et restent distinctes de la dimension des objets perdus.
|
||||
Le butin combinera des reliques collectives uniques et des récompenses
|
||||
personnelles. Les règles d’attribution restent à définir ; aucun réacteur,
|
||||
nouveau spawner de donjon ou système de progression n’est ajouté ici.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Alpha.17 — réseaux oubliés, prototype réversible
|
||||
|
||||
**État : publié et installé dans la même instance Prism.** Ticket WG-20, branche
|
||||
`codex/structures-experiment-alpha17`. Minecraft 26.3-pre-2, mêmes dépendances
|
||||
que l’alpha.16. Cet essai porte sur les lieux et leur génération.
|
||||
|
||||
## Contrat
|
||||
|
||||
Créer un **nouveau monde Sanctuary**, puis **Personnaliser** : Petit 512,
|
||||
Moyen 724 par défaut, Grand 1 024 et **Structures expérimentales** (activées par
|
||||
défaut). Le choix est enregistré dans le générateur 17, avec la taille. Désactiver
|
||||
l’option pour un autre nouveau monde permet de comparer avec le socle urbain 16.
|
||||
Ce bouton ne supprime ni ne rajoute de structures dans une sauvegarde existante.
|
||||
|
||||
Les anciens mondes gardent leurs codecs, rendus, pièces et futurs chunks. Le
|
||||
preset 16 historique est conservé à l’identique et caché à la création. Le 17
|
||||
possède son propre journal ; aucune conversion ni rétrogradation de sauvegarde
|
||||
n’est proposée. Le retour au binaire 16 concerne seulement les mondes 16 et
|
||||
antérieurs, jamais un monde 17.
|
||||
|
||||
Les nouvelles structures sont recherchées sur l’île initiale uniquement, hors
|
||||
ville et eaux protégées. Trois petits réseaux distincts : mines et ateliers,
|
||||
salles étranges, installations techniques abandonnées. Salles et couloirs se
|
||||
raccordent à des accès de surface. Les fondations reposent sur le terrain ou sur
|
||||
des appuis courts certifiés. La présence dépend des emplacements trouvés dans
|
||||
un budget borné. Les jonctions entre familles restent une étape suivante.
|
||||
|
||||
Les établis, fourneaux et outils vanilla donnent des usages aux installations ;
|
||||
rails, soutènements et chantiers interrompus suggèrent les anciens occupants.
|
||||
Les lieux étranges sont physiques dans l’île, distincts de la future dimension
|
||||
Backrooms. Aucun réacteur, progression, relique collective, récompense personnelle
|
||||
ou nouveau spawner n’est ajouté par ce prototype. Les coffres éventuels utilisent
|
||||
les tables de butin vanilla.
|
||||
|
||||
## Vérification et essai
|
||||
|
||||
Le monde **Moyen, graine 0**, option expérimentale activée, reçoit trois réseaux
|
||||
avec cinq pièces chacun. Les contrôles natifs portent sur **15 pièces**, **7 639
|
||||
blocs**, **13 chunks FULL**, **12 appuis**, **49 cibles de parcours** et **3 coffres
|
||||
à butin différé**. Les empreintes des starts, le plan et un bloc d’or témoin sont
|
||||
conservés après réouverture dans une seconde JVM. Les trappes sont ouvertes pour
|
||||
vérifier le parcours réel d’un joueur, puis refermées avant la sauvegarde.
|
||||
|
||||
| Réseau | Entrée X / Y / Z |
|
||||
| --- | --- |
|
||||
| Mine et atelier | **78 / 252 / 179** |
|
||||
| Salles étranges et archives | **−194 / 254 / −22** |
|
||||
| Locaux techniques | **116 / 253 / −157** |
|
||||
|
||||
Avec les commandes autorisées :
|
||||
|
||||
```text
|
||||
/locate structure sanctuary:forgotten_networks
|
||||
```
|
||||
|
||||
La commande donne une entrée de surface. Le prototype recherche les trois
|
||||
familles, sans les garantir sur chaque graine. Les neuf types de salles et
|
||||
couloirs plus l’accès sont testés sur des dimensions et ouvertures variées ;
|
||||
les plafonds, appuis de mobilier, orientations et chemins sont contrôlés.
|
||||
|
||||
Les trois smokes dédiés passent. Les huit contrôles moteur du socle passent,
|
||||
avec conservation des cavités et eaux naturelles. Les trois tailles et les deux
|
||||
valeurs de l’option passent le stockage réel des paramètres sur disque.
|
||||
|
||||
La planification des réseaux prend environ une seconde après celle de la ville
|
||||
sur le témoin 0 de ce Mac : 915 colonnes lues et 11 974 sondages, pour des plafonds
|
||||
respectifs de 5 000 et 250 000. Cela ne mesure ni le rendu client ni le chargement
|
||||
complet sur une autre machine. Rapports locaux ignorés :
|
||||
`build/alpha17-evidence/ruins17-create.json` et `ruins17-reload.json`.
|
||||
Logs : `build/alpha17-native-create-final.log`, `alpha17-native-reload.log`,
|
||||
`alpha17-first-native-base.log`, `alpha17-planner-piece-smokes.log`.
|
||||
|
||||
L’essai natif avec **Structures expérimentales désactivées** passe : plan vide,
|
||||
zéro sondage expérimental et booléen conservé. Rapport :
|
||||
`build/alpha17-evidence/ruins17-disabled.json`, log `build/alpha17-native-disabled.log`.
|
||||
La création et la réouverture actives utilisent les processus 32029 et 32123 ; le
|
||||
cas désactivé utilise 32176. Aucune sauvegarde personnelle n’a été ouverte.
|
||||
|
||||
La création et la réouverture des mondes de génération **16** passent sous le
|
||||
binaire 17 : les 71 pièces de la ville initiale et du continent témoin gardent
|
||||
leurs empreintes natives, ainsi que le journal, les paramètres et le bloc d’or
|
||||
témoin de la version publiée. Rapports :
|
||||
`build/alpha17-evidence/legacy16-create.json` et `legacy16-reload.json`.
|
||||
Le preset 16 historique est également identique octet par octet. La comparaison
|
||||
avec les reçus publiés est consignée dans `legacy16-comparison.json` :
|
||||
création sous 17 dans le processus 32244, réouverture dans 32429.
|
||||
|
||||
`./gradlew check build assemblePack` passe : 52 tâches, huit GameTests moteur
|
||||
et les trois smokes du prototype. Le JAR de production exclut les classes de
|
||||
test ; les versions, les hashes du pack, le MRpack et le ZIP d’amorçage Prism
|
||||
sont vérifiés. Journal final : `build/alpha17-final-check-build-pack.log`.
|
||||
|
||||
La release et le canal packwiz sont publiés. Deux synchronisations isolées puis
|
||||
deux dans la même instance Prism passent ; les 767 fichiers personnels et
|
||||
réglages suivis gardent leurs hashes. Le JAR 16 et le suivi packwiz précédent
|
||||
sont sauvegardés hors de `mods/`. Voir [le reçu de livraison](packwiz.md).
|
||||
|
||||
Aucun essai Windows natif ni revue visuelle dans le client n’est revendiqué ;
|
||||
le prochain essai de Koka validera l’ambiance.
|
||||
@@ -0,0 +1,173 @@
|
||||
# Alpha.19 — anciennes infrastructures de Sanctuary
|
||||
|
||||
**État : alpha.19 publiée, même instance Prism synchronisée et vérifiée.** Ticket WG-22, branche
|
||||
`codex/heritage-worldgen-alpha19`. Minecraft 26.3-pre-2, mêmes dépendances que
|
||||
l’alpha.17. La numérotation 19 est demandée explicitement ; aucune 18 n’est
|
||||
intercalée dans ce dépôt.
|
||||
|
||||
## Périmètre et réversibilité
|
||||
|
||||
Première passe des quatorze lieux du [catalogue](structures-audit.md) : gare de
|
||||
marchandises, centre postal, dépôt communal, cartothèque, observatoire,
|
||||
maintenance ferroviaire, pompage, sous-station, serre, quai d’expédition,
|
||||
relais radio, club photo, vestiaires/salles étranges et refuge de chantier.
|
||||
Les plans recherchent des sites admissibles et des voies de fret ou chemins
|
||||
entre destinations réelles ; ils ne garantissent pas tous les lieux sur chaque
|
||||
graine. Les mesures et coordonnées des essais figurent ci-dessous.
|
||||
|
||||
Créer un **nouveau monde Sanctuary** et utiliser **Personnaliser** :
|
||||
Petit 512, Moyen 724 par défaut ou Grand 1 024, avec **Structures expérimentales**.
|
||||
L’option est activée par défaut et enregistrée avec le générateur 19. La désactiver
|
||||
pour un autre nouveau monde conserve le socle urbain sans les réseaux
|
||||
expérimentaux 17 et les infrastructures 19. Elle ne retire aucun bloc ni ne change
|
||||
les règles d’une sauvegarde existante.
|
||||
|
||||
Les mondes 17 et antérieurs gardent leurs codecs, plans, pièces et futurs chunks.
|
||||
Le preset 17 public est conservé octet par octet sous un identifiant historique
|
||||
caché à la création. Le générateur 19 et son journal sont distincts. Il n’existe
|
||||
aucune conversion ou rétrogradation de sauvegarde 19 vers un ancien binaire.
|
||||
L’ancienne release reste disponible pour ses propres mondes, et l’option d’un
|
||||
nouveau monde permet de comparer les deux configurations du nouveau binaire.
|
||||
|
||||
Les nouveaux lieux sont réservés à l’île initiale. La ville et les anciens
|
||||
réseaux servent de base ; leurs protections et celles de l’hydrologie restent
|
||||
respectées. Les liaisons vers de futurs continents seront construites après
|
||||
leur apparition, sans présumer leurs emplacements dans cette génération.
|
||||
|
||||
## Objets et systèmes
|
||||
|
||||
Les bâtiments suggèrent leurs usages futurs par leurs pièces et aménagements.
|
||||
Mailbox, deposit boxes, caméra, réacteur, progression et récompenses
|
||||
personnelles ne sont pas activés. Chaque nouveau lieu dispose d’une table
|
||||
de coffre propre à la génération 19, avec un outil vanilla lié à son usage et
|
||||
quelques fournitures. Les coffres sont partagés et ne se renouvellent pas.
|
||||
|
||||
La valeur des rails, équipements et blocs récupérables du décor s’ajoute au
|
||||
butin. Leur distribution doit être constatée sur les témoins, sans transformer
|
||||
cette première passe en une garantie d’équilibrage économique pour la bêta.
|
||||
|
||||
## Vérifications effectuées
|
||||
|
||||
- Placement des quatorze architectures, ouvertures, circulation, orientation
|
||||
et appuis du mobilier ; accès réel au coffre et retour possible.
|
||||
- Plans bornés sur les trois tailles, terrain non occupé, supports, continuité
|
||||
des tracés et distinction entre ballast praticable et rails retirés.
|
||||
- Pièces natives et références locales, y compris pour les longues voies ;
|
||||
géométrie, orientation et rails sauvegardés dans un schéma strict.
|
||||
- Création et réouverture d’un monde 19 avec modification témoin ; conservation
|
||||
des starts, butins différés, accès, routes et supports.
|
||||
- Option désactivée, paramètres sauvegardés, génération 17 comparée aux preuves
|
||||
de la version publiée, refus des migrations implicites de journaux.
|
||||
- `./gradlew check build assemblePack`, artefacts et index, publication immuable
|
||||
puis canal packwiz et deux synchronisations de la même instance Prism.
|
||||
|
||||
Les smokes des quatorze architectures, de leurs quatre orientations, des
|
||||
plans sur les trois tailles et des pièces natives passent. Le mode désactivé
|
||||
est vérifié dans un monde 19 neuf : aucun site ni liaison expérimental.
|
||||
`./gradlew check build assemblePack` passe, avec les huit GameTests généraux,
|
||||
125 chunks FULL, 5 923 blocs d’eau conservés et 1 800 ticks de fluides.
|
||||
Le JAR, le MRpack, les hashes du pack et le ZIP Prism sont vérifiés.
|
||||
La release et le canal packwiz sont publiés. Deux synchronisations isolées puis
|
||||
deux dans la même instance Prism passent ; 767 fichiers personnels et réglages
|
||||
suivis conservent leurs hashes. Le JAR précédent et le suivi packwiz sont
|
||||
sauvegardés dans `sanctuary-backups/before-0.1.0-alpha.19/`.
|
||||
Voir [le reçu de distribution](packwiz.md#alpha19-publiée--9-septembre-2026).
|
||||
Aucun essai visuel dans le client ni essai Windows natif n’a été effectué.
|
||||
|
||||
## Témoin natif Moyen, graine 0
|
||||
|
||||
Créer un nouveau monde avec **Structures expérimentales activées**.
|
||||
`/sanctuary structures` liste les entrées prévues sans charger de chunks ;
|
||||
`/locate structure sanctuary:heritage_networks` retrouve l’entrée d’un site.
|
||||
Le relevé ci-dessous provient des placements et parcours vérifiés par le moteur.
|
||||
|
||||

|
||||
|
||||
Treize des quatorze lieux apparaissent. **La cartothèque est absente de cette
|
||||
graine** : la recherche n’a pas trouvé d’emplacement admissible. La ville et les
|
||||
trois réseaux souterrains 17 sont conservés. Les raccords recherchés vers la
|
||||
ville et l’ancienne mine ne sont pas admis sur ce témoin.
|
||||
|
||||
Les sept liaisons comprennent un tracé de fret **dépôt → pompage de 499 blocs**,
|
||||
d’une étendue de 245 blocs sur X et 181 sur Z. Son ballast est praticable ;
|
||||
478 rails sont présents et 21 sont volontairement manquants. Les autres voies
|
||||
et chemins relient gare, dépôt, atelier, poste, club photo, serre et vestiaires.
|
||||
Le quai, l’observatoire, le relais, la sous-station et le refuge gardent leurs
|
||||
propres accès au terrain, même sans liaison avec ce réseau.
|
||||
|
||||
La création et la réouverture dans deux JVM distinctes passent chacune les deux
|
||||
GameTests : **112 pièces natives, 111 chunks FULL, 80 487 blocs contrôlés,
|
||||
2 882 contrôles de portance, 13 coffres différés et 68 cibles accessibles**.
|
||||
Trois tirages de chacune des quatorze tables de butin sont vérifiés
|
||||
séparément, sans ouvrir les coffres persistants. Une modification témoin à
|
||||
`-204 / 255 / -13` est conservée à la réouverture.
|
||||
|
||||
Empreinte stable du plan :
|
||||
`7f476c2ecca703a05bf88a550c61cc5cbeff2594024c43ccd9f3620bbc2ad3de`.
|
||||
JVM de création `36366`, de réouverture `36481`. Reçus locaux ignorés :
|
||||
`build/alpha19-evidence/heritage19-create.json` et `heritage19-reload.json`.
|
||||
|
||||
La planification supplémentaire utilise 15 144 colonnes natives, dans un plafond
|
||||
de 16 000, en 20,7 s sur le Mac de développement. Ce temps concerne ce plan,
|
||||
pas le chargement total du jeu. Les routes sont découpées par chunk ; aucun
|
||||
start ne couvre toute leur longueur ni ne charge une destination lointaine.
|
||||
|
||||
La prospection 19 consulte les plans hydrologiques de toute l’enveloppe de
|
||||
départ, avant de publier les sites. Le contrôle général utilise donc pour 19
|
||||
une limite géographique calculée : 5 régions possibles pour Petit, 9 pour
|
||||
Moyen/Grand, avec les budgets de colonnes et de sondes contrôlés séparément.
|
||||
Le témoin Moyen en consulte 5 ; les quatre régions périphériques représentent
|
||||
3,38 s de calcul hydrologique supplémentaire dans le relevé du build.
|
||||
Ces consultations ne matérialisent aucun chunk. Les générations historiques
|
||||
conservent le contrôle initial de 4 régions maximum autour du spawn.
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryHeritage19Tests=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryHeritage19Tests=true -PsanctuaryExpansionReload=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
|
||||
```
|
||||
|
||||
## Autres tailles et couverture du catalogue
|
||||
|
||||
Les deux GameTests natifs passent aussi sur Petit et Grand, graine 42.
|
||||
Les quatorze architectures ont ainsi chacune été rencontrées et parcourues
|
||||
sur le terrain réel, en complément des rotations vérifiées par les smokes.
|
||||
|
||||
| Taille et graine | Lieux | Liaisons | Pièces natives | Chunks FULL |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Petit 512, 42 | 10 | 2 | 35 | 44 |
|
||||
| Moyen 724, 0 | 13 | 7 | 112 | 111 |
|
||||
| Grand 1 024, 42 | 13 | 4 | 47 | 61 |
|
||||
|
||||
Pour visiter la cartothèque : **Petit, graine 42**, entrée
|
||||
`-101 / 251 / -25`, ou **Grand, graine 42**, entrée `-252 / 249 / 146`.
|
||||
Le Petit testé n’a ni poste, ni observatoire, ni atelier ferroviaire, ni quai.
|
||||
Le Grand testé n’a pas de quai. Aucun grand axe n’est admis sur ces deux témoins :
|
||||
les portées maximales de leurs liaisons sont respectivement de 32 et 55 blocs.
|
||||
Le témoin Moyen, graine 0, reste celui conseillé pour découvrir le grand fret.
|
||||
Ces résultats rendent explicite la variation de cette première passe ; ils ne
|
||||
constituent pas une garantie de présence ou de connectivité pour chaque graine.
|
||||
|
||||
Reçus locaux ignorés : `build/alpha19-evidence/small42-create.json` (JVM 36591)
|
||||
et `large42-create.json` (JVM 36669).
|
||||
|
||||
## Compatibilité 17 vérifiée
|
||||
|
||||
Deux JVM de développement distinctes créent puis rouvrent le témoin 17
|
||||
**Moyen, graine 0**, avec le nouveau binaire. Les deux GameTests passent dans
|
||||
chaque exécution. Le codec, les starts natifs, les butins différés et les
|
||||
parcours restent identiques aux preuves de l’alpha.17 publiée. Le bloc d’or
|
||||
placé par le test est conservé à la réouverture.
|
||||
|
||||
Empreinte du plan 17 :
|
||||
`7358ecd7841c03be1977a7f45f37c0c3ef83a32dc27e8d3ec4f0d85d6ee5d3ef`.
|
||||
Les trois starts restent ceux documentés dans [le témoin 17](testing-alpha17.md).
|
||||
Reçus locaux ignorés : `build/alpha19-evidence/legacy17-create.json` et
|
||||
`legacy17-reload.json`.
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryRuins17Tests=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryRuins17Tests=true -PsanctuaryExpansionReload=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
|
||||
```
|
||||
|
||||
Ces commandes visent uniquement le monde de développement ignoré. La première
|
||||
le recrée ; la seconde conserve ce même monde et lit ses preuves précédentes.
|
||||
@@ -0,0 +1,327 @@
|
||||
# Alpha.20 — identité architecturale
|
||||
|
||||
**État : alpha.20 publiée, même instance Prism synchronisée et vérifiée.**
|
||||
Ticket WG-23, branche `codex/architecture-alpha20`.
|
||||
Minecraft 26.3-pre-2, dépendances identiques à la version 19 publiée.
|
||||
|
||||
## Intention
|
||||
|
||||
Chaque fonction reçoit une palette et une composition propre : bois clairs et
|
||||
grandes baies des lieux publics, pierre et tuf des installations techniques,
|
||||
loggias et retraits des logements. Les détails utilisent les blocs vanilla :
|
||||
tuf ciselé, briques de tuf ciselées, briques de pierre sculptées, bois et
|
||||
planches de chêne/bouleau, granit, diorite et andésite. Moulures, liserés,
|
||||
encadrements, murs bas et corniches utilisent escaliers, dalles et trappes.
|
||||
Les fondations restent en briques de pierre. Le [catalogue architectural](architecture-alpha20.md)
|
||||
décrit les formes et les matériaux effectivement construits pour chaque usage.
|
||||
|
||||
La passe concerne les quatorze infrastructures, la ville et ses sous-sols,
|
||||
ainsi que les petits réseaux oubliés. Elle ne déplace pas les sites ni les
|
||||
routes pour obtenir ses silhouettes. Les plans 19/17/16 servent d’emprises
|
||||
certifiées aux nouveaux rendus 20.
|
||||
|
||||
## Connexions des blocs
|
||||
|
||||
La géométrie complète d’une pièce est assemblée avant le découpage par chunk.
|
||||
Les états des vitres, grilles, clôtures et murets sont calculés à partir des voisins
|
||||
réellement présents dans ce plan ; les escaliers reçoivent leurs angles.
|
||||
Cela évite d’écrire des vitres sans raccord malgré leur juxtaposition, ou de
|
||||
dépendre de l’ordre de chargement des chunks voisins. Aucun chunk extérieur
|
||||
n’est chargé pour compléter ces états.
|
||||
|
||||
## Sauvegardes et réversibilité
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour voir la génération 20. Le bouton
|
||||
natif **Personnaliser** garde Petit 512, Moyen 724 et Grand 1 024.
|
||||
**Structures expérimentales** reste activé par défaut, enregistré avec le
|
||||
monde ; le désactiver dans un autre nouveau monde garde la ville sans les
|
||||
réseaux expérimentaux. Les détails urbains de la version 20 restent présents.
|
||||
|
||||
Le nouveau codec `sanctuary:island_v20`, son journal et ses types de pièces
|
||||
distinguent les nouveaux rendus des précédents. Les mondes 19 et antérieurs
|
||||
gardent leurs codecs, leurs pièces et leurs futurs chunks. Aucun monde
|
||||
personnel n’est ouvert ni rénové par la mise à jour. Une sauvegarde 20 ne se
|
||||
rétrograde pas vers un ancien binaire ; la release 19 reste disponible pour
|
||||
ses propres mondes.
|
||||
|
||||
## Vérification avant livraison
|
||||
|
||||
- Parcours des entrées, pièces et coffres, quatre orientations, marches,
|
||||
supports et limites d’écriture pour chaque type.
|
||||
- Connexions des vitres et grilles, angles d’escaliers, comparaison du rendu
|
||||
complet et du rendu découpé selon plusieurs ordres de chunks.
|
||||
- Création et réouverture natives, option désactivée, ancien témoin 19.
|
||||
- `check build assemblePack`, intégrité des artefacts, publication immuable,
|
||||
deux synchronisations isolées puis deux dans la même instance Prism.
|
||||
|
||||
## Reçus natifs : infrastructures et réseaux oubliés
|
||||
|
||||
**Création et réouverture réussies**, dans deux JVM distinctes, pour un monde
|
||||
jetable **Moyen 724, graine 0**, structures et structures expérimentales activées,
|
||||
codec `sanctuary:island_v20`. Les rapports locaux conservés sont
|
||||
`build/alpha20-evidence/heritage20-create.json` (processus 39381) et
|
||||
`build/alpha20-evidence/heritage20-reload.json` (processus 39736).
|
||||
Les journaux correspondants sont `build/alpha20-heritage-create-2.log` et
|
||||
`build/alpha20-heritage-reload.log`. Ces fichiers de développement restent
|
||||
ignorés par Git ; les valeurs utiles sont transcrites ci-dessous.
|
||||
|
||||
| Vérification | Création | Réouverture |
|
||||
| --- | --- | --- |
|
||||
| Sites expérimentaux naturels | 13 types présents sur 14 | Mêmes 13 sites |
|
||||
| Structures natives des sites et voies | 85 starts, 112 pièces | Mêmes empreintes NBT et références |
|
||||
| Positions de blocs contrôlées pour sites et voies | 80 487 | 80 487, hors modification témoin autorisée |
|
||||
| Chunks `FULL` contrôlés pour sites, voies et réseaux oubliés | 120 | 120 |
|
||||
| Contrôles de roche porteuse | 2 882 | 2 882 |
|
||||
| Coffres des sites | 13 butins différés, non ouverts | Tables et graines conservées |
|
||||
| Réseaux oubliés | 3 réseaux, 15 pièces, 7 639 positions contrôlées | Mêmes plans et structures natives |
|
||||
| Coffres des réseaux oubliés | 3 butins différés, non ouverts | Tables et graines conservées |
|
||||
| Parcours depuis les entrées | 68 cibles dans les sites et 49 dans les réseaux oubliés | Parcours revérifiés |
|
||||
| Modification témoin | Bloc d’or placé en `-203 255 -13` | Bloc d’or conservé |
|
||||
|
||||
La cartothèque d’expédition (`EXPEDITION_OFFICE`) n’a pas trouvé d’emplacement
|
||||
sur cette graine. Elle fait partie du catalogue et de ses smokes, mais ce reçu
|
||||
ne constitue pas une preuve de sa génération naturelle. Les quatorze tables
|
||||
de butin sont échantillonnées séparément sans consommer les coffres sauvegardés.
|
||||
Les portes et trappes ouvertes pendant le parcours retrouvent leur état initial.
|
||||
Aucune expansion ni sauvegarde personnelle n’est ouverte dans ces essais.
|
||||
|
||||
Le plan des infrastructures conserve l’empreinte
|
||||
`7f476c2ecca703a05bf88a550c61cc5cbeff2594024c43ccd9f3620bbc2ad3de`
|
||||
entre création et réouverture. Celui des réseaux oubliés conserve
|
||||
`7358ecd7841c03be1977a7f45f37c0c3ef83a32dc27e8d3ec4f0d85d6ee5d3ef`.
|
||||
Les pièces passent aussi un aller-retour NBT exact et refusent un numéro de
|
||||
schéma inconnu.
|
||||
|
||||
### Portée des vérifications de connexion
|
||||
|
||||
Les états complets de **594 blocs détaillés** des infrastructures sont comparés
|
||||
au rendu : 362 vitres ou barreaux, 49 clôtures, 18 murets et 165 escaliers.
|
||||
Sur ce total, **565** sont aussi confrontés aux règles natives de mise à jour
|
||||
des voisins (`updateShape`), avec les tags du serveur chargés. **Onze raccords
|
||||
de vitres ou barreaux traversent une frontière de chunk** et restent connectés.
|
||||
|
||||
Les **29 autres blocs détaillés** ont un voisinage extérieur au plan : leur
|
||||
état sauvegardé est bien comparé au rendu, mais ils sont exclus de cet oracle
|
||||
natif. Le compteur `externalNeighbors=29` désigne ces 29 blocs contrôlés,
|
||||
non un nombre de voisins uniques ; aucune stabilité complète vis-à-vis de ce
|
||||
voisinage extérieur n’est revendiquée. Les réseaux oubliés passent pour leur
|
||||
part **211 comparaisons d’états et 211 vérifications natives** : 6 vitres ou
|
||||
barreaux, 1 clôture et 204 escaliers. Ces résultats sont identiques à la
|
||||
réouverture.
|
||||
|
||||
### Coordonnées du monde témoin
|
||||
|
||||
Créer un nouveau monde **Moyen, graine 0**, avec les deux options de structures
|
||||
activées. Les coordonnées indiquent les pieds à l’entrée, pas le centre d’un
|
||||
toit. `/sanctuary structures` liste les lieux ;
|
||||
`/locate structure sanctuary:heritage_networks`, recherché depuis `0 250 0`
|
||||
dans le test, désigne l’entrée du refuge de chantier.
|
||||
|
||||
| Lieu | Entrée X Y Z |
|
||||
| --- | --- |
|
||||
| Gare de fret | `-110 252 -144` |
|
||||
| Dépôt communal | `-75 254 -184` |
|
||||
| Atelier ferroviaire | `-83 253 -83` |
|
||||
| Château d’eau | `164 252 -3` |
|
||||
| Serre | `173 252 -42` |
|
||||
| Observatoire | `51 251 210` |
|
||||
| Quai sur le vide | `184 181 218` |
|
||||
| Refuge de chantier | `-10 251 -15` |
|
||||
| Réseau minier oublié | `78 252 179` |
|
||||
| Salles étranges oubliées | `-194 254 -22` |
|
||||
| Réseau technique oublié | `116 253 -157` |
|
||||
|
||||
Sept liaisons sont parcourues dans le monde natif. La voie dépôt–château d’eau
|
||||
compte **499 positions**, 478 rails présents et 21 rails volontairement absents,
|
||||
avec une emprise de 245 blocs en X et 181 en Z. Le test prouve un parcours à pied,
|
||||
le ballast et les formes de rails sauvegardées ; il ne prouve pas un trajet
|
||||
ininterrompu en wagonnet sur ces voies abandonnées.
|
||||
|
||||
## Reçu natif : ville, expansion et grands sous-sols
|
||||
|
||||
**Création réussie ; réouverture encore en cours.** Le rapport
|
||||
`build/alpha20-evidence/city20-create.json` (processus 40221), associé au journal
|
||||
`build/alpha20-city-create.log`, couvre deux villes de génération 20 dans un
|
||||
monde de développement jetable. L’île initiale est **Moyen 724, graine 0**.
|
||||
Après trois prospections en lecture seule, une expansion de diamètre **256**
|
||||
est effectivement préparée : `city16_0_2`, graine `9111000990819250332`, centre
|
||||
`2000 -608`, climat `lost_city`. Les deux premiers prospects sont restés sans
|
||||
ville et n’ont pas été activés. L’identifiant de test historique ne change pas
|
||||
la version 20 enregistrée dans cette expansion.
|
||||
|
||||
| Mesure | Ville initiale | Ville d’expansion |
|
||||
| --- | --- | --- |
|
||||
| Pièces natives | 48 | 23 |
|
||||
| Chunks `FULL` inspectés autour des districts | 43 | 19 |
|
||||
| Bâtiments parcourus | Immeuble, hôtel, bibliothèque | Immeuble, hall |
|
||||
| Cibles intérieures et de toit atteintes | 65 | 46 |
|
||||
| Entrées reliées par les rues et les porches seuls | 5 | 2 |
|
||||
| Pièces de rue ou pont traversées | 33 | 17, dont un pont |
|
||||
| Appuis contrôlés | 71 | 3 |
|
||||
| Coffres gardant leur butin différé, non ouvert | 3 | 2 |
|
||||
| Témoins de biome urbain | 38 | 19 |
|
||||
|
||||
Les **71 pièces** sont issues des plans naturels des deux îles. Le parcours
|
||||
urbain ne prend aucun raccourci par le terrain naturel, les intérieurs ou les
|
||||
toits : il confirme la continuité des entrées par les rues dessinées.
|
||||
La table native d’apparition des monstres est vérifiée comme limitée aux
|
||||
zombies vanilla ; cela ne mesure pas un nombre de zombies effectivement apparus.
|
||||
Le pont de l’expansion se traverse de `2000 226 -613` à `2000 224 -597`.
|
||||
Aucune pile de viaduc n’est présente dans ces deux districts.
|
||||
|
||||
Vingt-quatre pièces de test supplémentaires couvrent la sauvegarde des six
|
||||
modèles de bâtiments dans quatre orientations. Ce sont des témoins de
|
||||
sérialisation, pas vingt-quatre bâtiments naturels. Restaurant et caserne,
|
||||
ainsi que la piscine extérieure, ne sont pas présents dans ces deux villes ;
|
||||
leurs smokes ne constituent pas une preuve d’apparition naturelle ici.
|
||||
|
||||
### Grands équipements du témoin initial
|
||||
|
||||
| Repère | Géométrie ou coordonnées constatées |
|
||||
| --- | --- |
|
||||
| Entrée de l’immeuble | `-30 252 21` |
|
||||
| Entrée de l’hôtel | `-8 252 21` |
|
||||
| Entrée de la bibliothèque | `-52 252 43` |
|
||||
| Trappe d’accès aux sous-sols, position du bloc | `-19 252 41` |
|
||||
| Grande piscine, emprise du bâtiment | De `-47 224 -26` à `9 236 4` |
|
||||
| Volume d’eau du bassin | 50 × 25 blocs, quatre couches ; de `-44 225 -23` à `5 228 1`, avec les marches dans cette emprise |
|
||||
| Grande salle sèche, emprise | De `-54 228 8` à `-30 237 32`, niveau d’entrée 229 |
|
||||
| Parking, emprise | De `-8 116 8` à `22 237 32` |
|
||||
| Huit planchers du parking, niveaux Y | `117, 133, 149, 165, 181, 197, 213, 229` |
|
||||
| Bouche de sortie de l’égout | `-50 117 20` |
|
||||
|
||||
La piscine conserve ses 40 appuis, la grande salle 16, le parking 9 et la
|
||||
sortie d’égout 6. Ces présences sont constatées sur cette graine ; elles ne
|
||||
sont pas garanties sur toutes les îles.
|
||||
|
||||
### Fluides et connexions en création
|
||||
|
||||
L’essai observe **120 ticks du monde** après la mise en activité des chunks.
|
||||
Dix-neuf petits témoins temporaires fermés, placés dans le ciel, démontrent
|
||||
l’exécution réelle des fluides par une descente d’eau observée dans chaque
|
||||
chunk concerné. Ils sont retirés avant la sauvegarde et exclus des quantités
|
||||
d’eau des structures.
|
||||
|
||||
Après cette simulation, le grand bassin garde **4 976 cellules d’eau** dans
|
||||
son enceinte, et chaque égout conserve ses cinq cellules contrôlées. Les deux
|
||||
bouches gardent leur source et produisent de l’eau à l’extérieur. Leur écoulement
|
||||
rencontre cependant du terrain : premier bloc solide sous les sorties aux
|
||||
niveaux **113** et **191**. Ce témoin prouve des sorties d’eau actives, pas une
|
||||
chute libre continue jusqu’au vide.
|
||||
|
||||
Les chiffres de connexion suivants sont ceux de l’inspection initiale des
|
||||
pièces, avant la simulation des fluides. Le contrôle de réouverture ci-dessous complète ces premières mesures.
|
||||
|
||||
| États détaillés | Ville initiale | Expansion | Total |
|
||||
| --- | --- | --- | --- |
|
||||
| Vitres ou barreaux | 1 457 | 577 | 2 034 |
|
||||
| Clôtures | 36 | 14 | 50 |
|
||||
| Escaliers | 1 451 | 699 | 2 150 |
|
||||
| Comparaisons complètes au rendu | 2 944 | 1 290 | 4 234 |
|
||||
| Vérifications supplémentaires par règles natives | 2 277 | 1 276 | 3 553 |
|
||||
| Blocs exclus de cet oracle pour voisinage extérieur | 667 | 14 | 681 |
|
||||
| Raccords de vitres/barreaux à travers des frontières de chunks | 82 | 22 | 104 |
|
||||
|
||||
Les **681 exclusions** gardent leur comparaison d’état au rendu. Elles ne
|
||||
prouvent pas la stabilité de la connexion face au voisinage extérieur, comme
|
||||
pour les infrastructures. Aucun muret n’est représenté dans cette partie du
|
||||
contrôle urbain. Le bloc d’or témoin est placé en `-38 252 21` et le journal
|
||||
est sauvegardé avec l’empreinte
|
||||
`e776dcab879458b3268ad34f44a003cb53b8b03cfcb67378559d40732f8ce73f` ;
|
||||
ils sont conservés à la réouverture.
|
||||
|
||||
### Réouverture de la ville et raccords avec le terrain
|
||||
|
||||
La réouverture passe dans une seconde JVM (40687), avec **71 pièces natives**,
|
||||
**5 coffres différés**, **74 appuis**, **111 cibles accessibles**, le bloc d’or et
|
||||
le journal conservés. Reçu : `build/alpha20-evidence/city20-reload.json` ; journal
|
||||
`build/alpha20-city-reload-2.log`. La piscine et les sorties d’eau sont revérifiées.
|
||||
|
||||
Le test confronte les raccords au voisinage réellement présent. Au coin du
|
||||
parking `-8 118 8`, les grilles touchent de la deepslate au nord et à l’ouest :
|
||||
Minecraft ajoute légitimement ces raccords après une mise à jour de blocs.
|
||||
Le contrôle refuse tout changement de matériau, orientation, demi-bloc ou
|
||||
waterlogging ; seules les propriétés de raccord peuvent suivre les règles
|
||||
natives. À l’intérieur d’une géométrie connue, l’état du rendu reste exigé.
|
||||
|
||||
Sur les **4 234 blocs détaillés**, **4 178** sont alors des états stables selon
|
||||
l’oracle Minecraft ; **640** de ces états ont un voisinage extérieur ou un
|
||||
voisin ayant lui-même évolué. **56** autres gardent exactement leur état de
|
||||
plan et attendent une mise à jour native extérieure : ils sont comptés
|
||||
séparément, sans revendiquer leur stabilisation. Les **104 raccords entre
|
||||
chunks** restent présents. `externalNeighbors=696` compte les blocs dont le
|
||||
voisinage n’est plus entièrement identique au plan, dont les 640 + 56 cas ;
|
||||
ce compteur a donc une portée plus large que les 681 exclusions initiales.
|
||||
|
||||
## Compatibilité avec la version 19 publiée
|
||||
|
||||
Avec le binaire 20, les deux rapports natifs 19 sont **identiques intégralement**
|
||||
aux reçus publiés, en retirant seulement le PID et le chemin du monde de test.
|
||||
La comparaison porte sur plans, pièces, paramètres, parcours, supports, butins
|
||||
et modification témoin, à la création comme à la réouverture.
|
||||
|
||||
- Création canonique SHA-256 : `3b055a3bc0fc2ddf4146aef035f5103bb7fe20d55a86fa1d4b87aeedc02f7879`.
|
||||
- Réouverture canonique SHA-256 : `aed0962f8dfbfbbf69da1b2153861801c58a3ea2376e6bbec2ef44b90d555d63`.
|
||||
- Référence source publiée : `c23e72b8e6fd8308855459f26c08082a68e81b61`.
|
||||
- Reçus : `build/alpha20-evidence/legacy19-create.json` et `legacy19-reload.json`.
|
||||
|
||||
Les renderers, modèles et pièces historiques restent inchangés ; le preset 19
|
||||
caché est identique octet par octet au preset public de la version publiée.
|
||||
Les journaux refusent les chargements croisés 19/20 au lieu de convertir un monde.
|
||||
|
||||
## Option désactivée et petite île
|
||||
|
||||
Le scénario **Moyen, graine 0, option désactivée** passe : plan expérimental
|
||||
vide, aucun start natif expérimental et aucune entrée localisable. Le paramètre
|
||||
est enregistré. Reçu : `build/alpha20-evidence/experimental-off.json`.
|
||||
|
||||
Le témoin **Petit 512, graine 42, option activée** passe avec dix types de lieux,
|
||||
35 pièces de sites/voies et 50 chunks `FULL` contrôlés, réseaux oubliés compris.
|
||||
La cartothèque est présente, entrée **`-101 251 -25`**, et ses cinq cibles sont
|
||||
accessibles. Les **436 états détaillés** des infrastructures passent l’oracle
|
||||
natif, dont un avec voisin extérieur ; vingt raccords traversent des frontières
|
||||
de chunks. Reçu : `build/alpha20-evidence/small42-create.json`.
|
||||
|
||||
Les deux témoins 20 réunis rencontrent et parcourent les **quatorze types
|
||||
d’infrastructures**. Ils ne garantissent pas leur présence sur toutes les îles.
|
||||
La génération native 20 Grand n’est pas rejouée ici : ses réglages sont couverts
|
||||
par les contrôles de codec et les plans de placement conservés. Les six modèles
|
||||
urbains sont vérifiés dans les quatre orientations ; leur apparition naturelle
|
||||
est distinguée des fixtures ci-dessus.
|
||||
|
||||
## Prévisualisation et état de livraison
|
||||
|
||||
Le [montage du catalogue](architecture-alpha20.md) provient des exports de
|
||||
blocs des renderers et des modèles Minecraft. Il permet de comparer les
|
||||
silhouettes, matières, escaliers, vitres et corniches. Il ne montre ni le
|
||||
terrain final ni le rendu d’un client avec ses shaders ; ce n’est pas une
|
||||
capture de partie et il ne remplace pas les essais de circulation ou de lumière.
|
||||
|
||||
| Étape | État de ce reçu |
|
||||
| --- | --- |
|
||||
| Infrastructures et petits réseaux : création/réouverture natives | Réussi, dans le périmètre détaillé ci-dessus |
|
||||
| Ville 20, espaces publics et grands sous-sols : essais natifs | Création et réouverture réussies, 71 pièces et 5 coffres conservés |
|
||||
| Compatibilité avec le témoin 19 publié | Rapports complets identiques à la création et à la réouverture |
|
||||
| Autres configurations | Petit 512/graine 42 et option désactivée passent ; Grand 20 non rejoué nativement |
|
||||
| `check build assemblePack` final et intégrité des artefacts | Réussi ; huit GameTests généraux, JAR/MRpack/ZIP et hashes vérifiés |
|
||||
| Publication, canal packwiz et synchronisations Prism | Réussi ; deux passages isolés puis deux dans la même instance, 925 fichiers personnels et réglages préservés |
|
||||
|
||||
`./gradlew check build assemblePack --max-workers=1` passe en 4 min 39 s sur
|
||||
la machine de développement : 62 tâches, dont 55 exécutées, huit GameTests,
|
||||
125 chunks `FULL`, 5 923 blocs d’eau conservés et 1 800 ticks de fluides.
|
||||
Les tests des sept nouveaux composants 20 et des anciennes générations passent.
|
||||
Les réglages des trois tailles et leur sauvegarde passent également.
|
||||
Journal : `build/alpha20-check-build-pack.log`.
|
||||
|
||||
Le JAR de production ne contient aucune classe de test. Son identité est
|
||||
vérifiée dans le MRpack et le pack local ; les index et le bootstrap officiel
|
||||
Prism sont contrôlés. SHA-256 du JAR :
|
||||
`9ea10576e641d83c13e1277960a375448a22d416a98a6b327c4d6d2a204c423a`.
|
||||
Reçu : `build/alpha20-artifact-validation.log`.
|
||||
|
||||
Release immuable depuis `0edac29`, canal packwiz `4e0967d` et deux
|
||||
synchronisations isolées vérifiés. Les deux passages dans la même instance
|
||||
Prism conservent les hashes de 925 fichiers personnels et réglages. Un seul
|
||||
JAR20 est actif ; JAR19 et suivi packwiz sont sauvegardés avant remplacement.
|
||||
Voir le [reçu de distribution](packwiz.md#alpha20-publiée--10-septembre-2026).
|
||||
Aucun essai client visuel ni essai Windows natif n’a été effectué.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Alpha.21 — contrat et vérification
|
||||
|
||||
Ticket WG-24, branche `codex/palettes-reseaux-alpha21`.
|
||||
Minecraft 26.3-pre-2, Fabric Loader 0.19.5, Fabric API 0.160.0+26.3,
|
||||
Java 25. Les dépendances restent celles de l’alpha.20.
|
||||
|
||||
## Résultat recherché
|
||||
|
||||
La ville reprend une palette de pierre lisse, pierre, briques de pierre,
|
||||
andésite et chêne. Les plans intérieurs sont conservés ; les corrections
|
||||
portent sur les matériaux, les appuis et les couvertures. Chaque infrastructure
|
||||
emploie un bois cohérent. Le tuf reste surtout au sol et dans les sous-sols.
|
||||
|
||||
La ville peut accueillir deux petits quartiers supplémentaires reliés à son
|
||||
cœur, avec du terrain naturel entre eux. La réserve centrale existante n’est
|
||||
pas agrandie. Les infrastructures reçoivent davantage de chemins entre des
|
||||
entrées réelles. Les recherches respectent des budgets de colonnes et refusent
|
||||
un quartier si sa liaison entière ne peut pas être construite.
|
||||
|
||||
Les galeries industrielles expérimentales ont cinq blocs de chaussée et un
|
||||
trottoir de chaque côté, des salles de maintenance et deux accès par escaliers.
|
||||
Le plan est recherché après les structures précédentes et évite leurs volumes,
|
||||
l’hydrologie. Un tablier continu peut franchir une cavité entre ses appuis ;
|
||||
les travées sans appuis complets sont limitées à 34 blocs, avec des piliers
|
||||
locaux d’au plus 16 blocs et deux couches de roche intacte sous les appuis. Chaque tronçon a une pièce native
|
||||
locale ; aucun démarrage de structure ne charge toute l’île.
|
||||
|
||||
## Sauvegardes et comparaison
|
||||
|
||||
Créer un **nouveau monde Sanctuary**, taille **Moyen**, graine **0**, avec
|
||||
**Structures expérimentales** activées. Petit 512, Moyen 724 et Grand 1024 restent
|
||||
les tailles proposées par le bouton natif **Personnaliser**.
|
||||
|
||||
Le nouveau codec est `sanctuary:island_v21`. Les codecs et pièces 20 et
|
||||
antérieurs restent disponibles, sans modifier leurs rendus. Le journal 21
|
||||
réutilise le schéma de persistance existant avec une génération distincte ;
|
||||
les ouvertures entre versions incompatibles sont refusées. Aucune conversion
|
||||
ni rénovation automatique des mondes précédents n’est effectuée.
|
||||
|
||||
Pour comparer sans les infrastructures, créer un autre monde avec l’option
|
||||
expérimentale désactivée. Une sauvegarde 21 nécessite le binaire 21 ou un futur
|
||||
binaire compatible ; elle ne se rétrograde pas en remplaçant simplement le JAR.
|
||||
|
||||
## Compatibilité vérifiée
|
||||
|
||||
373 fichiers de production historiques sont identiques au commit de publication
|
||||
20 (`0edac29`). Le preset caché `sanctuary_v20` est une copie exacte de son
|
||||
ancien preset public. Création et réouverture natives de la génération 20 donnent
|
||||
les mêmes plans, structures, appuis, accès et coffres que les rapports publiés.
|
||||
La comparaison ignore seulement l’identifiant du processus, le chemin du monde
|
||||
et deux nouveaux compteurs de détail ajoutés à l’oracle de connexion des tests.
|
||||
|
||||
## Galeries vérifiées dans Minecraft
|
||||
|
||||
Moyen, graine 0 : 23 modules dont deux accès et une salle, sur un axe de 340 blocs.
|
||||
Les entrées sont `-135 253 -178` et `-135 249 162` ; chaussée à Y 216. La portée
|
||||
sans appuis complets la plus longue du témoin est 17 blocs. Le plan emploie
|
||||
8 686 colonnes de bruit, avec un budget maximal de 12 000 ; cette recherche prend
|
||||
environ 11 secondes sur la machine de développement, hors reste du monde.
|
||||
|
||||
La création et la réouverture dans deux JVM distinctes passent : 34 chunks
|
||||
FULL, 57 635 blocs vérifiés, mêmes pièces natives, mêmes coffres différés et
|
||||
modification témoin conservée. L’oracle de collision de Minecraft vérifie un
|
||||
corps de 0,6×1,8 bloc, 74 cibles et 520 pas entre les deux portiques ; il utilise
|
||||
les vrais escaliers et les demi-marches des trottoirs.
|
||||
|
||||
Une alcôve d’excavation inachevée ouvre le parement de la salle sur une paroi
|
||||
irrégulière de tuf, pierre et deepslate. Certains tronçons exposent de petits
|
||||
filons ordinaires. Le témoin contient cinq minerais de charbon, dix-neuf de
|
||||
fer, cinq de redstone, un de diamant et un d’émeraude ajoutés dans ces fronts.
|
||||
Cuivre, or et lapis font aussi partie des variantes possibles. Ce sont des
|
||||
blocs finis, sans mécanisme de réapparition après minage. Ces quantités excluent
|
||||
les minerais naturels de l’île et ne constituent pas une garantie par graine.
|
||||
|
||||
## Autres vérifications
|
||||
|
||||
La création et la réouverture natives de la ville passent. Le témoin possède
|
||||
un cœur et deux quartiers reliés, 65 pièces de rue, trois immeubles de logements,
|
||||
un hôtel, une bibliothèque, un jardin et une place. La réserve centrale n’est
|
||||
pas étendue. Les deux nouveaux chemins sont longs de 51 et 38 cellules. Une
|
||||
expansion de test distincte est également vérifiée ; aucune expansion personnelle
|
||||
n’est activée.
|
||||
|
||||
La piscine couverte, le garage, la salle d’expansion, leurs accès et les égouts
|
||||
restent présents. Les contrôles natifs vérifient les bassins et sorties après
|
||||
exécution observée de la simulation des fluides.
|
||||
|
||||
Les quatorze types de lieux patrimoniaux apparaissent sur Moyen, graine 0,
|
||||
avec douze liaisons et 127 segments. Leur création native passe : 141 pièces,
|
||||
91 271 blocs, 140 chunks FULL et quatorze coffres différés. Les trois réseaux
|
||||
oubliés sont également parcourus. Les chemins rejoignent deux entrées de ville.
|
||||
L’observatoire et le quai du vide restent isolés sur ce témoin : leurs chemins
|
||||
n’ont pas trouvé de tracé admissible dans le budget. La commande `/sanctuary structures` indique leurs entrées ; aucun raccord
|
||||
construit complet n’est revendiqué.
|
||||
|
||||
Les smokes des renderers, plans, pièces enregistrées et raccords passent.
|
||||
La réouverture des lieux patrimoniaux conserve également le plan, les pièces,
|
||||
les coffres et la modification témoin. L’option expérimentale désactivée passe :
|
||||
elle ne publie aucun module de galerie. `check build assemblePack` passe, avec les huit GameTests généraux. Le
|
||||
MRpack, son index et le JAR embarqué sont vérifiés. La release et le canal
|
||||
packwiz sont publiés. Deux synchronisations isolées puis deux dans la même
|
||||
instance Prism conservent les 925 fichiers personnels et réglages suivis.
|
||||
Voir le [reçu de distribution](packwiz.md#alpha21-publiée--10-septembre-2026).
|
||||
|
||||
Les chemins de quartier regroupent leurs pentes à l’intérieur de rectangles ;
|
||||
leurs frontières et raccords sont des paliers plats. Les structures restent
|
||||
conditionnées par le terrain admissible, dans un budget borné. La traversée
|
||||
industrielle ne relie pas encore tous les réseaux souterrains historiques.
|
||||
|
||||
Les vues de modèles éventuelles ne remplacent pas une inspection dans le client.
|
||||
Aucune validation Windows native ni charge multijoueur n’est revendiquée.
|
||||
|
||||
|
||||
## Reproduire les contrôles
|
||||
|
||||
Depuis ce dépôt, avec Java 25, les mondes de test restent sous `mods/sanctuary/build/`.
|
||||
Pour chaque scénario, exécuter la création puis sa réouverture avant tout autre
|
||||
scénario qui nettoie ce monde de développement :
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryTransit21Tests=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --max-workers=1
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryTransit21Tests=true -PsanctuaryExpansionReload=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --max-workers=1
|
||||
```
|
||||
|
||||
Remplacer `sanctuaryTransit21Tests` par `sanctuaryLostCity21Tests` pour la ville
|
||||
ou `sanctuaryHeritage21Tests` pour les infrastructures. Le cas OFF ajoute
|
||||
`-PsanctuaryExperimentalStructures=false` à une création du scénario transit.
|
||||
Les rapports de cette livraison sont conservés localement dans
|
||||
`build/alpha21-evidence/`. `./gradlew check build assemblePack` constitue la
|
||||
vérification complète avant export.
|
||||
|
||||
## Artefacts vérifiés
|
||||
|
||||
MRpack : `Sanctuary-0.1.0-alpha.21.mrpack`. Son JAR est identique au JAR du build.
|
||||
Les versions et hashes de l’index packwiz sont contrôlés, ainsi que le ZIP
|
||||
d’amorçage Prism. SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
e528e42d99f9e1ea221f780ea089b49b42a1a4e9e24e4ddd001d045183202c21
|
||||
```
|
||||
@@ -0,0 +1,153 @@
|
||||
# Alpha.22 — placement sélectif
|
||||
|
||||
Ticket WG-25, branche `codex/placement-selectif-alpha22`.
|
||||
Minecraft 26.3-pre-2, Fabric Loader 0.19.5, Fabric API 0.160.0+26.3 et Java25,
|
||||
sans changement de dépendances.
|
||||
|
||||
## Contrat
|
||||
|
||||
Les bâtiments de surface redeviennent sélectifs : une ville peut être absente
|
||||
si aucun emplacement n’est adéquat. Deux éléments de base sont prioritaires :
|
||||
**la grande traversée souterraine et la salle d’expansion**, même sans ville.
|
||||
Leurs emplacements sont réservés avant les petits réseaux et le catalogue
|
||||
expérimental. Une recherche bornée ne prouve pas qu’aucun autre emplacement
|
||||
possible n’existe dans toute l’île.
|
||||
|
||||
- Ville : aucune réserve artificielle de secours, aucun pilier de rattrapage,
|
||||
terrassement/remblai limité à deux blocs par colonne. Les fondations sont
|
||||
entièrement recertifiées dans la roche. Les quartiers supplémentaires exigent
|
||||
un chemin complet vers les quartiers retenus. Au plus trois propositions dans
|
||||
un budget partagé de20 000 colonnes et1 120 000 contrôles de volume.
|
||||
- Infrastructures : relief maximal de quatre blocs sur l’emprise17×17, coupe et
|
||||
remblai limités à deux blocs, deux couches de roche intacte sous chaque colonne
|
||||
de fondation, sans piliers de secours. Jusqu’à24 candidats par famille, avec
|
||||
les mêmes plafonds globaux de16 000 colonnes et800 000 sondages.
|
||||
- Chemins patrimoniaux : coupe/remblai limité à un bloc, accès indépendant et
|
||||
raccord complet requis pour publier une liaison.
|
||||
- Traversée : conserve le génie civil21, avec deux accès réels, une chaussée
|
||||
de cinq blocs et deux trottoirs. Les piliers sont bornés à16 blocs ; les
|
||||
travées sans grille complète sont limitées à34 blocs et reposent sur les
|
||||
modules adjacents. La recherche22 intervient avant les structures facultatives
|
||||
pour leur réserver l’emprise. Si les lignes droites sont refusées, une seconde
|
||||
passe cherche des accès différents et des détours. Chaque passe est limitée à
|
||||
12 000 colonnes et3 millions de sondages (24 000 et6 millions au total au plus).
|
||||
Ses parois peuvent traverser des cavités existantes sans écraser les volumes
|
||||
d’eau et de lave protégés.
|
||||
- Salle : recherche autonome d’un accès de surface et d’une salle25×25, sans
|
||||
faux quartier ni socle de ville. Une salle déjà présente sous une ville évite
|
||||
un doublon. Les égouts et annexes peuvent accompagner ce lieu indépendant.
|
||||
|
||||
|
||||
Les palettes, les plans intérieurs et les petits filons minables restent ceux
|
||||
de21. Les anciens petits réseaux expérimentaux17 et les sous-sols des villes gardent
|
||||
leur géométrie ; leurs protections restent respectées.
|
||||
Le nouveau principe narratif est documenté dans [la vision](vision.md) et
|
||||
[l’audit](structures-audit.md), sans activer de système de progression.
|
||||
|
||||
## Sauvegardes
|
||||
|
||||
Le seul preset public utilise désormais `sanctuary:island_v22`. Le preset caché
|
||||
`sanctuary_v21` est la copie de l’ancien preset public. Les anciens codecs,
|
||||
planificateurs et rendus restent disponibles. Les pièces21 sont réutilisées
|
||||
avec leur format existant ; les nouveaux coudes possèdent une pièce22 distincte
|
||||
pour fermer leurs murs sans modifier les jonctions21 sauvegardées.
|
||||
La salle indépendante possède un type de structure22
|
||||
et réutilise les pièces souterraines20.
|
||||
Le journal identifie la génération22 et refuse une lecture croisée avec21.
|
||||
Aucun monde personnel n’est ouvert, converti ou régénéré.
|
||||
|
||||
Les nouvelles règles nécessitent un **nouveau monde Sanctuary**. Petit512,
|
||||
Moyen724 par défaut et Grand1024 restent proposés. L’option **Structures
|
||||
expérimentales** reste enregistrée à la création ; en22 elle retire le catalogue
|
||||
facultatif, tandis que la traversée et la salle restent des éléments de base.
|
||||
Le commutateur Minecraft général des structures demeure respecté.
|
||||
|
||||
## Témoin natif Moyen, graine0
|
||||
|
||||
La création et la réouverture dans deux JVM distinctes passent. La ville est
|
||||
refusée ; la traversée comporte23 modules, dont deux accès et une salle de
|
||||
maintenance. Son axe s’étend sur340 blocs. Les accès sont `-170 253 -100` et
|
||||
`170 254 -100`. Le hall d’expansion indépendant se rejoint par la trappe
|
||||
`2 252 0` ; son réseau comprend l’accès, la salle, deux passages et un égout.
|
||||
La piscine est une annexe conditionnelle, absente sur ce témoin.
|
||||
|
||||
Les14 bâtiments patrimoniaux trouvent un terrain adapté ; sept liaisons sont
|
||||
retenues. Les115 chunks de l’emprise sont générés en statut FULL. Les plans,
|
||||
pièces natives et une modification témoin persistent après réouverture. Le
|
||||
contrôle natif de circulation vérifie la vraie trappe, les échelles soutenues,
|
||||
les ouvertures et le trajet jusqu’au socle central. Aucun déplacement de joueur
|
||||
ou chargement d’un monde personnel n’a été utilisé pour ces essais.
|
||||
|
||||
## Vérifications complémentaires
|
||||
|
||||
**Petit512, graine42 :**17 modules, dont un coude sauvegardé avec sa pièce22,
|
||||
et une salle indépendante. Les anciens alignements droits sont refusés ; le
|
||||
repli trouve une paire d’accès naturels et conserve une chaussée continue.
|
||||
Les portiques sont `-68 250 -93` et `68 250 43`, la trappe du hall `43 244 -10`.
|
||||
La marche native relie les sorties en564 déplacements et atteint53 points de
|
||||
contrôle. Huit bâtiments patrimoniaux et six liaisons sont admis ; aucune ville
|
||||
n’est forcée. Les88 chunks de l’emprise sont vérifiés en statut FULL.
|
||||
|
||||
**Grand1024, graine42 :** un quartier naturel est admis avec son réseau de
|
||||
sous-sols : salle d’expansion, piscine, égout et parking. Aucun hall autonome
|
||||
supplémentaire n’est créé. La traversée comporte31 modules et s’étend sur476
|
||||
blocs ; portiques `-272 249 -8` et `204 250 -8`. La marche native les relie
|
||||
en740 déplacements et atteint98 points de contrôle. Quatorze bâtiments
|
||||
patrimoniaux et huit liaisons sont admis. Les179 chunks de l’emprise sont
|
||||
vérifiés en statut FULL ; la salle et ses17 ouvertures sont accessibles.
|
||||
|
||||
Les tests purs des trois tailles passent sur des terrains plats adaptés ;
|
||||
falaises, eau/protections, vides et appuis insuffisants sont refusés pour les
|
||||
bâtiments. Le hall autonome et ses annexes sont vérifiés sans fixture de ville.
|
||||
Les suites historiques restent actives. Avec l’option expérimentale désactivée,
|
||||
le témoin Moyen conserve ses23 modules de traversée et sa salle indépendante,
|
||||
sans bâtiments patrimoniaux ni petits réseaux expérimentaux. La marche native
|
||||
relie les deux sorties en548 déplacements et atteint74 points de contrôle ;
|
||||
les échelles et l’accès au hall restent praticables.
|
||||
|
||||
Les créations et réouvertures de génération21 sont identiques aux rapports de
|
||||
la release21, hors identifiants de processus et dossier de test. Les391 fichiers
|
||||
de production historiques qui ne nécessitent pas de raccord22 restent identiques
|
||||
octet pour octet ; le preset caché21 est la copie exacte du précédent preset
|
||||
public. Les masques sont vérifiés à l’intérieur et hors des structures.
|
||||
|
||||
`check build assemblePack` passe :75 tâches, dont les huit GameTests généraux.
|
||||
Le JAR, l’index packwiz, le MRpack et le ZIP Prism sont vérifiés. Aucun code de
|
||||
test n’est embarqué dans le JAR. SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
10d7ef32ac2e81a977ea4854503bafa29e0bc10929984b76df01d6f5f61f9056
|
||||
```
|
||||
|
||||
SHA-256 du MRpack :
|
||||
|
||||
```text
|
||||
0b553fcdd8b6a2ec26f72059e428d2cc0ce526f36ef8580dd3e50c1dfa40ec78
|
||||
```
|
||||
|
||||
Les reçus de publication et de synchronisation sont suivis dans
|
||||
[la distribution](packwiz.md).
|
||||
|
||||
Ces témoins vérifient les trois tailles, avec et sans ville ; ils ne constituent
|
||||
pas une preuve de présence pour toutes les graines. La recherche reste bornée
|
||||
et refuse un volume protégé ou insuffisamment porté. Aucun essai visuel client
|
||||
ou essai Windows natif n’est revendiqué.
|
||||
|
||||
## Reproduire dans le dossier de développement
|
||||
|
||||
Avec Java25, création du témoin Moyen puis réouverture du même monde de test :
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runGameTest -PsanctuarySelective22Tests=true -PsanctuaryDensityMaps=false
|
||||
./gradlew :sanctuary:runGameTest -PsanctuarySelective22Tests=true -PsanctuaryDensityMaps=false -PsanctuaryExpansionReload=true
|
||||
```
|
||||
|
||||
Pour une nouvelle création Petit ou Grand, ajouter `-PsanctuaryTestSeed=42`
|
||||
et `-PsanctuaryTestDiameter=512` ou `1024`. Le cas OFF utilise
|
||||
`-PsanctuaryExperimentalStructures=false`. Chaque réouverture doit suivre la
|
||||
création correspondante, avec les mêmes paramètres. Les rapports sont dans
|
||||
`mods/sanctuary/build/run/gameTest/diagnostics/selective22-<graine>-<diamètre>/`.
|
||||
|
||||
Le témoin historique utilise `-PsanctuaryHeritage21Tests=true`, puis le même
|
||||
drapeau avec `-PsanctuaryExpansionReload=true`. La vérification de livraison
|
||||
reste `./gradlew check build assemblePack`. Exécuter ces JVM séquentiellement.
|
||||
+215
-2
@@ -19,6 +19,219 @@ 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
|
||||
smokes. Les preuves exportées, journaux, paramètres, mesures et hashes figurent
|
||||
dans [le rapport alpha.13](benchmarks/alpha13-worldgen.json).
|
||||
|
||||
Le test courant charge `sanctuary:sanctuary`, puis règle son vrai générateur
|
||||
`sanctuary:island_v13` comme le fait la personnalisation. Les suites historiques
|
||||
restent accessibles avec `-PsanctuaryUnifiedTests=false`.
|
||||
|
||||
Les trois tailles passent les huit tests moteur sur la graine **42**, avec
|
||||
1 800 ticks de fluides. Chaque inspection des cavités utilise au plus 27 chunks
|
||||
FULL, choisis sur des sols et plafonds naturels ; les contrôles d’hydrologie
|
||||
emploient un autre échantillon borné. Ces volumes ne sont pas un inventaire de
|
||||
l’île et ne mesurent pas le rendu du client.
|
||||
|
||||
| Taille | Chunks FULL du diagnostic d’eau | Blocs d’eau retenus après 1 800 ticks | Pilier de spéléothèmes dans l’air naturel |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Petit, 512 | 116 | 4 722 | 25 blocs |
|
||||
| Moyen, 724 | 80 | 1 196 | 18 blocs |
|
||||
| Grand, 1 024 | 136 | 1 434 | 18 blocs |
|
||||
|
||||
Les trois cavités natives sont réellement décorées dans chacun de ces mondes :
|
||||
mousse et lianes récoltables pour le luxuriant, pointes et piliers pour les
|
||||
spéléothèmes, roche soufrée et pointes pour le soufre. Musiques, brouillard et
|
||||
table du `sulfur_cube` restent ceux des biomes Minecraft. La graine 0 présente
|
||||
seulement du luxuriant dans le raster naturel inspecté ; elle ne justifie pas
|
||||
de forcer les deux autres biomes dans la roche. Une livraison exige les preuves
|
||||
cumulées des trois types, pas leur présence sur toute graine.
|
||||
|
||||
Le garde-fou est exercé par une vraie écriture `WorldGenRegion` : eau, lit,
|
||||
parois naturelles, sources et sorties sont protégés. Quatre témoins de berge
|
||||
appartiennent à un chunk voisin de leur cellule d’eau. Une écriture hors volume
|
||||
reste possible. Le test utilise une vue modifiable de chunk dédiée, car les
|
||||
holders FULL exposent normalement une vue de génération en lecture seule.
|
||||
Les blocs témoins temporaires sont restaurés dans le monde de développement.
|
||||
|
||||
L’essai client ouvre le bouton natif **Personnaliser**, parcourt les trois
|
||||
tailles et vérifie Terminé, Annuler, Échap, réouverture et rafraîchissement des
|
||||
réglages. L’écriture/relecture réelle des paramètres conserve la taille, la
|
||||
graine, les options, le Nether et l’End. Les anciens générateurs ne deviennent
|
||||
pas personnalisables par erreur ; l’éditeur Superflat vanilla reste disponible.
|
||||
Une capture confirme la lisibilité de l’écran. Cet essai ne constitue pas une
|
||||
visite graphique de l’île.
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryTestDiameter=512 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryTestDiameter=1024 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
|
||||
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
|
||||
```
|
||||
|
||||
Les diagnostics détaillés restent dans le dossier ignoré
|
||||
`mods/sanctuary/build/run/gameTest/diagnostics/unified-<diamètre>-seed-<graine>-*.json`.
|
||||
Les anciennes mesures alpha.11 ci-dessous ne sont pas des mesures alpha.13.
|
||||
|
||||
### Mesure alpha.13 de Moyen
|
||||
|
||||
Sur le Mac M1 à 8 Go, Java 25.0.3 et heap de 2 Gio, graine 0, un seul processus
|
||||
de jeu actif : la création initiale prend **17,65 s**, puis 289 chunks FULL autour
|
||||
du spawn **46,01 s**, soit **63,66 s** cumulées. Le couloir suivant de 80 chunks
|
||||
FULL prend **7,82 s**. Le maximum observé pour une requête de chunk est de 1,30 s
|
||||
autour du spawn et de 0,79 s dans ce couloir.
|
||||
|
||||
Le protocole conserve les anneaux 17 × 17 et le couloir est de l’alpha.11.
|
||||
L’ancien Grand et le nouveau Moyen ont le même diamètre nominal de 724 blocs,
|
||||
mais la décoration et le spawn peuvent différer : ce n’est pas une mesure
|
||||
d’équivalence de génération. Le résultat de départ reste proche des 62,67 s
|
||||
mesurées pour l’alpha.11. Un seul passage par version, sans mesure du GPU,
|
||||
des FPS, du réseau, de la disponibilité des chunks pour les ticks ou d’une
|
||||
charge multijoueur. Grand n’a pas de benchmark complet 289 + 80 dans cette
|
||||
livraison ; ses tests moteur ci-dessus ont une autre charge de travail.
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryBenchmark=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
|
||||
```
|
||||
|
||||
Rapport local : `diagnostics/generation-performance-unified-724-seed-0.json`,
|
||||
log `build/alpha13-benchmark-medium.log`.
|
||||
|
||||
### Expansions et rechargement alpha.13
|
||||
|
||||
Sur le monde unifié Moyen de graine 42, les deux processus réussissent :
|
||||
refus des huit premiers sites occupés, tropicale plus loin au nord, branche
|
||||
aride à au moins 2 048 blocs de son parent, relecture du journal et des blocs
|
||||
témoins, puis nouvelle expansion après redémarrage. Les colonnes de terrain
|
||||
et les biomes enregistrés restent identiques après décodage du générateur.
|
||||
Le framework reconstruit les paramètres actifs depuis le preset ; le test
|
||||
vérifie séparément le codec sauvegardé et les fichiers réellement rechargés.
|
||||
|
||||
Aucun camp admissible n’a été observé sur l’origine unifiée des graines 42 et
|
||||
8 675 309 : les fondations naturelles ou la protection hydrologique écartent
|
||||
les candidats. Les essais stricts y échouent donc sur la preuve de structure.
|
||||
La paire unifiée validée utilise explicitement un témoin de structure
|
||||
facultatif, seulement après inspection complète des candidats. Ses rapports
|
||||
indiquent `native_structure_observed=false` et ne constituent aucune preuve
|
||||
positive de placement ou de butin dans l’origine alpha.13. Les critères de
|
||||
fondation et la génération ne sont pas assouplis pour faire passer un test.
|
||||
|
||||
Exécuter les deux commandes sans lancer un autre GameTest entre elles :
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryExpansionTests=true -PsanctuaryOptionalStructureWitness=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryExpansionTests=true -PsanctuaryExpansionReload=true -PsanctuaryOptionalStructureWitness=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
|
||||
```
|
||||
|
||||
Sans ce drapeau explicite, le scénario exige toujours une vraie structure avec
|
||||
son conteneur et sa table de butin native. La réouverture vérifie toujours ces
|
||||
trois éléments lorsqu’ils ont été observés dans le premier processus.
|
||||
|
||||
La paire historique, rejouée avec le binaire alpha.13, réussit sur la graine
|
||||
42 sans rendre le témoin facultatif : camp abandonné de forêt, bloc du
|
||||
conteneur et table de butin native retrouvés après redémarrage. Cela valide
|
||||
la chaîne génération 12 conservée ; ce résultat ne vaut pas comme preuve
|
||||
d’un camp dans l’origine unifiée. Le smoke du journal teste aussi une origine
|
||||
13 de diamètre 1 024 avec un enfant 12, le schéma 1 et le refus d’une lecture
|
||||
avec une autre génération ou taille, sans modifier les octets enregistrés.
|
||||
|
||||
## Correctif alpha.12.1 — tous les premiers emplacements occupés
|
||||
|
||||
Le scénario laboratoire utilise les mêmes commandes Gradle ci-dessous. Il
|
||||
prépare maintenant un vrai chunk FULL vide sur chacun des huit premiers sites,
|
||||
constate leur occupation puis ouvre une tropicale plus au nord. Une branche
|
||||
aride demande ensuite une distance explicite de 2 048 blocs depuis cette île.
|
||||
Les anciens chunks vides et le bloc témoin restent inchangés.
|
||||
|
||||
Le second processus vérifie d’abord les huit refus sur disque avec les chunks
|
||||
témoins non chargés, puis ouvre une autre île plus loin au sud et attend du
|
||||
terrain prêt. Les preuves se trouvent dans
|
||||
`diagnostics/expansion-alpha12-1-seed-42.json` et
|
||||
`diagnostics/expansion-alpha12-1-reload-seed-42.json` sous le dossier GameTest.
|
||||
Le rapport exporté est [alpha.12.1](benchmarks/alpha121-expansion.json).
|
||||
|
||||
`expansionPlacementSmoke`, inclus dans `check`, vérifie les huit directions,
|
||||
les quatre tailles, les distances minimales diagonales/cardinales, les bornes,
|
||||
les réservations existantes et l’immutabilité. Les tests de commandes contrôlent
|
||||
les anciennes syntaxes, les retours `preview` à 2 048 blocs et les distances
|
||||
hors limites. Le correctif ne change ni le codec, ni la génération 12, ni le journal v1.
|
||||
|
||||
## Laboratoire alpha.12 — validation locale
|
||||
|
||||
`check build assemblePack` passe sur Java 25 / Minecraft 26.3-pre-2, avec
|
||||
@@ -31,8 +244,8 @@ Le laboratoire possède un scénario moteur distinct, à lancer puis à rouvrir
|
||||
**dans cet ordre**, sans autre GameTest entre les deux :
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryExpansionTests=true -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryExpansionTests=true -PsanctuaryExpansionReload=true -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryExpansionTests=true -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryExpansionTests=true -PsanctuaryExpansionReload=true -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
|
||||
```
|
||||
|
||||
Le premier passage crée le monde jetable. Le second saute volontairement
|
||||
|
||||
+106
-20
@@ -22,12 +22,38 @@ L'objectif mythologique est de progresser d'une condition très limitée, proche
|
||||
- **Humour, surprise et souvenirs.** Les noms d'entités, les événements, les animaux rares et les objets insolites comptent autant que les systèmes économiques.
|
||||
- **Un développement par tickets.** Livrer une petite mécanique jouable et vérifiable, puis l'ajuster avec des tickets de fonctionnalités et de bugs. Ne pas construire tous les systèmes avant de pouvoir jouer.
|
||||
|
||||
## Minecraft comme archéologie d’un autre Minecraft
|
||||
|
||||
**Canon et ligne de conception retenus.** Steve, Ari, Sunny, Kai, Zuri, Alex,
|
||||
Mefe, Makena, Noor sont les personnages disparus de la partie. Ils ont découvert
|
||||
quelque chose de révolutionnaire et ont continué parce que ça marchait. Jusqu’au
|
||||
moment où ça n’a plus marché.
|
||||
|
||||
> Ils n’ont pas ouvert quelque chose qu’ils n’auraient pas dû ouvrir. Ils ont
|
||||
> construit quelque chose qu’ils ne savaient plus refermer.
|
||||
|
||||
Chaque ruine doit être une trace d’une ancienne solution, pas seulement la trace
|
||||
d’un problème : une infrastructure dont l’auteur a progressivement disparu
|
||||
derrière son fonctionnement. Son ancien usage, ses circulations, ses réparations,
|
||||
ses extensions et ses chantiers doivent permettre de comprendre ce qui avait
|
||||
été résolu ici, même quand l’installation est abandonnée.
|
||||
|
||||
La nature de la découverte et les rôles individuels restent à définir. Aucune
|
||||
catastrophe précise n’est confirmée par ce principe. **La mise en scène de cette
|
||||
histoire et ses liens avec la progression restent à implémenter** ; les versions
|
||||
livrées et leurs tests établissent ce que chaque structure contient effectivement.
|
||||
|
||||
## Périmètre de départ
|
||||
|
||||
Le premier incrément porte sur le socle Fabric et la génération du monde : retrouver le générateur 26.2, produire une île principale dans un vrai vide, assurer une arrivée commune et sûre, puis poser une manière reproductible de générer des continents à une distance, une direction et une taille choisies.
|
||||
|
||||
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é |
|
||||
@@ -50,19 +76,19 @@ Les intégrations communautaires envisagées comprennent Fabric API, Sodium, Iri
|
||||
|
||||
L'île de Sanctuary est le point de départ commun. Elle flotte dans un vide qui ne doit pas être rempli par la génération habituelle du monde. Les continents suivants sont de vastes terres suspendues, créées progressivement selon une taille et une direction maîtrisées.
|
||||
|
||||
Le choix de taille initiale se limite à **Petit** et **Grand**, avec Grand
|
||||
par défaut. Petit prend une référence de 512 blocs de diamètre ; Grand vise
|
||||
environ 724 blocs, soit deux fois son aire nominale, sur une hauteur de
|
||||
384 blocs. Les repères internes 5/10 ne constituent ni les noms affichés ni
|
||||
une promesse de capacité multijoueur. Ce resserrement des tailles doit limiter
|
||||
les distances entre constructions.
|
||||
Le monde doit se créer par un seul choix **Sanctuary**, avec le bouton natif
|
||||
**Personnaliser** pour régler sa taille : **Petit** (512 blocs de diamètre
|
||||
nominal), **Moyen** (724, par défaut) et **Grand** (1 024). La hauteur reste de
|
||||
384 blocs. Ces dimensions ne promettent ni une aire entièrement habitable,
|
||||
ni un stock de ressources, ni une capacité multijoueur.
|
||||
|
||||
L’alpha.11 implémente ces deux choix et l’optimisation des chargements. Les
|
||||
anciens formats 20/100 sont masqués à la création mais restent chargeables
|
||||
avec leurs paramètres enregistrés ; aucune sauvegarde n’est réduite.
|
||||
Les mesures serveur et leurs limites sont consignées dans [Validation](testing.md).
|
||||
Ce dimensionnement ne remplace pas l’expansion collective ni ne garantit
|
||||
une aire habitable ou un stock de ressources par joueur.
|
||||
L’alpha.13 implémente cette interface et la réunion de l’île complète de Population,
|
||||
de ses forêts et de son hydrologie, avec les cavités natives du laboratoire.
|
||||
L’alpha.11 et ses anciens choix Petit/Grand restent historiques. Les anciens
|
||||
codecs et paramètres sauvegardés restent chargeables ; les choix précédents
|
||||
sont cachés à la création et aucune partie n’est convertie ou redimensionnée.
|
||||
L’état d’intégration est décrit dans le [guide alpha.13](alpha13-worldgen.md) ;
|
||||
les observations réelles et leurs limites restent dans [Validation](testing.md).
|
||||
|
||||
L'île initiale doit être **tempérée et légèrement humide**, avec quelques zones
|
||||
plus sèches, notamment de larges plages et dépôts irréguliers de sable, gravier,
|
||||
@@ -143,12 +169,14 @@ parent ; le serveur conserve ainsi l’histoire des découvertes de son univers.
|
||||
La recherche porte sur des climats et leurs distributions, sans imposer la même
|
||||
forme ni le même climat à une direction pour toutes les parties.
|
||||
|
||||
L’alpha.12 sert de laboratoire à cette conception : commandes opérateur pour
|
||||
créer et visiter les continents, reliefs hauts aussi expérimentés sur Sanctuary
|
||||
Island et structures vanilla compatibles. Ces essais sont réservés à une nouvelle
|
||||
génération. Les recherches, mailboxes et deposit boxes viendront ensuite ; elles
|
||||
ne sont pas nécessaires aux commandes d’essai. Le passage en bêta interviendra
|
||||
après clarification du périmètre de la vision et de la progression à développer.
|
||||
Les alpha.12 et alpha.12.1 ont fourni un laboratoire à cette conception :
|
||||
commandes opérateur, climats et reliefs indépendants, structures vanilla
|
||||
compatibles et recherche d’emplacements libres. L’alpha.13 reprend ces mêmes
|
||||
outils dans le nouveau monde Sanctuary unifié. Son journal est séparé de celui
|
||||
des anciens laboratoires, sans conversion de leur génération.
|
||||
Les recherches, mailboxes et deposit boxes viendront ensuite ; elles ne sont
|
||||
pas nécessaires aux commandes d’essai. Le passage en bêta dépendra du périmètre
|
||||
de progression retenu et de sa validation, pas du seul changement de générateur.
|
||||
|
||||
L'économie naturelle d'une île doit être connue **après sa génération** : nombre
|
||||
de blocs de pierre, bûches, minerais de charbon, de fer et autres ressources,
|
||||
@@ -168,9 +196,67 @@ 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. L’alpha.16 garantit une petite ville compacte sur la nouvelle île de départ,
|
||||
lorsque les structures sont activées, puis admet des quartiers conditionnels
|
||||
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.
|
||||
La passe alpha.15 vise des pièces réellement séparées et aménagées : seuil,
|
||||
vestibule, hall, corridors, escaliers, chambres, sols, plafonds et ornements.
|
||||
Les rues, parcs et bâtiments doivent être incrustés dans le sol. Les bâtiments
|
||||
se regroupent en îlots denses avec rues et carrefours continus, sans mottes
|
||||
de terre entre les façades et leurs accès. Les ponts et viaducs prolongent
|
||||
le réseau urbain sur les franchissements naturels admissibles.
|
||||
- **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. L’alpha.15 travaille des égouts et pool
|
||||
rooms en réseau, procéduralement creusés sous la ville,
|
||||
avec des passages et des accès de surface. Une section suspendue exige des
|
||||
chaînes ou des pylônes ancrés. Leur présence reste liée au terrain ; le
|
||||
suivi de validation distingue les intentions des preuves. L’alpha.16 ajoute
|
||||
un bassin couvert de 50 × 25 blocs, des plateformes de parking jusqu’à huit
|
||||
niveaux et des exutoires ouverts avec écoulement natif. Les étages alternent
|
||||
leurs plans, les sanitaires sont clos et les lits adossés aux murs. Seule la
|
||||
zone urbaine initiale est garantie ; piscine et autres grands équipements
|
||||
dépendent du terrain et des appuis. Voir [la validation alpha.16](testing-alpha16.md).
|
||||
- **Future salle d’expansion** : une grande salle sèche reliée aux tunnels
|
||||
pourrait accueillir une machine diégétique. Un socle et des cadres porteraient
|
||||
des cartes déjà découvertes, présentant l’ensemble de l’île en vue du dessus.
|
||||
L’alpha prépare la salle ; la machine, les cartes préremplies et les
|
||||
interactions d’expansion seront définies et implémentées dans un ticket suivant.
|
||||
- **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.
|
||||
- **Sanctuary était habitée avant nous** (orientation validée après l’alpha.15) :
|
||||
développer les trois familles ensemble : mines et ateliers, lieux étranges,
|
||||
infrastructures à réactiver. Les ruines doivent raconter une occupation
|
||||
antérieure par d’autres joueurs, à travers leurs usages et leurs circulations.
|
||||
Plusieurs réseaux distincts peuvent se rejoindre ponctuellement : découvrir
|
||||
ces connexions fait partie de l’exploration. Privilégier les installations
|
||||
encore utilisables et les chantiers interrompus pour raconter cette histoire.
|
||||
Les back rooms de ces donjons sont des lieux physiques dans l’île, distincts
|
||||
de la dimension Backrooms recevant les objets perdus. Le futur butin combine
|
||||
des reliques collectives uniques et des récompenses personnelles ; les
|
||||
spawners pourront rester exploitables après exploration sans régénérer les
|
||||
donjons. Les règles précises restent à concevoir. Le réacteur et ces nouveaux
|
||||
systèmes ne font pas partie de l’alpha.16.
|
||||
Le prototype alpha.17 commence par trois petits réseaux indépendants, avec
|
||||
accès de surface, ateliers, galeries, salles étranges et locaux techniques.
|
||||
L’option « Structures expérimentales » se choisit pour un nouveau monde.
|
||||
Les connexions entre familles et les systèmes de butin spécifiques restent
|
||||
à construire ; voir [le contrat du prototype](testing-alpha17.md).
|
||||
- **Secret rooms et challenge rooms** : des accès présents dans le monde mènent à des indoors aménagés, avec des équipements, des épreuves ou des gisements de fer, d'or, d'argent ou de titane.
|
||||
|
||||
### Dimensions
|
||||
|
||||
+63
-57
@@ -1,74 +1,80 @@
|
||||
# Premier monde Sanctuary
|
||||
|
||||
Le preset `sanctuary:sanctuary` crée l'île principale à l'origine de l'Overworld,
|
||||
entourée de vide sans répétition d'îles à grande distance. C'est une première
|
||||
tranche de génération pour Minecraft **26.3-pre-2 / Fabric**, pas encore le
|
||||
système d'expansion collective.
|
||||
## Alpha.13 — génération unifiée
|
||||
|
||||
L’alpha.11 propose deux choix de création, **Petit** et **Grand**, avec Grand
|
||||
par défaut. Les anciennes tailles 20/100 restent chargeables dans les sauvegardes.
|
||||
Les observations de chaque version restent dans [Validation](testing.md).
|
||||
Le choix public **Sanctuary** (`sanctuary:sanctuary`) crée l’île principale à
|
||||
l’origine de l’Overworld, entourée de vide. Son bouton natif **Personnaliser**
|
||||
propose **Petit** (512 blocs de diamètre nominal), **Moyen** (724, par défaut)
|
||||
et **Grand** (1 024). Les types vanilla restent disponibles. La hauteur reste
|
||||
de 384 blocs ; les dimensions nominales ne donnent ni aire habitable garantie
|
||||
ni capacité multijoueur.
|
||||
|
||||
## Laboratoire d’expansion — alpha.12
|
||||
L’île initiale reprend la densité, les forêts et l’hydrologie complète de la
|
||||
famille Population, avec les poches de biomes souterrains natives du laboratoire.
|
||||
Les extensions conservent leurs profils climatiques et leurs commandes
|
||||
alpha.12.1. Leurs règles de génération restent distinctes de celles de l’île
|
||||
initiale : la grande hydrologie de départ n’est pas automatiquement reproduite
|
||||
sur chaque continent ajouté.
|
||||
|
||||
Un troisième choix, **Sanctuary — Laboratoire** (`sanctuary:expansion_lab`),
|
||||
crée une origine de 512 blocs avec un relief supérieur expérimental. Les commandes
|
||||
opérateur peuvent ensuite ouvrir des régions de 64/128/256/512 blocs, avec
|
||||
parent, direction, climat et relief indépendants. Chaque région enregistre sa
|
||||
graine et sa génération 12 dans un journal serveur propre au monde.
|
||||
Les trois tailles et les cavités passent les contrôles moteur. Le [guide alpha.13](alpha13-worldgen.md)
|
||||
décrit l’interface, les paramètres sauvegardés et les outils d’expansion ;
|
||||
[Validation](testing.md) conserve la portée des essais de chaque version.
|
||||
|
||||
Ce générateur distinct utilise la densité flottante comme base et les biomes,
|
||||
matériaux, décorations et structures compatibles de Minecraft. Il ne reprend
|
||||
pas encore les grandes rivières et terrasses alpha.11. Petit, Grand et les
|
||||
sauvegardes historiques conservent leurs réglages. Créer un nouveau monde pour
|
||||
le laboratoire ; aucune migration ou commande de régénération n’est fournie.
|
||||
Le [guide alpha.12](alpha12-laboratory.md) décrit les outils, les limites et les
|
||||
observations réelles. Les sections historiques ci-dessous gardent leur portée.
|
||||
## Créer un nouveau monde
|
||||
|
||||
## Petit et Grand — alpha.11
|
||||
En solo, choisir **Sanctuary**, ouvrir **Personnaliser**, puis confirmer la
|
||||
taille avec **Terminé**. **Annuler** conserve le réglage précédent. Cette
|
||||
interface configure le monde à créer ; elle ne redimensionne pas une partie.
|
||||
|
||||
| Choix public | Diamètre nominal | Aire nominale relative | Preset | Réglage enregistré |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Petit | 512 blocs | 1 | `sanctuary:sanctuary_5` | `sanctuary:population_5` |
|
||||
| Grand, défaut | Environ 724 blocs | 2 | `sanctuary:sanctuary` | `sanctuary:population_10` |
|
||||
|
||||
La hauteur reste de 384 blocs. Les repères internes 5/10 décrivent le calcul
|
||||
de surface ; ils ne sont ni des noms affichés ni une capacité promise.
|
||||
Le relief réel garde ses trous, ses surplombs et sa bordure sculptée.
|
||||
|
||||
Le nouveau Grand reçoit sa propre clé `population_10`. Les anciens mondes
|
||||
`population_5`, `population_20` et `population_100` restent chargeables avec
|
||||
leurs paramètres ; les formats 20/100 sont cachés dans les choix publics.
|
||||
Changer la valeur du preset de création n’agrandit ni ne réduit une sauvegarde.
|
||||
Les générations antérieures restent distinctes, dont `sanctuary_cavern` et
|
||||
`sanctuary_rift`.
|
||||
|
||||
L’optimisation réutilise les coins d’interpolation avec la même arithmétique
|
||||
et borne ce cache à 192 Kio par thread, sans retenir fortement un monde.
|
||||
Les plans prêts restent accessibles pendant le calcul d’une autre région ;
|
||||
le sampler compilé est réutilisé dans ce calcul. Les contrôles d’équivalence,
|
||||
de persistance et les mesures à géométrie identique sont dans [Validation](testing.md).
|
||||
Les règles de forme, d’hydrologie et les anciens réglages restent inchangés.
|
||||
|
||||
## Créer un monde
|
||||
|
||||
La création solo propose **Sanctuary (Petit)** et **Sanctuary (Grand)**,
|
||||
ce dernier étant le défaut Sanctuary.
|
||||
Les types de monde vanilla restent disponibles.
|
||||
|
||||
Sur un nouveau serveur Fabric disposant de cette version et de Fabric API,
|
||||
le choix Grand utilise :
|
||||
Sur un nouveau serveur Fabric avec Fabric API, le preset public utilise la
|
||||
taille **Moyen**, 724 blocs :
|
||||
|
||||
```properties
|
||||
level-type=sanctuary:sanctuary
|
||||
level-name=sanctuary
|
||||
level-name=sanctuary-dev
|
||||
level-seed=0
|
||||
```
|
||||
|
||||
Pour Petit, utiliser `level-type=sanctuary:sanctuary_5`. Choisir un nouveau
|
||||
`level-name` pour tester l’autre taille. La seed est libre ; ces exemples ne
|
||||
convertissent aucun monde déjà enregistré et n’autorisent aucune régénération.
|
||||
Les fichiers de test et les sauvegardes personnelles restent distincts.
|
||||
La graine est libre. Utiliser un dossier neuf pour un essai ; modifier
|
||||
`level-type` ne convertit aucun monde enregistré. Aucune commande de migration
|
||||
ou de régénération des anciennes îles n’est fournie.
|
||||
|
||||
## Compatibilité et versions historiques
|
||||
|
||||
L’alpha.13 enregistre le générateur `sanctuary:island_v13`, son diamètre initial
|
||||
et une racine de génération 13. Les nouvelles expansions restent de génération
|
||||
12. Leur journal utilise le dossier propre au monde
|
||||
`data/sanctuary-world-v13/`. Les anciens codecs et presets sont conservés, mais
|
||||
cachés de la sélection publique. Les mondes déjà créés conservent leurs réglages
|
||||
et leur journal ; aucun chunk n’est remplacé par cette mise à jour.
|
||||
|
||||
### Alpha.12 et alpha.12.1 — laboratoire historique
|
||||
|
||||
Le preset distinct `sanctuary:expansion_lab` proposait une origine de 512 blocs
|
||||
et des commandes opérateur pour ajouter des îles de 64/128/256/512 blocs.
|
||||
Son relief expérimental et ses biomes natifs ne reprenaient pas les grandes
|
||||
rivières et terrasses de Population. L’alpha.12.1 ajoutait une distance
|
||||
facultative et la recherche d’un emplacement libre au-delà des chunks occupés.
|
||||
|
||||
Ces mondes gardent leur génération 12 et leur dossier
|
||||
`data/sanctuary-expansion-v1/`. Le [guide du laboratoire](alpha12-laboratory.md)
|
||||
reste la référence historique de ces versions, avec leurs seules observations.
|
||||
|
||||
### Alpha.11 — Petit et Grand historiques
|
||||
|
||||
| Ancien choix public | Diamètre nominal | Ancien preset | Réglage enregistré |
|
||||
| --- | --- | --- | --- |
|
||||
| Petit | 512 blocs | `sanctuary:sanctuary_5` | `sanctuary:population_5` |
|
||||
| Grand, ancien défaut | Environ 724 blocs | `sanctuary:sanctuary` | `sanctuary:population_10` |
|
||||
|
||||
L’ancien Grand correspond à la taille nominale du nouveau Moyen. Les mondes
|
||||
historiques `population_5`, `population_10`, `population_20` et `population_100`
|
||||
restent chargeables avec leurs dimensions. Le preset public est une recette
|
||||
de création ; sa nouvelle définition ne remplace pas leur générateur sauvegardé.
|
||||
|
||||
L’optimisation alpha.11 conserve l’arithmétique de l’interpolation et les règles
|
||||
de terrain et d’hydrologie. Ses mesures et contrôles sont historiques, sans
|
||||
validation anticipée des ajouts de l’alpha.13.
|
||||
|
||||
## Socle de génération conservé
|
||||
|
||||
|
||||
+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.12
|
||||
pack_version=0.1.0-alpha.12
|
||||
mod_version=0.1.0-alpha.22
|
||||
pack_version=0.1.0-alpha.22
|
||||
maven_group=fr.koka.sanctuary
|
||||
|
||||
+271
-1
@@ -37,18 +37,66 @@ tasks.named('runGameTest') {
|
||||
systemProperty('sanctuary.test.players', providers.gradleProperty('sanctuaryTestPlayers').getOrElse('10'))
|
||||
systemProperty('sanctuary.test.resourceSurvey', providers.gradleProperty('sanctuaryResourceSurvey').getOrElse('sample'))
|
||||
systemProperty('sanctuary.test.densityMaps', providers.gradleProperty('sanctuaryDensityMaps').getOrElse('true'))
|
||||
systemProperty('sanctuary.test.unified', providers.gradleProperty('sanctuaryUnifiedTests').getOrElse('true'))
|
||||
systemProperty('sanctuary.test.diameter', providers.gradleProperty('sanctuaryTestDiameter').getOrElse('724'))
|
||||
systemProperty('sanctuary.test.generation', providers.gradleProperty('sanctuaryTestGeneration').getOrElse(
|
||||
providers.gradleProperty('sanctuaryLostCityTests').map { it.toBoolean() }.getOrElse(false) ? '14' : providers.gradleProperty('sanctuaryLostCity15Tests').map { it.toBoolean() }.getOrElse(false) ? '15' : providers.gradleProperty('sanctuaryLostCity16Tests').map { it.toBoolean() }.getOrElse(false) ? '16' : providers.gradleProperty('sanctuaryRuins17Tests').map { it.toBoolean() }.getOrElse(false) ? '17' : providers.gradleProperty('sanctuaryHeritage19Tests').map { it.toBoolean() }.getOrElse(false) ? '19' : (providers.gradleProperty('sanctuaryHeritage20Tests').map { it.toBoolean() }.getOrElse(false) || providers.gradleProperty('sanctuaryLostCity20Tests').map { it.toBoolean() }.getOrElse(false)) ? '20' : (providers.gradleProperty('sanctuaryHeritage21Tests').map { it.toBoolean() }.getOrElse(false) || providers.gradleProperty('sanctuaryLostCity21Tests').map { it.toBoolean() }.getOrElse(false) || providers.gradleProperty('sanctuaryTransit21Tests').map { it.toBoolean() }.getOrElse(false)) ? '21' : '22'))
|
||||
systemProperty('sanctuary.test.selective22', providers.gradleProperty('sanctuarySelective22Tests').getOrElse('false'))
|
||||
systemProperty('sanctuary.transit.plannerDiagnostics', providers.gradleProperty('sanctuaryTransitDiagnostics').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.heritage21', providers.gradleProperty('sanctuaryHeritage21Tests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.lostCity21', providers.gradleProperty('sanctuaryLostCity21Tests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.transit21', providers.gradleProperty('sanctuaryTransit21Tests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.expansion', providers.gradleProperty('sanctuaryExpansionTests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.expansionReload', providers.gradleProperty('sanctuaryExpansionReload').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.lostCity', providers.gradleProperty('sanctuaryLostCityTests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.lostCity15', providers.gradleProperty('sanctuaryLostCity15Tests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.lostCity20', providers.gradleProperty('sanctuaryLostCity20Tests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.lostCity16', providers.gradleProperty('sanctuaryLostCity16Tests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.ruins17', providers.gradleProperty('sanctuaryRuins17Tests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.heritage20', providers.gradleProperty('sanctuaryHeritage20Tests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.heritage19', providers.gradleProperty('sanctuaryHeritage19Tests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.experimentalStructures', providers.gradleProperty('sanctuaryExperimentalStructures').getOrElse('true'))
|
||||
systemProperty('sanctuary.test.cityMatrix', providers.gradleProperty('sanctuaryCityMatrix').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.cityMatrixOnly', providers.gradleProperty('sanctuaryCityMatrixOnly').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.voidReuse', providers.gradleProperty('sanctuaryVoidReuseTests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.optionalStructureWitness', providers.gradleProperty('sanctuaryOptionalStructureWitness').getOrElse('false'))
|
||||
}
|
||||
|
||||
tasks.named('processGametestResources') {
|
||||
def benchmark = providers.gradleProperty('sanctuaryBenchmark').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('benchmark', benchmark)
|
||||
def unified = providers.gradleProperty('sanctuaryUnifiedTests').map { it.toBoolean() }.getOrElse(true)
|
||||
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 lostCity15 = providers.gradleProperty('sanctuaryLostCity15Tests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('lostCity15', lostCity15)
|
||||
def lostCity16 = providers.gradleProperty('sanctuaryLostCity16Tests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('lostCity16', lostCity16)
|
||||
def ruins17 = providers.gradleProperty('sanctuaryRuins17Tests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('ruins17', ruins17)
|
||||
def lostCity20 = providers.gradleProperty('sanctuaryLostCity20Tests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('lostCity20', lostCity20)
|
||||
def heritage20 = providers.gradleProperty('sanctuaryHeritage20Tests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('heritage20', heritage20)
|
||||
def heritage21 = providers.gradleProperty('sanctuaryHeritage21Tests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('heritage21', heritage21)
|
||||
def lostCity21 = providers.gradleProperty('sanctuaryLostCity21Tests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('lostCity21', lostCity21)
|
||||
def selective22 = providers.gradleProperty('sanctuarySelective22Tests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('selective22', selective22)
|
||||
def transit21 = providers.gradleProperty('sanctuaryTransit21Tests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('transit21', transit21)
|
||||
def heritage19 = providers.gradleProperty('sanctuaryHeritage19Tests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('heritage19', heritage19)
|
||||
def voidReuse = providers.gradleProperty('sanctuaryVoidReuseTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('voidReuse', voidReuse)
|
||||
filesMatching('fabric.mod.json') {
|
||||
expand(gametest_entrypoint: expansion ? 'fr.koka.sanctuary.gametest.ExpansionWorldGameTests' : benchmark
|
||||
expand(gametest_entrypoint: selective22 ? 'fr.koka.sanctuary.gametest.Selective22WorldGameTests' : transit21 ? 'fr.koka.sanctuary.gametest.Transit21WorldGameTests' : lostCity21 ? 'fr.koka.sanctuary.gametest.LostCity21WorldGameTests' : heritage21 ? 'fr.koka.sanctuary.gametest.Heritage21WorldGameTests' : lostCity20 ? 'fr.koka.sanctuary.gametest.LostCity20WorldGameTests' : heritage20 ? 'fr.koka.sanctuary.gametest.Heritage20WorldGameTests' : heritage19 ? 'fr.koka.sanctuary.gametest.Heritage19WorldGameTests' : ruins17 ? 'fr.koka.sanctuary.gametest.Ruins17WorldGameTests' : voidReuse ? 'fr.koka.sanctuary.gametest.ExpansionVoidReuseGameTests' : lostCity16 ? 'fr.koka.sanctuary.gametest.LostCity16WorldGameTests' : lostCity15 ? 'fr.koka.sanctuary.gametest.LostCity15WorldGameTests' : lostCity ? 'fr.koka.sanctuary.gametest.LostCityWorldGameTests' : expansion ? 'fr.koka.sanctuary.gametest.ExpansionWorldGameTests' : benchmark
|
||||
? 'fr.koka.sanctuary.gametest.WorldgenPerformanceGameTests'
|
||||
: unified ? 'fr.koka.sanctuary.gametest.UnifiedWorldGameTests'
|
||||
: 'fr.koka.sanctuary.gametest.SanctuaryWorldGameTests')
|
||||
}
|
||||
}
|
||||
@@ -260,3 +308,225 @@ tasks.register('expansionReliefSmoke', JavaExec) {
|
||||
mainClass = 'fr.koka.sanctuary.expansion.ExpansionReliefSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('expansionJournalSmoke', 'expansionReliefSmoke') }
|
||||
|
||||
tasks.register('expansionPlacementSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
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') }
|
||||
|
||||
tasks.register('lostCityInteriors15Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityInteriors15Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityInteriors15Smoke') }
|
||||
|
||||
tasks.register('lostCityUnderground15Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityUnderground15Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityUnderground15Smoke') }
|
||||
|
||||
tasks.register('lostCityProtection15Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check bounded three-dimensional city protection and immutable snapshots.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityProtection15Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityProtection15Smoke') }
|
||||
|
||||
tasks.register('lostCityPlanner15Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check terrain-certified city fragments, inset floors and anchored viaducts.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityPlanner15Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityPlanner15Smoke') }
|
||||
|
||||
tasks.register('lostCityInteriors16Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityInteriors16Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityInteriors16Smoke') }
|
||||
|
||||
tasks.register('lostCityUnderground16Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityUnderground16Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityUnderground16Smoke') }
|
||||
|
||||
tasks.register('lostCityProtection16Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check bounded three-dimensional city protection and immutable snapshots.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityProtection16Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityProtection16Smoke') }
|
||||
|
||||
tasks.register('lostCityPlanner16Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check terrain-certified city fragments, inset floors and anchored viaducts.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityPlanner16Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityPlanner16Smoke') }
|
||||
|
||||
|
||||
tasks.register('lostCityFoundation16Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityFoundation16Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityFoundation16Smoke') }
|
||||
|
||||
// The experimental pieces and algorithms are additive; historical city suites remain unchanged.
|
||||
['Planner', 'Renderer', 'Piece'].each { component ->
|
||||
def smoke = tasks.register("ruins17${component}Smoke", JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka.sanctuary.worldgen.ruins.Ruins${component}17Smoke"
|
||||
}
|
||||
tasks.named('check') { dependsOn(smoke) }
|
||||
}
|
||||
|
||||
// Local native starts keep the long heritage routes bounded by Minecraft's reference range.
|
||||
['Planner', 'Renderer', 'Piece'].each { component ->
|
||||
def smoke = tasks.register("heritage19${component}Smoke", JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka.sanctuary.worldgen.heritage.Heritage${component}19Smoke"
|
||||
}
|
||||
tasks.named('check') { dependsOn(smoke) }
|
||||
}
|
||||
|
||||
// Generation20 architecture retains the previous renderers and verifies connected block states.
|
||||
[
|
||||
connectedBlocks20Smoke: 'detail.ConnectedBlocks20Smoke',
|
||||
heritage20RendererSmoke: 'heritage.HeritageRenderer20Smoke',
|
||||
heritage20PieceSmoke: 'heritage.HeritagePiece20Smoke',
|
||||
ruins20RendererSmoke: 'ruins.RuinsRenderer20Smoke',
|
||||
lostCityUnderground20Smoke: 'city.LostCityUnderground20Smoke',
|
||||
lostCitySurface20Smoke: 'city.LostCitySurface20Smoke',
|
||||
lostCityInteriors20Smoke: 'city.LostCityInteriors20Smoke'
|
||||
].each { name, harness ->
|
||||
def smoke = tasks.register(name, JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka.sanctuary.worldgen.${harness}"
|
||||
}
|
||||
tasks.named('check') { dependsOn(smoke) }
|
||||
}
|
||||
|
||||
// Optional actual block-state exports for architectural review; never enabled in ordinary checks.
|
||||
tasks.named('heritage20RendererSmoke') {
|
||||
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
|
||||
systemProperty('sanctuary.test.heritage20.export', rootProject.layout.buildDirectory.dir('alpha20-preview/heritage').get().asFile.absolutePath)
|
||||
}
|
||||
tasks.named('lostCityInteriors20Smoke') {
|
||||
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
|
||||
systemProperty('sanctuary.city20.preview', rootProject.layout.buildDirectory.file('alpha20-preview/city.json').get().asFile.absolutePath)
|
||||
}
|
||||
tasks.named('lostCitySurface20Smoke') {
|
||||
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
|
||||
systemProperty('sanctuary.city20.exteriorPreview', rootProject.layout.buildDirectory.file('alpha20-preview/exterior.json').get().asFile.absolutePath)
|
||||
}
|
||||
|
||||
// Generation21 keeps published20 renderers immutable.
|
||||
[
|
||||
heritage21RendererSmoke: 'heritage.HeritageRenderer21Smoke',
|
||||
heritage21PieceSmoke: 'heritage.HeritagePiece21Smoke',
|
||||
heritage21PlannerSmoke: 'heritage.HeritagePlanner21Smoke',
|
||||
lostCity21PlannerSmoke: 'city.LostCityPlanner21Smoke',
|
||||
lostCitySurface21Smoke: 'city.LostCitySurface21Smoke',
|
||||
lostCityInteriors21Smoke: 'city.LostCityInteriors21Smoke',
|
||||
transit21Smoke: 'transit.Transit21Smoke'
|
||||
].each { name, harness ->
|
||||
def smoke = tasks.register(name, JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka.sanctuary.worldgen.${harness}"
|
||||
}
|
||||
tasks.named('check') { dependsOn(smoke) }
|
||||
}
|
||||
tasks.named('heritage21RendererSmoke') {
|
||||
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
|
||||
systemProperty('sanctuary.test.heritage21.export', rootProject.layout.buildDirectory.dir('alpha21-preview/heritage').get().asFile.absolutePath)
|
||||
}
|
||||
tasks.named('lostCityInteriors21Smoke') {
|
||||
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
|
||||
systemProperty('sanctuary.city21.preview', rootProject.layout.buildDirectory.file('alpha21-preview/city.json').get().asFile.absolutePath)
|
||||
}
|
||||
tasks.named('lostCitySurface21Smoke') {
|
||||
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
|
||||
systemProperty('sanctuary.city21.exteriorPreview', rootProject.layout.buildDirectory.file('alpha21-preview/exterior.json').get().asFile.absolutePath)
|
||||
}
|
||||
|
||||
// Generation22 filters surface placement and adds an independent core hall; historical tests still run.
|
||||
[
|
||||
lostCity22PlannerSmoke: 'city.LostCityPlanner22Smoke',
|
||||
underground22Smoke: 'city.LostCityUnderground22Smoke',
|
||||
heritage22PlannerSmoke: 'heritage.HeritagePlanner22Smoke',
|
||||
transit22PlannerSmoke: 'transit.TransitPlanner22Smoke',
|
||||
transit22RouteSmoke: 'transit.TransitRoute22Smoke',
|
||||
transit22RendererSmoke: 'transit.TransitRenderer22Smoke'
|
||||
].each { name, harness ->
|
||||
def smoke = tasks.register(name, JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka.sanctuary.worldgen.${harness}"
|
||||
}
|
||||
tasks.named('check') { dependsOn(smoke) }
|
||||
}
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ public final class SanctuaryClientRenderTests implements FabricClientGameTest {
|
||||
"net.minecraft.client.renderer.fog.FogRenderer", "sanctuary$openVoidFog");
|
||||
SanctuaryMod.LOGGER.info("Client render tests passed: Sanctuary sky and fog mixins applied.");
|
||||
});
|
||||
SanctuaryWorldOptionsClientChecks.run(context);
|
||||
}
|
||||
|
||||
private static void assertApplied(ClassLoader loader, String target, String hook) {
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.client.SanctuaryWorldOptionsScreen;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.minecraft.client.gui.components.CycleButton;
|
||||
import net.minecraft.client.gui.components.tabs.TabNavigationBar;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen;
|
||||
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
||||
import net.minecraft.client.input.KeyEvent;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.world.level.dimension.LevelStem;
|
||||
import net.minecraft.world.level.levelgen.WorldGenSettings;
|
||||
import net.minecraft.world.level.storage.LevelStorageSource;
|
||||
|
||||
/** The real new-world button path and saved settings, without starting or opening a game world. */
|
||||
final class SanctuaryWorldOptionsClientChecks {
|
||||
private static final KeyEvent PRESS = new KeyEvent(InputConstants.KEY_RETURN, 0, 0);
|
||||
|
||||
static void run(ClientGameTestContext context) {
|
||||
Screen previous = context.computeOnClient(client -> client.gui.screen());
|
||||
context.runOnClient(client -> CreateWorldScreen.openFresh(client, () -> client.gui.setScreen(previous)));
|
||||
context.waitFor(client -> client.gui.screen() instanceof CreateWorldScreen, 1200);
|
||||
CreateWorldScreen create = context.computeOnClient(client -> (CreateWorldScreen) client.gui.screen());
|
||||
List<Map<String, Object>> savedSizes = new ArrayList<>();
|
||||
try {
|
||||
context.runOnClient(client -> {
|
||||
TabNavigationBar tabs = create.children().stream().filter(TabNavigationBar.class::isInstance)
|
||||
.map(TabNavigationBar.class::cast).findFirst().orElseThrow();
|
||||
tabs.selectTab(1, false); // The vanilla World tab owns the world type and Customize widgets.
|
||||
var state = create.getUiState();
|
||||
long sanctuaryChoices = state.getNormalPresetList().stream().filter(entry -> entry.preset() != null
|
||||
&& entry.preset().getRegisteredName().startsWith("sanctuary:")).count();
|
||||
require(sanctuaryChoices == 1, "Exactly one public Sanctuary choice must remain");
|
||||
require(state.getAltPresetList().stream().filter(entry -> entry.preset() != null
|
||||
&& entry.preset().getRegisteredName().startsWith("sanctuary:")).count() == 1,
|
||||
"The extended world-type cycle must not expose the old laboratory or sizes");
|
||||
CycleButton<?> worldType = create.children().stream().filter(CycleButton.class::isInstance)
|
||||
.map(CycleButton.class::cast)
|
||||
.filter(button -> button.getValue() instanceof WorldCreationUiState.WorldTypeEntry)
|
||||
.findFirst().orElseThrow();
|
||||
int attempts = state.getNormalPresetList().size();
|
||||
while (!isSanctuary(state) && attempts-- > 0) worldType.onPress(PRESS);
|
||||
require(isSanctuary(state), "The real world-type cycle must reach Sanctuary");
|
||||
require(diameter(create) == 724, "Sanctuary defaults to Medium");
|
||||
state.setSeed("42");
|
||||
state.setGenerateStructures(false);
|
||||
state.setBonusChest(true);
|
||||
});
|
||||
|
||||
// Cancel and Escape both leave the pending generator untouched.
|
||||
context.clickScreenButton("selectWorld.customizeType");
|
||||
context.waitForScreen(SanctuaryWorldOptionsScreen.class);
|
||||
context.runOnClient(client -> chooseSize(client.gui.screen(), "LARGE"));
|
||||
context.clickScreenButton("gui.cancel");
|
||||
context.runOnClient(client -> require(diameter(create) == 724, "Cancel must not apply Large"));
|
||||
context.clickScreenButton("selectWorld.customizeType");
|
||||
context.runOnClient(client -> {
|
||||
chooseSize(client.gui.screen(), "SMALL");
|
||||
client.gui.screen().keyPressed(new KeyEvent(InputConstants.KEY_ESCAPE, 0, 0));
|
||||
require(client.gui.screen() == create && diameter(create) == 724,
|
||||
"Escape must return to creation without applying Small");
|
||||
});
|
||||
|
||||
Path diagnostics = Path.of("diagnostics").toAbsolutePath();
|
||||
Files.createDirectories(diagnostics);
|
||||
Path testRoot = Files.createTempDirectory(diagnostics, "world-options-settings-");
|
||||
var storage = LevelStorageSource.createDefault(testRoot);
|
||||
for (var size : Map.of("SMALL", 512, "MEDIUM", 724, "LARGE", 1024).entrySet()) {
|
||||
var before = context.computeOnClient(client -> create.getUiState().getSettings());
|
||||
context.clickScreenButton("selectWorld.customizeType");
|
||||
context.waitForScreen(SanctuaryWorldOptionsScreen.class);
|
||||
context.runOnClient(client -> chooseSize(client.gui.screen(), size.getKey()));
|
||||
context.clickScreenButton("gui.done");
|
||||
context.runOnClient(client -> {
|
||||
require(client.gui.screen() == create && diameter(create) == size.getValue(),
|
||||
"Done must apply the chosen diameter and return to creation");
|
||||
var after = create.getUiState().getSettings();
|
||||
require(after.options().equals(before.options()), "Size must preserve seed, structures and bonus chest");
|
||||
for (var dimension : List.of(LevelStem.NETHER, LevelStem.END)) {
|
||||
require(after.selectedDimensions().get(dimension).equals(before.selectedDimensions().get(dimension)),
|
||||
"Size must preserve other dimensions");
|
||||
}
|
||||
require(isSanctuary(create.getUiState()), "Customization must keep the single Sanctuary world type");
|
||||
create.getUiState().setSettings(after.withOptions(options -> options));
|
||||
require(diameter(create) == size.getValue(),
|
||||
"Refreshing world settings must preserve the custom size instead of restoring the preset default");
|
||||
String name = size.getKey().toLowerCase(java.util.Locale.ROOT);
|
||||
var settings = new WorldGenSettings(after.options(), after.selectedDimensions());
|
||||
try (var save = storage.createAccess(name)) {
|
||||
LevelStorageSource.writeWorldGenSettings(after.worldgenLoadContext(), storage.getLevelPath(name), settings);
|
||||
var restored = LevelStorageSource.readExistingSavedData(save, after.worldgenLoadContext(),
|
||||
WorldGenSettings.TYPE).getOrThrow();
|
||||
require(restored.dimensions().overworld() instanceof SanctuaryChunkGenerator generator
|
||||
&& generator.initialDiameter() == size.getValue(), "Selected size must survive actual settings storage");
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, after.worldgenLoadContext());
|
||||
require(WorldGenSettings.CODEC.encodeStart(ops, restored).getOrThrow()
|
||||
.equals(WorldGenSettings.CODEC.encodeStart(ops, settings).getOrThrow()),
|
||||
"Stored options and dimensions must remain unchanged");
|
||||
savedSizes.add(Map.of("size", name, "diameter", size.getValue(), "disk_round_trip", true));
|
||||
}
|
||||
});
|
||||
context.clickScreenButton("selectWorld.customizeType");
|
||||
context.runOnClient(client -> require(sizeButton(client.gui.screen()).getValue().toString().equals(size.getKey()),
|
||||
"Reopening Customize must restore the saved selection"));
|
||||
if (size.getKey().equals("MEDIUM")) context.takeScreenshot("sanctuary-world-options-medium");
|
||||
context.clickScreenButton("gui.cancel");
|
||||
}
|
||||
|
||||
context.runOnClient(client -> {
|
||||
var state = create.getUiState();
|
||||
var settings = state.getSettings();
|
||||
var presets = settings.worldgenLoadContext().lookupOrThrow(Registries.WORLD_PRESET);
|
||||
var legacy = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_5")))
|
||||
.value().createWorldDimensions().overworld();
|
||||
state.updateDimensions((registries, dimensions) -> dimensions.replaceOverworldGenerator(registries, legacy));
|
||||
require(state.getPresetEditor() == null,
|
||||
"A legacy generator under the public key must not gain an implicit conversion editor");
|
||||
state.setSettings(settings);
|
||||
var flat = presets.getOrThrow(net.minecraft.world.level.levelgen.presets.WorldPresets.FLAT);
|
||||
state.setWorldType(new WorldCreationUiState.WorldTypeEntry(flat));
|
||||
require(state.getPresetEditor() != null
|
||||
&& !(state.getPresetEditor().createEditScreen(create, state.getSettings()) instanceof SanctuaryWorldOptionsScreen),
|
||||
"Vanilla flat-world customization must remain available");
|
||||
});
|
||||
Files.writeString(diagnostics.resolve("sanctuary-world-options-client.json"),
|
||||
new GsonBuilder().setPrettyPrinting().create().toJson(Map.of(
|
||||
"public_sanctuary_choices", 1, "default_diameter", 724,
|
||||
"saved_sizes", savedSizes, "cancel_and_escape_preserved", true,
|
||||
"settings_refresh_preserved", true,
|
||||
"legacy_editor_guard", true, "vanilla_editor_preserved", true,
|
||||
"test_settings_directory", testRoot.toString(), "passed", true)) + "\n");
|
||||
SanctuaryMod.LOGGER.info("Sanctuary world options client tests passed: one world type, three saved sizes, Done/Cancel/Escape.");
|
||||
} catch (IOException failure) {
|
||||
throw new AssertionError("Unable to verify the selected world settings on disk", failure);
|
||||
} finally {
|
||||
context.runOnClient(client -> create.onClose());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isSanctuary(WorldCreationUiState state) {
|
||||
var preset = state.getWorldType().preset();
|
||||
return preset != null && preset.getRegisteredName().equals("sanctuary:sanctuary");
|
||||
}
|
||||
|
||||
private static int diameter(CreateWorldScreen create) {
|
||||
return ((SanctuaryChunkGenerator) create.getUiState().getSettings().selectedDimensions().overworld()).initialDiameter();
|
||||
}
|
||||
|
||||
private static CycleButton<?> sizeButton(Screen screen) {
|
||||
require(screen instanceof SanctuaryWorldOptionsScreen, "Customize must open the Sanctuary screen");
|
||||
return screen.children().stream().filter(CycleButton.class::isInstance).map(CycleButton.class::cast)
|
||||
.findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
private static void chooseSize(Screen screen, String target) {
|
||||
CycleButton<?> button = sizeButton(screen);
|
||||
int attempts = 3;
|
||||
while (!button.getValue().toString().equals(target) && attempts-- > 0) button.onPress(PRESS);
|
||||
require(button.getValue().toString().equals(target), "Size cycle must expose " + target);
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
+57
-2
@@ -5,9 +5,12 @@ import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import fr.koka.sanctuary.expansion.ExpansionIsland;
|
||||
import fr.koka.sanctuary.expansion.ExpansionRuntime;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import net.minecraft.commands.CommandSource;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||
import net.minecraft.server.level.ChunkLevel;
|
||||
import net.minecraft.server.permissions.LevelBasedPermissionSet;
|
||||
import net.minecraft.server.permissions.PermissionLevel;
|
||||
@@ -56,16 +59,68 @@ public final class ExpansionCommandChecks {
|
||||
helper.assertTrue(dispatcher.execute(preview, operatorTwo) == 1, "OP2 must execute the same read-only preview successfully");
|
||||
helper.assertTrue(session.islands().equals(before), "Preview must not reserve or activate an island");
|
||||
|
||||
var feedback = new ArrayList<Component>();
|
||||
var previewSource = server.createCommandSourceStack().withLevel(level).withPermission(LevelBasedPermissionSet.GAMEMASTER)
|
||||
.withSource(new CommandSource() {
|
||||
@Override public void sendSystemMessage(Component message) { feedback.add(message); }
|
||||
@Override public boolean acceptsSuccess() { return true; }
|
||||
@Override public boolean acceptsFailure() { return true; }
|
||||
@Override public boolean shouldInformAdmins() { return false; }
|
||||
});
|
||||
var parent = session.island("sanctuary");
|
||||
for (String direction : new String[] {"north", "northeast"}) {
|
||||
feedback.clear();
|
||||
helper.assertTrue(dispatcher.execute("sanctuary expansion preview distance_preview sanctuary " + direction
|
||||
+ " boreal 64 natural 2048", previewSource) == 1, "The optional distance must execute for " + direction);
|
||||
helper.assertTrue(feedback.size() == 1 && feedback.getFirst().getContents() instanceof TranslatableContents,
|
||||
"A successful distance preview must return its proposed coordinates");
|
||||
var result = (TranslatableContents) feedback.getFirst().getContents();
|
||||
helper.assertTrue(result.getKey().equals("sanctuary.expansion.preview"), "Distance preview must return the production preview response");
|
||||
var args = result.getArgs();
|
||||
int x = ((Number) args[1]).intValue(), z = ((Number) args[2]).intValue();
|
||||
int dx = x - parent.centerX(), dz = z - parent.centerZ();
|
||||
helper.assertTrue(Math.hypot(dx, dz) >= 2048,
|
||||
"The command distance must be a minimum centre-to-centre distance, including diagonals");
|
||||
helper.assertTrue(dz < 0 && (direction.equals("north") ? dx == 0 : dx == -dz),
|
||||
"The distance preview must preserve the exact requested direction");
|
||||
helper.assertTrue(session.islands().equals(before), "Distance preview must not reserve or activate an island");
|
||||
}
|
||||
for (String command : new String[] {
|
||||
"sanctuary expansion quick north",
|
||||
"sanctuary expansion quick north boreal 64 natural",
|
||||
"sanctuary expansion quick north boreal 64 natural 2048",
|
||||
"sanctuary expansion create distance_create sanctuary north boreal 64 natural",
|
||||
"sanctuary expansion create distance_create sanctuary north boreal 64 natural 2048"}) {
|
||||
var syntax = dispatcher.parse(command, operatorTwo);
|
||||
helper.assertTrue(!syntax.getReader().canRead() && syntax.getExceptions().isEmpty() && syntax.getContext().getCommand() != null,
|
||||
"Old and distance-aware create/quick syntax must remain executable: " + command);
|
||||
}
|
||||
helper.assertTrue(dispatcher.execute(preview + " 0", operatorTwo) == 1,
|
||||
"An explicit zero distance must preserve automatic positioning");
|
||||
|
||||
for (String distance : new String[] {"-1", "100001"}) {
|
||||
for (String command : new String[] {
|
||||
preview,
|
||||
"sanctuary expansion create distance_invalid sanctuary north boreal 64 natural",
|
||||
"sanctuary expansion quick north boreal 64 natural"}) {
|
||||
boolean distanceRejected = false;
|
||||
try { dispatcher.execute(command + " " + distance, operatorTwo); }
|
||||
catch (CommandSyntaxException expected) { distanceRejected = true; }
|
||||
helper.assertTrue(distanceRejected && session.islands().equals(before),
|
||||
"All expansion commands must reject an out-of-range distance without mutation");
|
||||
}
|
||||
}
|
||||
|
||||
// 65 is accepted by Brigadier's numeric bounds, then rejected by the production size contract.
|
||||
int invalid = dispatcher.execute("sanctuary expansion create command_invalid sanctuary east boreal 65 natural", operatorTwo);
|
||||
helper.assertTrue(invalid == 0, "An unsupported nominal diameter must fail in the production command handler");
|
||||
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(ExpansionIsland.spatialGeneration(generator.rootGeneration()), "Void reuse is restricted to spatial city generations");
|
||||
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", generator.rootGeneration());
|
||||
}
|
||||
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", generator.rootGeneration());
|
||||
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");
|
||||
}
|
||||
}
|
||||
+138
-39
@@ -6,6 +6,7 @@ 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.ExpansionAdmission;
|
||||
import fr.koka.sanctuary.expansion.ExpansionBiomeSource;
|
||||
import fr.koka.sanctuary.expansion.ExpansionChunkGenerator;
|
||||
import fr.koka.sanctuary.expansion.ExpansionIsland;
|
||||
@@ -43,6 +44,8 @@ import net.minecraft.world.level.storage.LevelResource;
|
||||
|
||||
/** Opt-in end-to-end exercise on a new laboratory world; never rewrites chunks or a personal world. */
|
||||
public final class ExpansionWorldGameTests {
|
||||
private static final boolean OPTIONAL_STRUCTURE_WITNESS = Boolean.getBoolean("sanctuary.test.optionalStructureWitness");
|
||||
|
||||
@GameTest(maxTicks = 6000)
|
||||
public void islandsBranchPersistAndProtectVisitedChunks(GameTestHelper helper) {
|
||||
helper.assertTrue(Boolean.getBoolean("sanctuary.test.expansion"), "Expansion GameTest must be explicitly enabled");
|
||||
@@ -62,6 +65,7 @@ public final class ExpansionWorldGameTests {
|
||||
Map<String, Object> report = new LinkedHashMap<>();
|
||||
report.put("seed", level.getSeed());
|
||||
report.put("process_id", ProcessHandle.current().pid());
|
||||
report.put("optional_native_structure_witness", OPTIONAL_STRUCTURE_WITNESS);
|
||||
report.put("scope", "A second Java process opens the same saved development chunks and expansion journal, and verifies a "
|
||||
+ "gold block written and flushed before the first shutdown. GameTestServer reconstructs its world settings from the preset; "
|
||||
+ "the prior generator codec is decoded separately here and its base columns are compared. This is not a normal-client "
|
||||
@@ -74,6 +78,19 @@ public final class ExpansionWorldGameTests {
|
||||
"The reload proof must run in another Java process");
|
||||
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed(), "Reload must keep the original world seed");
|
||||
JsonObject saved = previous.getAsJsonObject("restart_snapshot");
|
||||
List<ExpansionIsland> blockers = ExpansionIsland.CODEC.listOf().parse(JsonOps.INSTANCE, saved.get("blocked_sites")).getOrThrow();
|
||||
int savedRefusals = 0;
|
||||
for (var site : blockers) {
|
||||
helper.assertTrue(level.getChunkSource().chunkMap.getUpdatingChunkIfPresent(new ChunkPos(site.centerX() >> 4, site.centerZ() >> 4).pack()) == null,
|
||||
"Reload collision proof must start with an unloaded saved witness");
|
||||
try { ExpansionAdmission.verify(level, site); }
|
||||
catch (ExpansionAdmission.OccupiedException expected) {
|
||||
helper.assertTrue(expected.getMessage().contains("saved chunk"), "The reload proof must detect disk occupancy, not just a loaded holder");
|
||||
savedRefusals++;
|
||||
}
|
||||
}
|
||||
helper.assertTrue(savedRefusals == 8, "All eight first sites must remain unavailable on disk after restart");
|
||||
report.put("saved_only_collision_refusals", savedRefusals);
|
||||
helper.assertTrue(saved.get("world_directory").getAsString().equals(worldDirectory(level)),
|
||||
"Reload must use exactly the same development save directory");
|
||||
helper.assertTrue(session.islands().size() == 3 && session.islands().stream().allMatch(i -> i.status().equals("ready")),
|
||||
@@ -108,24 +125,48 @@ public final class ExpansionWorldGameTests {
|
||||
"Decoding the saved generator codec must reproduce its base column and climate: " + island.id());
|
||||
samples.add(current);
|
||||
}
|
||||
JsonObject nativeStructure = previous.getAsJsonObject("native_structure");
|
||||
BlockPos containerPos = position(nativeStructure.getAsJsonArray("container"));
|
||||
full(helper, level, containerPos.getX() >> 4, containerPos.getZ() >> 4);
|
||||
helper.assertTrue(level.getBlockEntity(containerPos) instanceof RandomizableContainerBlockEntity,
|
||||
"The native structure's placed container must be loaded from the saved chunk");
|
||||
var container = (RandomizableContainerBlockEntity) level.getBlockEntity(containerPos);
|
||||
helper.assertTrue(container.getLootTable() != null && container.getLootTable().identifier().toString()
|
||||
.equals(nativeStructure.get("loot_table").getAsString()), "The native structure loot table must survive restart unconsumed");
|
||||
boolean hasNativeStructure = previous.has("native_structure") && !previous.get("native_structure").isJsonNull();
|
||||
if (hasNativeStructure) {
|
||||
helper.assertTrue(!previous.has("native_structure_observed") || previous.get("native_structure_observed").getAsBoolean(),
|
||||
"Stored native structure evidence must not contradict the observation result");
|
||||
JsonObject nativeStructure = previous.getAsJsonObject("native_structure");
|
||||
BlockPos containerPos = position(nativeStructure.getAsJsonArray("container"));
|
||||
full(helper, level, containerPos.getX() >> 4, containerPos.getZ() >> 4);
|
||||
helper.assertTrue(level.getBlockState(containerPos).toString().equals(nativeStructure.get("placed_block").getAsString()),
|
||||
"The native structure's placed block state must survive restart");
|
||||
helper.assertTrue(level.getBlockEntity(containerPos) instanceof RandomizableContainerBlockEntity,
|
||||
"The native structure's placed container must be loaded from the saved chunk");
|
||||
var container = (RandomizableContainerBlockEntity) level.getBlockEntity(containerPos);
|
||||
helper.assertTrue(container.getLootTable() != null && container.getLootTable().identifier().toString()
|
||||
.equals(nativeStructure.get("loot_table").getAsString()), "The native structure loot table must survive restart unconsumed");
|
||||
report.put("native_container", coordinates(containerPos));
|
||||
report.put("native_structure_observed", true);
|
||||
report.put("native_container_verified", true);
|
||||
report.put("native_structure_status", "placed_block_and_unconsumed_loot_verified_after_restart");
|
||||
} else {
|
||||
helper.assertTrue(OPTIONAL_STRUCTURE_WITNESS
|
||||
&& previous.has("optional_native_structure_witness") && previous.get("optional_native_structure_witness").getAsBoolean()
|
||||
&& previous.has("native_structure_observed") && !previous.get("native_structure_observed").getAsBoolean()
|
||||
&& previous.has("native_structure_search_complete") && previous.get("native_structure_search_complete").getAsBoolean(),
|
||||
"Missing native structure evidence is allowed only after an explicitly optional, completed first-process search");
|
||||
report.put("native_structure_observed", false);
|
||||
report.put("native_container_verified", false);
|
||||
report.put("native_structure_status", "not_observed_after_complete_first_process_scan; no_native_structure_reload_proof");
|
||||
report.put("native_structure_limitation", "No natural structure with placed vanilla loot was observed in the first process. "
|
||||
+ "This reload verifies chunks, generator settings, the journal and expansion behavior, but supplies no positive structure proof.");
|
||||
}
|
||||
report.put("previous_process_id", previous.get("process_id").getAsLong());
|
||||
report.put("world_directory", worldDirectory(level));
|
||||
report.put("gold_witness", coordinates(witness));
|
||||
report.put("samples", samples);
|
||||
report.put("regions", session.islands().size());
|
||||
report.put("native_container", coordinates(containerPos));
|
||||
report.put("saved_generator_codec_decoded", true);
|
||||
report.put("passed", true);
|
||||
writeDiagnostic(level, true, report);
|
||||
helper.succeed();
|
||||
var firstCandidate = session.candidate("after_reload", "sanctuary", "south", "boreal", 64, "natural");
|
||||
var opened = session.create("after_reload", "sanctuary", "south", "boreal", 64, "natural");
|
||||
helper.assertTrue(opened.centerZ() > firstCandidate.centerZ() && opened.centerX() == 0,
|
||||
"Creation must skip the saved occupied first site and preserve the requested southern ray");
|
||||
report.put("after_reload_expansion", List.of(opened.centerX(), opened.centerZ()));
|
||||
awaitReloadExpansion(helper, session, blockers, witness, report);
|
||||
} catch (Exception exception) {
|
||||
report.put("passed", false);
|
||||
report.put("failure", exception.toString());
|
||||
@@ -135,6 +176,41 @@ public final class ExpansionWorldGameTests {
|
||||
}
|
||||
}
|
||||
|
||||
private static void awaitReloadExpansion(GameTestHelper helper, ExpansionRuntime.Session session,
|
||||
List<ExpansionIsland> blockers, BlockPos gold, Map<String, Object> report) {
|
||||
try {
|
||||
helper.assertTrue(helper.getTick() < 5000, "Reload expansion preparation deadline: " + session.progress());
|
||||
if (!session.island("after_reload").status().equals("ready")) {
|
||||
helper.runAtTickTime(helper.getTick() + 10, () -> awaitReloadExpansion(helper, session, blockers, gold, report));
|
||||
return;
|
||||
}
|
||||
verifyUntouchedVoid(helper, blockers);
|
||||
helper.assertTrue(helper.getLevel().getBlockState(gold).is(Blocks.GOLD_BLOCK), "Opening farther away must preserve the saved player witness");
|
||||
var landing = session.landing("after_reload");
|
||||
helper.assertTrue(ExpansionRuntime.safeLanding(helper.getLevel(), landing), "The new region after restart must reach usable terrain");
|
||||
report.put("after_reload_landing", coordinates(landing));
|
||||
report.put("final_regions", session.islands().size());
|
||||
report.put("passed", true);
|
||||
writeDiagnostic(helper.getLevel(), true, report);
|
||||
helper.succeed();
|
||||
} catch (Exception exception) {
|
||||
report.put("passed", false);
|
||||
report.put("failure", exception.toString());
|
||||
try { writeDiagnostic(helper.getLevel(), true, report); } catch (IOException failure) { exception.addSuppressed(failure); }
|
||||
helper.fail(exception.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static void verifyUntouchedVoid(GameTestHelper helper, List<ExpansionIsland> blockers) {
|
||||
var generator = (ExpansionChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
for (var site : blockers) {
|
||||
var chunk = full(helper, helper.getLevel(), site.centerX() >> 4, site.centerZ() >> 4);
|
||||
helper.assertTrue(generator.islandAt(site.centerX(), site.centerZ()) == null
|
||||
&& Arrays.stream(chunk.getSections()).allMatch(section -> section.hasOnlyAir()),
|
||||
"An occupied saved void chunk must remain unchanged after farther placement: " + site.direction());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Scenario {
|
||||
private final GameTestHelper helper;
|
||||
private final ServerLevel level;
|
||||
@@ -152,6 +228,7 @@ public final class ExpansionWorldGameTests {
|
||||
private int phase;
|
||||
private int structureIndex;
|
||||
private int validStarts;
|
||||
private final List<ExpansionIsland> blockedSites = new ArrayList<>();
|
||||
|
||||
Scenario(GameTestHelper helper) {
|
||||
this.helper = helper;
|
||||
@@ -160,11 +237,18 @@ public final class ExpansionWorldGameTests {
|
||||
this.session = ExpansionRuntime.session(level);
|
||||
report.put("seed", level.getSeed());
|
||||
report.put("process_id", ProcessHandle.current().pid());
|
||||
report.put("generator_version", ExpansionIsland.GENERATION);
|
||||
report.put("generator_version", generator.rootGeneration());
|
||||
report.put("initial_diameter", generator.initialDiameter());
|
||||
report.put("islands", observedIslands);
|
||||
report.put("scope", "Fresh laboratory world; real FULL chunks and native structure loot, asynchronous activation, "
|
||||
report.put("optional_native_structure_witness", OPTIONAL_STRUCTURE_WITNESS);
|
||||
report.put("native_structure_observed", false);
|
||||
report.put("native_structure_search_complete", false);
|
||||
report.put("native_structure_status", "not_searched");
|
||||
report.put("scope", "Fresh versioned Sanctuary development world; real FULL chunks, a natural native-structure search, asynchronous activation, "
|
||||
+ "branching, journal re-read and loaded void admission refusal. Journal re-read is not a process restart. "
|
||||
+ "The parent block witness represents a player edit; no player teleport, multiplayer or client rendering is simulated.");
|
||||
+ "The parent block witness represents a player edit; no player teleport, multiplayer or client rendering is simulated. "
|
||||
+ (OPTIONAL_STRUCTURE_WITNESS ? "The native structure witness is explicitly optional; an absent witness is reported as missing evidence, never as a successful structures test."
|
||||
: "A naturally accepted structure with placed vanilla loot is required."));
|
||||
}
|
||||
|
||||
void advance() {
|
||||
@@ -177,9 +261,10 @@ public final class ExpansionWorldGameTests {
|
||||
helper.assertTrue(session.island("alpha").equals(alpha.ready()), "Activation must only advance the immutable island state");
|
||||
observeIsland("alpha", Set.of("jungle", "sparse_jungle", "bamboo_jungle", "mangrove_swamp"));
|
||||
verifyMarker();
|
||||
beta = session.create("beta", "alpha", "east", "arid", 64, "natural");
|
||||
beta = session.create("beta", "alpha", "east", "arid", 64, "natural", 2048);
|
||||
helper.assertTrue(beta.parent().equals("alpha") && beta.centerX() > alpha.centerX()
|
||||
&& beta.centerZ() == alpha.centerZ(), "A child must branch east from alpha, not from world origin");
|
||||
helper.assertTrue(beta.centerX() - alpha.centerX() >= 2048, "The requested minimum distance must be honoured from the chosen parent");
|
||||
verifyJournal();
|
||||
phase = 2;
|
||||
}
|
||||
@@ -189,7 +274,7 @@ public final class ExpansionWorldGameTests {
|
||||
observeIsland("beta", Set.of("desert", "badlands", "eroded_badlands", "wooded_badlands"));
|
||||
verifyMarker();
|
||||
verifyJournal();
|
||||
rejectVisitedVoid();
|
||||
verifyUntouchedVoid(helper, blockedSites);
|
||||
prepareStructureSearch();
|
||||
phase = 3;
|
||||
}
|
||||
@@ -242,9 +327,25 @@ public final class ExpansionWorldGameTests {
|
||||
report.put("root_landing", coordinates(landing));
|
||||
samplePoints.put("sanctuary", landing);
|
||||
|
||||
for (String direction : ExpansionIsland.DIRECTIONS) {
|
||||
var site = session.candidate("blocked_" + direction, "sanctuary", direction, "boreal", 64, "natural");
|
||||
var chunk = full(site.centerX() >> 4, site.centerZ() >> 4);
|
||||
helper.assertTrue(Arrays.stream(chunk.getSections()).allMatch(section -> section.hasOnlyAir()), "The first site must initially contain only real generated void");
|
||||
blockedSites.add(site);
|
||||
}
|
||||
int occupied = 0;
|
||||
for (var site : blockedSites) {
|
||||
try { ExpansionAdmission.verify(level, site); }
|
||||
catch (ExpansionAdmission.OccupiedException expected) { occupied++; }
|
||||
}
|
||||
helper.assertTrue(occupied == 8, "Reproduce the user failure with all eight first directions occupied");
|
||||
report.put("initial_occupied_directions", occupied);
|
||||
var nearestNorth = session.candidate("alpha", "sanctuary", "north", "tropical", 64, "natural");
|
||||
|
||||
alpha = session.create("alpha", "sanctuary", "north", "tropical", 64, "natural");
|
||||
helper.assertTrue(alpha.status().equals("reserved") && alpha.centerZ() < 0 && alpha.centerX() == 0,
|
||||
"North placement and the explicitly chosen tropical climate must remain independent");
|
||||
helper.assertTrue(alpha.centerZ() < nearestNorth.centerZ(), "Automatic placement must skip occupied chunks and continue farther north");
|
||||
helper.assertTrue(alpha.equals(session.create("alpha", "sanctuary", "north", "tropical", 64, "natural"))
|
||||
&& session.islands().size() == 2, "Repeating a matching request must not create another region or change its identity");
|
||||
boolean conflict = false;
|
||||
@@ -278,8 +379,8 @@ public final class ExpansionWorldGameTests {
|
||||
}
|
||||
|
||||
private void verifyJournal() throws IOException {
|
||||
Path directory = level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-expansion-v1");
|
||||
var reloaded = new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter());
|
||||
Path directory = level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/" + ((ExpansionChunkGenerator) level.getChunkSource().getGenerator()).journalDirectory());
|
||||
var reloaded = new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter(), generator.rootGeneration());
|
||||
helper.assertTrue(reloaded.islands().equals(session.islands()), "Re-reading the saved journal must preserve all region identities and states");
|
||||
report.put("journal_reloaded_regions", reloaded.islands().size());
|
||||
}
|
||||
@@ -295,6 +396,7 @@ public final class ExpansionWorldGameTests {
|
||||
snapshot.put("journal_sha256", sha256(Files.readString(journalFile(level))));
|
||||
snapshot.put("islands", ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow());
|
||||
snapshot.put("generator_codec", ChunkGenerator.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow());
|
||||
snapshot.put("blocked_sites", ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, blockedSites).getOrThrow());
|
||||
List<Map<String, Object>> samples = new ArrayList<>();
|
||||
for (var island : session.islands()) samples.add(sample(helper, generator, island, samplePoints.get(island.id())));
|
||||
snapshot.put("samples", samples);
|
||||
@@ -303,24 +405,6 @@ public final class ExpansionWorldGameTests {
|
||||
report.put("restart_snapshot", snapshot);
|
||||
}
|
||||
|
||||
private void rejectVisitedVoid() throws IOException {
|
||||
ExpansionIsland target = session.candidate("visited", "sanctuary", "southwest", "boreal", 64, "natural");
|
||||
helper.assertTrue(generator.islandAt(target.centerX(), target.centerZ()) == null, "Admission witness must initially be outside every island");
|
||||
LevelChunk visited = full(target.centerX() >> 4, target.centerZ() >> 4);
|
||||
helper.assertTrue(Arrays.stream(visited.getSections()).allMatch(section -> section.hasOnlyAir()),
|
||||
"The visited-region witness must be a real empty FULL chunk, not an occupied island");
|
||||
int before = session.islands().size();
|
||||
boolean refused = false;
|
||||
String reason = "";
|
||||
try { session.create("visited", "sanctuary", "southwest", "boreal", 64, "natural"); }
|
||||
catch (IOException expected) { refused = true; reason = expected.getMessage(); }
|
||||
helper.assertTrue(refused && session.islands().size() == before,
|
||||
"A visited empty chunk must prevent admission without publishing or changing a region");
|
||||
helper.assertTrue(generator.islandAt(target.centerX(), target.centerZ()) == null
|
||||
&& Arrays.stream(visited.getSections()).allMatch(section -> section.hasOnlyAir()), "Refused activation must leave the visited void unchanged");
|
||||
report.put("visited_void_refusal", Map.of("chunk", List.of(visited.getPos().x(), visited.getPos().z()), "reason", reason));
|
||||
}
|
||||
|
||||
private void prepareStructureSearch() {
|
||||
ExpansionIsland root = session.island("sanctuary");
|
||||
int limit = (root.terrainRadius() + 15) / 16;
|
||||
@@ -328,6 +412,8 @@ public final class ExpansionWorldGameTests {
|
||||
for (int ring = 0; ring <= limit; ring++) for (int x = -ring; x <= ring; x++) for (int z = -ring; z <= ring; z++) {
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) == ring && root.contains(x * 16 + 8, z * 16 + 8)) structureSearch.add(new ChunkPos(x, z));
|
||||
}
|
||||
report.put("structure_search_candidate_chunks", structureSearch.size());
|
||||
report.put("native_structure_status", "search_in_progress");
|
||||
}
|
||||
|
||||
private boolean observeNativeStructure() {
|
||||
@@ -344,10 +430,23 @@ public final class ExpansionWorldGameTests {
|
||||
Map<String, Object> evidence = materializeAndFindLoot(start, name);
|
||||
if (!evidence.isEmpty()) {
|
||||
report.put("native_structure", evidence);
|
||||
report.put("native_structure_observed", true);
|
||||
report.put("native_structure_status", "natural_start_with_placed_vanilla_loot_observed");
|
||||
report.put("native_structure_search_complete", structureIndex == structureSearch.size());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
report.put("native_structure_starts_seen", validStarts);
|
||||
report.put("structure_start_chunks_inspected", structureIndex);
|
||||
if (structureIndex == structureSearch.size()) {
|
||||
report.put("native_structure_search_complete", true);
|
||||
report.put("native_structure_status", "not_observed_after_complete_scan");
|
||||
report.put("native_structure_limitation", "No naturally accepted native structure with placed vanilla loot was observed after "
|
||||
+ structureIndex + " root-island start chunks and " + validStarts + " valid starts. "
|
||||
+ "This is missing engine evidence for structures, not a positive generation or loot result.");
|
||||
if (OPTIONAL_STRUCTURE_WITNESS) return true;
|
||||
}
|
||||
helper.assertTrue(structureIndex < structureSearch.size(), "No naturally accepted native structure with placed vanilla loot was observed; "
|
||||
+ validStarts + " valid starts. This is a missing engine proof, not a successful structures test.");
|
||||
return false;
|
||||
@@ -418,11 +517,11 @@ public final class ExpansionWorldGameTests {
|
||||
}
|
||||
|
||||
private static Path journalFile(ServerLevel level) {
|
||||
return level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-expansion-v1").resolve(ExpansionJournal.FILE_NAME);
|
||||
return level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/" + ((ExpansionChunkGenerator) level.getChunkSource().getGenerator()).journalDirectory()).resolve(ExpansionJournal.FILE_NAME);
|
||||
}
|
||||
private static String worldDirectory(ServerLevel level) { return level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize().toString(); }
|
||||
private static Path diagnostic(ServerLevel level, boolean reload) {
|
||||
return Path.of("diagnostics", "expansion-alpha12-" + (reload ? "reload-" : "") + "seed-" + level.getSeed() + ".json");
|
||||
return Path.of("diagnostics", (Boolean.getBoolean("sanctuary.test.unified") ? "expansion-alpha13-" : "expansion-alpha12-1-") + (reload ? "reload-" : "") + "seed-" + level.getSeed() + ".json");
|
||||
}
|
||||
private static void writeDiagnostic(ServerLevel level, boolean reload, Map<String, Object> report) throws IOException {
|
||||
Path output = diagnostic(level, reload);
|
||||
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
|
||||
import fr.koka.sanctuary.worldgen.heritage.HeritagePiece19;
|
||||
import fr.koka.sanctuary.worldgen.heritage.HeritagePlan19;
|
||||
import fr.koka.sanctuary.worldgen.heritage.HeritageRenderer19;
|
||||
import fr.koka.sanctuary.worldgen.heritage.HeritageStructures19;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.DoorBlock;
|
||||
import net.minecraft.world.level.block.TrapDoorBlock;
|
||||
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.minecraft.world.level.storage.loot.LootParams;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
|
||||
import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Native creation/reload proof confined to a disposable generation19 starter island. */
|
||||
public final class Heritage19WorldGameTests {
|
||||
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
|
||||
@GameTest(maxTicks = 12000)
|
||||
public void heritagePersistsWithRealDestinations(GameTestHelper helper) {
|
||||
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
|
||||
"The heritage proof requires a Sanctuary generator");
|
||||
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
helper.assertTrue(generator.rootGeneration() == 19, "Use a fresh generation19 test world; do not convert an old save");
|
||||
new Scenario(helper, generator, Boolean.getBoolean("sanctuary.test.expansionReload")).advance();
|
||||
}
|
||||
private static final class Scenario {
|
||||
final GameTestHelper helper;
|
||||
final ServerLevel level;
|
||||
final SanctuaryChunkGenerator generator;
|
||||
final HeritagePlan19 plan;
|
||||
final boolean reload;
|
||||
final Map<String, Object> report = new LinkedHashMap<>();
|
||||
final List<ChunkPos> chunks = new ArrayList<>();
|
||||
final List<StructureStart> starts = new ArrayList<>();
|
||||
JsonObject previous;
|
||||
BlockPos gold;
|
||||
int nextChunk;
|
||||
boolean prepared;
|
||||
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator, boolean reload) {
|
||||
this.helper = helper; this.level = helper.getLevel(); this.generator = generator; this.reload = reload;
|
||||
this.plan = generator.heritagePlan19();
|
||||
report.put("seed", level.getSeed()); report.put("size", generator.initialDiameter());
|
||||
report.put("process_id", ProcessHandle.current().pid()); report.put("world_directory", world(level).toString());
|
||||
report.put("experimental_structures", generator.experimentalStructures());
|
||||
report.put("structures_enabled", level.structureManager().shouldGenerateStructures());
|
||||
report.put("scope", "Native local starts, saved sites and routes, player-sized entrances, supports and unopened chests on the starter island. "
|
||||
+ "All14 tool tables are sampled separately without consuming a saved chest. Door states are restored after traversal. "
|
||||
+ "No personal save or expansion is opened; one disposable outer-wall edit is checked in a second JVM. "
|
||||
+ "Missing rails are intentional; this proof checks walking and saved rail shapes, not an uninterrupted minecart journey.");
|
||||
}
|
||||
void advance() {
|
||||
try {
|
||||
if (!prepared) { report.put("stage", "prepare"); prepare(); prepared = true; if (plan.isEmpty()) { finishDisabled(); return; } }
|
||||
if (nextChunk < chunks.size()) {
|
||||
report.put("stage", "load_full_chunks"); var pos = chunks.get(nextChunk++);
|
||||
helper.assertTrue(level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true) instanceof LevelChunk,
|
||||
"Heritage footprint must reach native FULL: " + pos);
|
||||
if (nextChunk % 20 == 0) SanctuaryMod.LOGGER.info("Heritage19 loaded {}/{} footprint chunks", nextChunk, chunks.size());
|
||||
helper.runAtTickTime(helper.getTick() + 1, this::advance); return;
|
||||
}
|
||||
report.put("stage", "verify_native_geometry"); verifyPlaced(); report.put("passed", true);
|
||||
if (!reload) {
|
||||
helper.assertTrue(gold != null && level.getBlockState(gold).isCollisionShapeFullBlock(level, gold), "Gold witness must replace a solid wall away from circulation");
|
||||
level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3); report.put("gold_witness", coords(gold));
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native starts, unopened loot and the wall edit");
|
||||
report.put("world_flushed_before_shutdown", true);
|
||||
} else {
|
||||
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Reload replaced the player's wall edit");
|
||||
report.put("gold_witness", coords(gold)); report.put("gold_witness_preserved", true);
|
||||
}
|
||||
write(level, report, reload); helper.succeed();
|
||||
} catch (Exception failure) {
|
||||
report.put("passed", false); report.put("failure", failure.toString());
|
||||
try { write(level, report, reload); } catch (IOException ignored) { }
|
||||
throw new IllegalStateException("Heritage19 native " + (reload ? "reload" : "creation") + " failed", failure);
|
||||
}
|
||||
}
|
||||
void prepare() throws IOException {
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
|
||||
var encoded = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow();
|
||||
var decoded = ChunkGenerator.CODEC.parse(ops, encoded).getOrThrow();
|
||||
helper.assertTrue(decoded instanceof SanctuaryChunkGenerator copy && copy.rootGeneration() == 19
|
||||
&& copy.experimentalStructures() == generator.experimentalStructures(), "Generation19 codec lost its experimental switch");
|
||||
report.put("generator_codec", encoded); report.put("plan_sha256", planHash(plan));
|
||||
report.put("planned_sites", plan.sites().stream().map(site -> Map.of("id", site.id(), "kind", site.kind().name(),
|
||||
"entry", coords(HeritagePiece19.locator(site)), "floor_y", site.floorY(), "foundation_depth", site.foundationDepth(), "footings", site.supports().size())).toList());
|
||||
report.put("planned_links", plan.links().stream().map(link -> Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(), "path", pathCoords(link))).toList());
|
||||
report.put("planning_samples", plan.samples()); report.put("planning_columns", plan.columns()); report.put("planning_diagnostics", plan.diagnostics());
|
||||
helper.assertTrue(plan.samples() <= HeritagePlan19.MAX_SAMPLES && plan.columns() <= HeritagePlan19.MAX_COLUMNS, "Heritage planning exceeded its budget");
|
||||
verifyLootTables(helper, report);
|
||||
if (reload) {
|
||||
previous = JsonParser.parseString(Files.readString(proof(level))).getAsJsonObject();
|
||||
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(), "Reload needs a successful flushed creation proof");
|
||||
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must use a second JVM");
|
||||
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed() && previous.get("size").getAsInt() == generator.initialDiameter()
|
||||
&& previous.get("world_directory").getAsString().equals(world(level).toString()), "Reload selected another world");
|
||||
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("plan_sha256").getAsString().equals(planHash(plan)), "Saved settings or planned identities changed on reload");
|
||||
if (previous.has("gold_witness")) gold = readPos(previous.getAsJsonArray("gold_witness"));
|
||||
}
|
||||
if (!generator.experimentalStructures() || !level.structureManager().shouldGenerateStructures()) {
|
||||
helper.assertTrue(plan.isEmpty() && plan.segments().isEmpty() && plan.links().isEmpty(), "Disabled generation published heritage geometry"); return;
|
||||
}
|
||||
helper.assertTrue(!plan.isEmpty(), "Enabled reference island has no heritage site");
|
||||
report.put("present_site_kinds", plan.sites().stream().map(s -> s.kind().name()).distinct().sorted().toList());
|
||||
report.put("missing_site_kinds", java.util.Arrays.stream(HeritagePlan19.Kind.values()).filter(k -> plan.sites().stream().noneMatch(s -> s.kind() == k)).map(Enum::name).toList());
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
|
||||
var origins = new HashSet<ChunkPos>(); var uniqueChunks = new HashSet<ChunkPos>();
|
||||
plan.sites().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
|
||||
plan.segments().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
|
||||
for (var origin : origins.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList()) {
|
||||
var chunk = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true);
|
||||
var start = chunk.getStartForStructure(structure.value());
|
||||
helper.assertTrue(start != null && start.isValid(), "Accepted site/route has no native start at " + origin); starts.add(start);
|
||||
for (var piece : start.getPieces()) {
|
||||
helper.assertTrue(piece instanceof HeritagePiece19, "Heritage loaded a foreign piece type");
|
||||
var box = piece.getBoundingBox();
|
||||
helper.assertTrue(Math.abs((long) (box.minX() >> 4) - origin.x()) <= 7 && Math.abs((long) (box.maxX() >> 4) - origin.x()) <= 7
|
||||
&& Math.abs((long) (box.minZ() >> 4) - origin.z()) <= 7 && Math.abs((long) (box.maxZ() >> 4) - origin.z()) <= 7, "Native start references a distant route endpoint");
|
||||
box.intersectingChunks().forEach(uniqueChunks::add);
|
||||
}
|
||||
}
|
||||
for (var site : plan.sites()) uniqueChunks.add(ChunkPos.containing(HeritagePiece19.locator(site)));
|
||||
for (var link : plan.links()) for (var point : link.path()) uniqueChunks.add(new ChunkPos(point.x() >> 4, point.z() >> 4));
|
||||
helper.assertTrue(uniqueChunks.size() <= 1024, "Bounded proof footprint exceeds1024 chunks");
|
||||
chunks.addAll(uniqueChunks.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList());
|
||||
}
|
||||
void finishDisabled() throws IOException {
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
|
||||
var start = level.getChunk(0, 0, ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(structure.value());
|
||||
helper.assertTrue(start == null || !start.isValid(), "Disabled heritage emitted a native start");
|
||||
helper.assertTrue(generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false) == null, "Disabled heritage is locatable");
|
||||
report.put("empty_plan_verified", true); report.put("passed", true);
|
||||
if (!reload) { helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save disabled experiment setting"); report.put("world_flushed_before_shutdown", true); }
|
||||
write(level, report, reload); helper.succeed();
|
||||
}
|
||||
void verifyPlaced() {
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
|
||||
var context = StructurePieceSerializationContext.fromLevel(level); var summaries = new ArrayList<Map<String, Object>>();
|
||||
int blocks = 0, chests = 0, pieces = 0, supports = 0;
|
||||
for (var start : starts) {
|
||||
var origin = start.getChunkPos(); var saved = start.createTag(context, origin); String nativeHash = hash(saved.toString());
|
||||
var copy = StructureStart.loadStaticStart(context, saved, level.getSeed());
|
||||
helper.assertTrue(copy != null && copy.isValid() && copy.createTag(context, origin).equals(saved), "Native start failed an exact NBT roundtrip");
|
||||
var expectedPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
|
||||
plan.sites().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece19.encodeSite(s)));
|
||||
plan.segments().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece19.encodeSegment(s)));
|
||||
var actualPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
|
||||
if (reload) {
|
||||
JsonObject prior = null;
|
||||
for (var value : previous.getAsJsonArray("starts")) { var item = value.getAsJsonObject(); var at = item.getAsJsonArray("start_chunk"); if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = item; }
|
||||
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(nativeHash), "Reload changed saved geometry or native reference count");
|
||||
}
|
||||
var loot = new ArrayList<Map<String, Object>>();
|
||||
for (var value : start.getPieces()) {
|
||||
var piece = (HeritagePiece19) value; pieces++;
|
||||
var tag = piece.createTag(context); var restored = new HeritagePiece19(context, tag);
|
||||
helper.assertTrue(restored.createTag(context).equals(tag), "Saved piece parameters changed on native load");
|
||||
actualPayloads.add(tag.getCompound("SanctuaryHeritage").orElseThrow());
|
||||
var corrupt = tag.copy(); corrupt.getCompound("SanctuaryHeritage").orElseThrow().putInt("Schema", 20);
|
||||
boolean rejected = false; try { new HeritagePiece19(context, corrupt); } catch (IllegalArgumentException expected) { rejected = true; }
|
||||
helper.assertTrue(rejected, "Unknown schema must fail closed");
|
||||
piece.getBoundingBox().intersectingChunks().forEach(at -> helper.assertTrue(level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true)
|
||||
.getReferencesForStructure(structure.value()).contains(origin.pack()), "FULL chunk lost its native structure reference"));
|
||||
var expected = rendered(piece); blocks += expected.blocks().size();
|
||||
for (var entry : expected.blocks().entrySet()) {
|
||||
var at = entry.getKey(); var wanted = entry.getValue(); helper.assertTrue(piece.getBoundingBox().isInside(at), "Renderer escaped saved bounds");
|
||||
if (reload && at.equals(gold)) continue;
|
||||
helper.assertTrue(level.getBlockState(at).is(wanted.getBlock()), "Clipping or decoration changed heritage at " + at + ": expected " + wanted + " got " + level.getBlockState(at));
|
||||
helper.assertTrue(!wanted.is(Blocks.SPAWNER), "Heritage must not create spawners");
|
||||
}
|
||||
if (piece.isSite()) {
|
||||
supports += verifySiteBearing(helper, generator, plan, piece.site());
|
||||
if (!reload && gold == null) {
|
||||
gold = expected.blocks().entrySet().stream().filter(e -> isOuterWall(piece.site(), e.getKey()) && e.getValue().isCollisionShapeFullBlock(level, e.getKey()))
|
||||
.map(Map.Entry::getKey).sorted(Comparator.<BlockPos>comparingInt(p -> p.getY()).thenComparingInt(p -> p.getX()).thenComparingInt(p -> p.getZ())).findFirst().orElse(null);
|
||||
}
|
||||
} else for (var cell : piece.segment().cells()) { supports++; assertRock(helper, new BlockPos(cell.x(), cell.minY() - 1, cell.z())); }
|
||||
for (var chest : expected.chests()) {
|
||||
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Deferred chest is missing");
|
||||
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
|
||||
helper.assertTrue(entity.getLootTable() != null && entity.getLootTable().equals(chest.table()) && entity.getLootTableSeed() == chest.seed(), "Deferred loot table/seed was lost or consumed");
|
||||
if (piece.isSite()) helper.assertTrue(chest.table().equals(lootKey(piece.site().kind())), "Site uses another kind's tool table");
|
||||
loot.add(Map.of("position", coords(chest.position()), "table", chest.table().identifier().toString(), "seed", chest.seed())); chests++;
|
||||
}
|
||||
}
|
||||
helper.assertTrue(actualPayloads.equals(expectedPayloads) && start.getPieces().size() == expectedPayloads.size(), "Native start lost or duplicated planned pieces");
|
||||
summaries.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", nativeHash, "pieces", start.getPieces().size(), "unopened_chests", loot));
|
||||
}
|
||||
report.put("starts", summaries); report.put("native_pieces", pieces); report.put("native_blocks_checked", blocks);
|
||||
report.put("full_chunks_checked", chunks.size()); report.put("rock_supports_checked", supports); report.put("unopened_deferred_chests", chests);
|
||||
report.put("stage", "verify_site_routes"); var sites = new ArrayList<Map<String, Object>>(); for (var site : plan.sites()) sites.add(verifyAccess(helper, site)); report.put("native_sites", sites);
|
||||
report.put("stage", "verify_read_only_landings");
|
||||
report.put("read_only_landmarks", verifyReadOnlyLandings(helper, generator, plan));
|
||||
report.put("stage", "verify_island_routes"); report.put("native_links", verifyLinks(helper, plan));
|
||||
int greatestSpan = plan.links().stream().mapToInt(Heritage19WorldGameTests::routeSpan).max().orElse(0);
|
||||
report.put("greatest_single_route_span", greatestSpan);
|
||||
if (level.getSeed() == 0 && generator.initialDiameter() == 724) {
|
||||
int requiredSpan = generator.initialDiameter() / 4;
|
||||
report.put("reference_minimum_route_span", requiredSpan);
|
||||
helper.assertTrue(greatestSpan >= requiredSpan, "Seed0 Medium must demonstrate an island-scale route spanning at least "
|
||||
+ requiredSpan + " blocks on X or Z; got " + greatestSpan);
|
||||
}
|
||||
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
|
||||
helper.assertTrue(located != null && located.getSecond().is(HeritageStructures19.KEY) && plan.sites().stream().anyMatch(s -> HeritagePiece19.locator(s).equals(located.getFirst())), "Locate must lead to a real exterior site entry");
|
||||
report.put("locate", coords(located.getFirst()));
|
||||
}
|
||||
}
|
||||
private record Chest(BlockPos position, ResourceKey<LootTable> table, long seed) {}
|
||||
private record Rendered(Map<BlockPos, BlockState> blocks, List<Chest> chests) {}
|
||||
private static Rendered rendered(HeritagePiece19 piece) {
|
||||
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
|
||||
int x0 = piece.isSite() ? piece.site().minX() : piece.segment().minX();
|
||||
int y0 = piece.isSite() ? piece.site().floorY() : piece.segment().minY();
|
||||
int z0 = piece.isSite() ? piece.site().minZ() : piece.segment().minZ();
|
||||
var writer = new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(new BlockPos(x0 + x, y0 + y, z0 + z), state); }
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
|
||||
var at = new BlockPos(x0 + x, y0 + y, z0 + z); blocks.put(at, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(at, table, RandomSource.create(seed).nextLong()));
|
||||
}
|
||||
};
|
||||
if (piece.isSite()) HeritageRenderer19.render(piece.site(), writer); else HeritageRenderer19.renderSegment(piece.segment(), writer);
|
||||
return new Rendered(blocks, chests);
|
||||
}
|
||||
private static boolean isOuterWall(HeritagePlan19.Site site, BlockPos pos) {
|
||||
return pos.getY() >= site.floorY() + 2 && pos.getY() <= site.floorY() + 3
|
||||
&& (pos.getX() == site.minX() + 2 || pos.getX() == site.maxX() - 2 || pos.getZ() == site.minZ() + 2 || pos.getZ() == site.maxZ() - 2)
|
||||
&& Math.abs(pos.getX() - site.entry().x()) + Math.abs(pos.getZ() - site.entry().z()) > 6;
|
||||
}
|
||||
private static Map<String, Object> verifyAccess(GameTestHelper helper, HeritagePlan19.Site site) {
|
||||
var level = helper.getLevel(); var expected = rendered(new HeritagePiece19(site)); var doors = new HashMap<BlockPos, BlockState>();
|
||||
expected.blocks().forEach((at, state) -> {
|
||||
if ((state.getBlock() instanceof DoorBlock || state.getBlock() instanceof TrapDoorBlock) && state.hasProperty(BlockStateProperties.OPEN)) doors.put(at, level.getBlockState(at));
|
||||
});
|
||||
doors.forEach((at, state) -> level.setBlock(at, state.setValue(BlockStateProperties.OPEN, true), 2));
|
||||
try {
|
||||
var entry = HeritagePiece19.locator(site);
|
||||
Predicate<BlockPos> inside = p -> p.equals(entry) || p.getX() >= site.minX() && p.getX() <= site.maxX()
|
||||
&& p.getZ() >= site.minZ() && p.getZ() <= site.maxZ() && p.getY() >= site.floorY() - 4 && p.getY() < site.maxY();
|
||||
var reached = walk(helper, entry, inside); int targets = 0;
|
||||
for (var target : HeritageRenderer19.targets(site)) {
|
||||
var at = new BlockPos(site.minX() + target.x(), site.floorY() + target.y(), site.minZ() + target.z());
|
||||
helper.assertTrue(reached.contains(at), "Site " + site.id() + " cannot reach " + target.name() + " at " + at); targets++;
|
||||
}
|
||||
return Map.of("id", site.id(), "kind", site.kind().name(), "entry", coords(entry), "standing_cells", reached.size(), "walkable_targets", targets);
|
||||
} finally { doors.forEach((at, state) -> level.setBlock(at, state, 2)); }
|
||||
}
|
||||
private static List<Map<String, Object>> verifyLinks(GameTestHelper helper, HeritagePlan19 plan) {
|
||||
helper.assertTrue(!plan.links().isEmpty(), "Reference island needs a real route between accepted destinations");
|
||||
var entries = new HashMap<String, HeritagePlan19.Entry>(); plan.sites().forEach(s -> entries.put(s.id(), s.entry())); plan.landmarks().forEach(s -> entries.put(s.id(), s.entry()));
|
||||
var cells = new HashMap<Long, HeritagePlan19.Cell>(); plan.segments().forEach(s -> s.cells().forEach(c -> cells.put(column(c.x(), c.z()), c)));
|
||||
var result = new ArrayList<Map<String, Object>>();
|
||||
for (var link : plan.links()) {
|
||||
var from = entries.get(link.fromId()); var to = entries.get(link.toId());
|
||||
helper.assertTrue(from != null && to != null, "Route points to an absent destination");
|
||||
var first = link.path().getFirst(); var last = link.path().getLast();
|
||||
helper.assertTrue(first.x() == from.x() && first.z() == from.z() && first.floorY() + 1 == from.y()
|
||||
&& last.x() == to.x() && last.z() == to.z() && last.floorY() + 1 == to.y(), "Route does not meet its real entrances");
|
||||
int present = 0, missing = 0;
|
||||
for (int i = 0; i < link.path().size(); i++) {
|
||||
var point = link.path().get(i); var at = new BlockPos(point.x(), point.floorY() + 1, point.z());
|
||||
helper.assertTrue(standable(helper.getLevel(), at), "Ballast route " + link.id() + " is blocked at " + at);
|
||||
if (i > 0) { var prior = link.path().get(i - 1); if (point.floorY() > prior.floorY()) helper.assertTrue(clearBody(helper.getLevel(), new BlockPos(prior.x(), prior.floorY() + 2, prior.z())), "Route slope cannot be climbed by a player"); }
|
||||
var cell = cells.get(column(point.x(), point.z()));
|
||||
if (cell != null && cell.rail() != HeritagePlan19.Rail.NONE) {
|
||||
var actual = helper.getLevel().getBlockState(at);
|
||||
if (cell.railPresent()) {
|
||||
helper.assertTrue(actual.is(Blocks.RAIL), "Saved existing rail disappeared");
|
||||
helper.assertTrue(actual.getValue(BlockStateProperties.RAIL_SHAPE).name().equals(cell.rail().name()), "Native rail update changed the intended slope or curve at " + at); present++;
|
||||
} else { helper.assertTrue(!actual.is(Blocks.RAIL), "An intentionally missing rail regenerated"); missing++; }
|
||||
}
|
||||
}
|
||||
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
|
||||
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
|
||||
result.add(Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(),
|
||||
"span_x", spanX, "span_z", spanZ, "existing_rails", present, "deliberately_missing_rails", missing, "path", pathCoords(link)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private static int routeSpan(HeritagePlan19.Link link) {
|
||||
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow()
|
||||
- link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
|
||||
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow()
|
||||
- link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
|
||||
return Math.max(spanX, spanZ);
|
||||
}
|
||||
private static List<List<Integer>> pathCoords(HeritagePlan19.Link link) {
|
||||
return link.path().stream().map(point -> List.of(point.x(), point.floorY(), point.z())).toList();
|
||||
}
|
||||
private static int verifySiteBearing(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan, HeritagePlan19.Site site) {
|
||||
int checked = 0;
|
||||
// The planner certifies a nine-point bearing grid, either directly below its slab or below a saved short footing.
|
||||
for (int dx : new int[] {1, 8, 15}) for (int dz : new int[] {1, 8, 15}) {
|
||||
int x = site.minX() + dx, z = site.minZ() + dz;
|
||||
var support = site.supports().stream().filter(s -> s.x() == x && s.z() == z).findFirst();
|
||||
int bottom = support.map(HeritagePlan19.Support::minY).orElse(site.minY());
|
||||
for (int below = 1; below <= 2; below++) {
|
||||
var at = new BlockPos(x, bottom - below, z); assertRock(helper, at);
|
||||
helper.assertTrue(plan.protects(at.getX(), at.getY(), at.getZ()) && generator.protectsCityDecoration(at),
|
||||
"Site's certified bearing layer is not published in the decoration mask: " + site.id() + " at " + at);
|
||||
helper.assertTrue(!plan.contains(at.getX(), at.getY(), at.getZ()), "Original bearing rock became a writable heritage volume: " + at);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
return checked;
|
||||
}
|
||||
private static List<Map<String, Object>> verifyReadOnlyLandings(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan) {
|
||||
var used = new HashSet<String>(); plan.links().forEach(link -> { used.add(link.fromId()); used.add(link.toId()); });
|
||||
var result = new ArrayList<Map<String, Object>>();
|
||||
for (var landmark : plan.landmarks()) if (used.contains(landmark.id())) {
|
||||
var e = landmark.entry(); int checked = 0;
|
||||
for (int side = -1; side <= 1; side++) {
|
||||
int x = e.x() + ((e.facing() & 1) == 0 ? side : 0), z = e.z() + ((e.facing() & 1) == 1 ? side : 0);
|
||||
for (int y = e.y() - 2; y <= e.y() + 2; y++) {
|
||||
var at = new BlockPos(x, y, z);
|
||||
helper.assertTrue(plan.protects(x, y, z) && generator.protectsCityDecoration(at), "Read-only arrival lost its decoration mask: " + landmark.id() + " at " + at);
|
||||
helper.assertTrue(!plan.contains(x, y, z), "Heritage gained write ownership of a historical arrival: " + landmark.id() + " at " + at);
|
||||
if (y == e.y() - 1) assertRock(helper, at);
|
||||
if (y >= e.y()) helper.assertTrue(helper.getLevel().getBlockState(at).isAir(), "Decoration obstructed a certified historical arrival: " + at);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
result.add(Map.of("id", landmark.id(), "entry", List.of(e.x(), e.y(), e.z()), "read_only_cells", checked));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private static void assertRock(GameTestHelper helper, BlockPos at) {
|
||||
helper.assertTrue(helper.getLevel().getBlockState(at).isSolid() && helper.getLevel().getFluidState(at).isEmpty(), "Lost original rock bearing at " + at);
|
||||
}
|
||||
private static void verifyLootTables(GameTestHelper helper, Map<String, Object> report) {
|
||||
var result = new ArrayList<Map<String, Object>>(); var level = helper.getLevel();
|
||||
var params = new LootParams.Builder(level).withParameter(LootContextParams.ORIGIN, new Vec3(0, 160, 0)).create(LootContextParamSets.CHEST);
|
||||
for (var kind : HeritagePlan19.Kind.values()) {
|
||||
var table = level.getServer().reloadableRegistries().getLootTable(lootKey(kind));
|
||||
helper.assertTrue(table != LootTable.EMPTY, "Heritage tool table did not load: " + kind);
|
||||
for (long seed : new long[] {0, 42, -1}) helper.assertTrue(table.getRandomItems(params, seed).stream().anyMatch(stack -> stack.is(tool(kind))), "Tool table is empty or lacks its expected primary tool: " + kind);
|
||||
result.add(Map.of("kind", kind.name(), "table", lootKey(kind).identifier().toString(), "sampled_seeds", List.of(0, 42, -1)));
|
||||
}
|
||||
report.put("tool_tables_sampled_separately", result);
|
||||
}
|
||||
private static ResourceKey<LootTable> lootKey(HeritagePlan19.Kind kind) { return ResourceKey.create(Registries.LOOT_TABLE, SanctuaryMod.id("chests/heritage19/" + kind.name().toLowerCase(Locale.ROOT))); }
|
||||
private static Item tool(HeritagePlan19.Kind kind) {
|
||||
return switch (kind) {
|
||||
case OBSERVATORY -> Items.SPYGLASS;
|
||||
case POST_OFFICE, EXPEDITION_OFFICE, RADIO_RELAY -> Items.COMPASS;
|
||||
case SUBSTATION, IMPOSSIBLE_SERVICE -> Items.CLOCK;
|
||||
case FREIGHT_STATION -> Items.MINECART;
|
||||
case COMMUNAL_DEPOT -> Items.IRON_AXE;
|
||||
case RAIL_WORKSHOP -> Items.IRON_PICKAXE;
|
||||
case PUMP_STATION -> Items.BUCKET;
|
||||
case GREENHOUSE -> Items.IRON_HOE;
|
||||
case VOID_DOCK -> Items.LEAD;
|
||||
case PHOTO_CLUB -> Items.BOOK;
|
||||
case CONSTRUCTION_REFUGE -> Items.IRON_SHOVEL;
|
||||
};
|
||||
}
|
||||
private static Set<BlockPos> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside) {
|
||||
var level = helper.getLevel(); helper.assertTrue(inside.test(start) && standable(level, start), "Blocked or unsupported exterior entrance: " + start);
|
||||
var seen = new HashSet<BlockPos>(); var queue = new ArrayDeque<BlockPos>(); seen.add(start); queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
var pos = queue.removeFirst();
|
||||
for (var direction : HORIZONTAL) for (int dy = -1; dy <= 1; dy++) {
|
||||
var next = pos.relative(direction).offset(0, dy, 0);
|
||||
if (!inside.test(next) || dy > 0 && !clearBody(level, pos.above())) continue;
|
||||
if (standable(level, next) && seen.add(next)) queue.add(next);
|
||||
}
|
||||
for (int dy : new int[] {-1, 1}) {
|
||||
var next = pos.offset(0, dy, 0); if (!inside.test(next)) continue;
|
||||
if ((level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(next).is(Blocks.LADDER) || level.getBlockState(pos.below()).is(Blocks.LADDER))
|
||||
&& standable(level, next) && seen.add(next)) queue.add(next);
|
||||
}
|
||||
helper.assertTrue(seen.size() <= 50_000, "Native site traversal escaped its bounds");
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
private static boolean standable(ServerLevel level, BlockPos pos) {
|
||||
if (!clearBody(level, pos)) return false;
|
||||
if (level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(pos.below()).is(Blocks.LADDER)) return true;
|
||||
var below = pos.below(); return level.getBlockState(below).getCollisionShape(level, below).toAabbs().stream()
|
||||
.anyMatch(b -> b.maxY >= .5 && b.minX <= .5 && b.maxX >= .5 && b.minZ <= .5 && b.maxZ >= .5);
|
||||
}
|
||||
private static boolean clearBody(ServerLevel level, BlockPos pos) {
|
||||
var body = new AABB(pos.getX() + .2, pos.getY(), pos.getZ() + .2, pos.getX() + .8, pos.getY() + 1.8, pos.getZ() + .8);
|
||||
for (var at : new BlockPos[] {pos, pos.above()}) {
|
||||
if (!level.getFluidState(at).isEmpty()) return false;
|
||||
for (var shape : level.getBlockState(at).getCollisionShape(level, at).toAabbs()) if (shape.move(at.getX(), at.getY(), at.getZ()).intersects(body)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private static long column(int x, int z) { return (long) x << 32 ^ z & 0xffffffffL; }
|
||||
private static List<Integer> coords(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
|
||||
private static BlockPos readPos(com.google.gson.JsonArray value) { return new BlockPos(value.get(0).getAsInt(), value.get(1).getAsInt(), value.get(2).getAsInt()); }
|
||||
private static Path world(ServerLevel level) { return level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); }
|
||||
private static Path proof(ServerLevel level) { return world(level).resolve("heritage19-create-proof.json"); }
|
||||
private static String planHash(HeritagePlan19 plan) { return hash(plan.sites().toString() + plan.landmarks() + plan.links() + plan.segments()); }
|
||||
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
|
||||
var path = Path.of("diagnostics", "heritage19-" + (reload ? "reload" : "create") + ".json"); Files.createDirectories(path.getParent());
|
||||
var value = new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n"; Files.writeString(path, value); if (!reload) Files.writeString(proof(level), value);
|
||||
}
|
||||
private static String hash(String value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
|
||||
}
|
||||
}
|
||||
+556
@@ -0,0 +1,556 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
|
||||
import fr.koka.sanctuary.worldgen.heritage.HeritagePiece20;
|
||||
import fr.koka.sanctuary.worldgen.heritage.HeritagePlan19;
|
||||
import fr.koka.sanctuary.worldgen.heritage.HeritageRenderer20;
|
||||
import fr.koka.sanctuary.worldgen.heritage.HeritageStructures19;
|
||||
import fr.koka.sanctuary.worldgen.ruins.RuinsPiece20;
|
||||
import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17;
|
||||
import fr.koka.sanctuary.worldgen.ruins.RuinsRenderer20;
|
||||
import fr.koka.sanctuary.worldgen.ruins.RuinsStructures17;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.DoorBlock;
|
||||
import net.minecraft.world.level.block.TrapDoorBlock;
|
||||
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.minecraft.world.level.storage.loot.LootParams;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
|
||||
import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Native creation/reload proof confined to a disposable generation20 starter island. */
|
||||
public final class Heritage20WorldGameTests {
|
||||
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
|
||||
@GameTest(maxTicks = 12000)
|
||||
public void heritagePersistsWithRealDestinations(GameTestHelper helper) {
|
||||
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
|
||||
"The heritage proof requires a Sanctuary generator");
|
||||
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
helper.assertTrue(generator.rootGeneration() == 20, "Use a fresh generation20 test world; do not convert an old save");
|
||||
new Scenario(helper, generator, Boolean.getBoolean("sanctuary.test.expansionReload")).advance();
|
||||
}
|
||||
private static final class Scenario {
|
||||
final GameTestHelper helper;
|
||||
final ServerLevel level;
|
||||
final SanctuaryChunkGenerator generator;
|
||||
final HeritagePlan19 plan;
|
||||
final boolean reload;
|
||||
final Map<String, Object> report = new LinkedHashMap<>();
|
||||
final List<ChunkPos> chunks = new ArrayList<>();
|
||||
final List<StructureStart> starts = new ArrayList<>();
|
||||
final List<StructureStart> ruinsStarts = new ArrayList<>();
|
||||
JsonObject previous;
|
||||
BlockPos gold;
|
||||
int nextChunk;
|
||||
boolean prepared;
|
||||
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator, boolean reload) {
|
||||
this.helper = helper; this.level = helper.getLevel(); this.generator = generator; this.reload = reload;
|
||||
this.plan = generator.heritagePlan19();
|
||||
report.put("seed", level.getSeed()); report.put("size", generator.initialDiameter());
|
||||
report.put("process_id", ProcessHandle.current().pid()); report.put("world_directory", world(level).toString());
|
||||
report.put("experimental_structures", generator.experimentalStructures());
|
||||
report.put("structures_enabled", level.structureManager().shouldGenerateStructures());
|
||||
report.put("scope", "Native local starts, saved sites and routes, player-sized entrances, supports and unopened chests on the starter island. "
|
||||
+ "All14 tool tables are sampled separately without consuming a saved chest. Door states are restored after traversal. "
|
||||
+ "No personal save or expansion is opened; one disposable outer-wall edit is checked in a second JVM. "
|
||||
+ "Missing rails are intentional; this proof checks walking and saved rail shapes, not an uninterrupted minecart journey.");
|
||||
}
|
||||
void advance() {
|
||||
try {
|
||||
if (!prepared) { report.put("stage", "prepare"); prepare(); prepared = true; if (plan.isEmpty()) { finishDisabled(); return; } }
|
||||
if (nextChunk < chunks.size()) {
|
||||
report.put("stage", "load_full_chunks"); var pos = chunks.get(nextChunk++);
|
||||
helper.assertTrue(level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true) instanceof LevelChunk,
|
||||
"Heritage footprint must reach native FULL: " + pos);
|
||||
if (nextChunk % 20 == 0) SanctuaryMod.LOGGER.info("Heritage20 loaded {}/{} footprint chunks", nextChunk, chunks.size());
|
||||
helper.runAtTickTime(helper.getTick() + 1, this::advance); return;
|
||||
}
|
||||
report.put("stage", "verify_native_geometry"); verifyPlaced(); report.put("passed", true);
|
||||
if (!reload) {
|
||||
helper.assertTrue(gold != null && level.getBlockState(gold).isCollisionShapeFullBlock(level, gold), "Gold witness must replace a solid wall away from circulation");
|
||||
level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3); report.put("gold_witness", coords(gold));
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native starts, unopened loot and the wall edit");
|
||||
report.put("world_flushed_before_shutdown", true);
|
||||
} else {
|
||||
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Reload replaced the player's wall edit");
|
||||
report.put("gold_witness", coords(gold)); report.put("gold_witness_preserved", true);
|
||||
}
|
||||
write(level, report, reload); helper.succeed();
|
||||
} catch (Exception failure) {
|
||||
report.put("passed", false); report.put("failure", failure.toString());
|
||||
try { write(level, report, reload); } catch (IOException ignored) { }
|
||||
throw new IllegalStateException("Heritage20 native " + (reload ? "reload" : "creation") + " failed", failure);
|
||||
}
|
||||
}
|
||||
void prepare() throws IOException {
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
|
||||
var encoded = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow();
|
||||
var decoded = ChunkGenerator.CODEC.parse(ops, encoded).getOrThrow();
|
||||
helper.assertTrue(decoded instanceof SanctuaryChunkGenerator copy && copy.rootGeneration() == 20
|
||||
&& copy.experimentalStructures() == generator.experimentalStructures(), "Generation20 codec lost its experimental switch");
|
||||
report.put("generator_codec", encoded); report.put("plan_sha256", planHash(plan));
|
||||
report.put("planned_sites", plan.sites().stream().map(site -> Map.of("id", site.id(), "kind", site.kind().name(),
|
||||
"entry", coords(HeritagePiece20.locator(site)), "floor_y", site.floorY(), "foundation_depth", site.foundationDepth(), "footings", site.supports().size())).toList());
|
||||
report.put("planned_links", plan.links().stream().map(link -> Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(), "path", pathCoords(link))).toList());
|
||||
report.put("planning_samples", plan.samples()); report.put("planning_columns", plan.columns()); report.put("planning_diagnostics", plan.diagnostics());
|
||||
helper.assertTrue(plan.samples() <= HeritagePlan19.MAX_SAMPLES && plan.columns() <= HeritagePlan19.MAX_COLUMNS, "Heritage planning exceeded its budget");
|
||||
verifyLootTables(helper, report);
|
||||
if (reload) {
|
||||
previous = JsonParser.parseString(Files.readString(proof(level))).getAsJsonObject();
|
||||
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(), "Reload needs a successful flushed creation proof");
|
||||
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must use a second JVM");
|
||||
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed() && previous.get("size").getAsInt() == generator.initialDiameter()
|
||||
&& previous.get("world_directory").getAsString().equals(world(level).toString()), "Reload selected another world");
|
||||
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("plan_sha256").getAsString().equals(planHash(plan)), "Saved settings or planned identities changed on reload");
|
||||
if (previous.has("gold_witness")) gold = readPos(previous.getAsJsonArray("gold_witness"));
|
||||
}
|
||||
if (!generator.experimentalStructures() || !level.structureManager().shouldGenerateStructures()) {
|
||||
helper.assertTrue(plan.isEmpty() && plan.segments().isEmpty() && plan.links().isEmpty(), "Disabled generation published heritage geometry"); return;
|
||||
}
|
||||
helper.assertTrue(!plan.isEmpty(), "Enabled reference island has no heritage site");
|
||||
report.put("present_site_kinds", plan.sites().stream().map(s -> s.kind().name()).distinct().sorted().toList());
|
||||
report.put("missing_site_kinds", java.util.Arrays.stream(HeritagePlan19.Kind.values()).filter(k -> plan.sites().stream().noneMatch(s -> s.kind() == k)).map(Enum::name).toList());
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
|
||||
var origins = new HashSet<ChunkPos>(); var uniqueChunks = new HashSet<ChunkPos>();
|
||||
plan.sites().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
|
||||
plan.segments().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
|
||||
for (var origin : origins.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList()) {
|
||||
var chunk = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true);
|
||||
var start = chunk.getStartForStructure(structure.value());
|
||||
helper.assertTrue(start != null && start.isValid(), "Accepted site/route has no native start at " + origin); starts.add(start);
|
||||
for (var piece : start.getPieces()) {
|
||||
helper.assertTrue(piece instanceof HeritagePiece20, "Heritage loaded a foreign piece type");
|
||||
var box = piece.getBoundingBox();
|
||||
helper.assertTrue(Math.abs((long) (box.minX() >> 4) - origin.x()) <= 7 && Math.abs((long) (box.maxX() >> 4) - origin.x()) <= 7
|
||||
&& Math.abs((long) (box.minZ() >> 4) - origin.z()) <= 7 && Math.abs((long) (box.maxZ() >> 4) - origin.z()) <= 7, "Native start references a distant route endpoint");
|
||||
box.intersectingChunks().forEach(uniqueChunks::add);
|
||||
}
|
||||
}
|
||||
for (var site : plan.sites()) uniqueChunks.add(ChunkPos.containing(HeritagePiece20.locator(site)));
|
||||
for (var link : plan.links()) for (var point : link.path()) uniqueChunks.add(new ChunkPos(point.x() >> 4, point.z() >> 4));
|
||||
helper.assertTrue(uniqueChunks.size() <= 1024, "Bounded proof footprint exceeds1024 chunks");
|
||||
var ruins = generator.ruinsPlan17();
|
||||
report.put("ruins_plan_sha256", hash(ruins.networks().toString()));
|
||||
if (reload) helper.assertTrue(previous.get("ruins_plan_sha256").getAsString().equals(hash(ruins.networks().toString())), "Reload changed forgotten room planning");
|
||||
helper.assertTrue(!ruins.isEmpty(), "Reference20 must demonstrate native experimental room architecture");
|
||||
var ruinsType = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
|
||||
var ruinsOrigins = new HashSet<ChunkPos>();
|
||||
for (var network : ruins.networks()) {
|
||||
var origin = new ChunkPos(network.startChunkX(), network.startChunkZ()); if (!ruinsOrigins.add(origin)) continue;
|
||||
var nativeStart = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(ruinsType.value());
|
||||
helper.assertTrue(nativeStart != null && nativeStart.isValid(), "Missing native20 forgotten network: " + network.id());
|
||||
ruinsStarts.add(nativeStart);
|
||||
for (var piece : nativeStart.getPieces()) {
|
||||
helper.assertTrue(piece instanceof RuinsPiece20, "Generation20 loaded a historical forgotten piece");
|
||||
piece.getBoundingBox().intersectingChunks().forEach(uniqueChunks::add);
|
||||
}
|
||||
}
|
||||
chunks.addAll(uniqueChunks.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList());
|
||||
}
|
||||
void finishDisabled() throws IOException {
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
|
||||
var start = level.getChunk(0, 0, ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(structure.value());
|
||||
helper.assertTrue(start == null || !start.isValid(), "Disabled heritage emitted a native start");
|
||||
helper.assertTrue(generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false) == null, "Disabled heritage is locatable");
|
||||
report.put("empty_plan_verified", true); report.put("passed", true);
|
||||
if (!reload) { helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save disabled experiment setting"); report.put("world_flushed_before_shutdown", true); }
|
||||
write(level, report, reload); helper.succeed();
|
||||
}
|
||||
void verifyPlaced() {
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
|
||||
var context = StructurePieceSerializationContext.fromLevel(level); var summaries = new ArrayList<Map<String, Object>>();
|
||||
int blocks = 0, chests = 0, pieces = 0, supports = 0;
|
||||
var connections = NativeConnections20.Stats.empty();
|
||||
for (var start : starts) {
|
||||
var origin = start.getChunkPos(); var saved = start.createTag(context, origin); String nativeHash = hash(saved.toString());
|
||||
var copy = StructureStart.loadStaticStart(context, saved, level.getSeed());
|
||||
helper.assertTrue(copy != null && copy.isValid() && copy.createTag(context, origin).equals(saved), "Native start failed an exact NBT roundtrip");
|
||||
var expectedPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
|
||||
plan.sites().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece20.encodeSite(s)));
|
||||
plan.segments().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece20.encodeSegment(s)));
|
||||
var actualPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
|
||||
if (reload) {
|
||||
JsonObject prior = null;
|
||||
for (var value : previous.getAsJsonArray("starts")) { var item = value.getAsJsonObject(); var at = item.getAsJsonArray("start_chunk"); if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = item; }
|
||||
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(nativeHash), "Reload changed saved geometry or native reference count");
|
||||
}
|
||||
var loot = new ArrayList<Map<String, Object>>();
|
||||
for (var value : start.getPieces()) {
|
||||
var piece = (HeritagePiece20) value; pieces++;
|
||||
var tag = piece.createTag(context); var restored = new HeritagePiece20(context, tag);
|
||||
helper.assertTrue(restored.createTag(context).equals(tag), "Saved piece parameters changed on native load");
|
||||
actualPayloads.add(tag.getCompound("SanctuaryHeritage").orElseThrow());
|
||||
var corrupt = tag.copy(); corrupt.getCompound("SanctuaryHeritage").orElseThrow().putInt("Schema", 21);
|
||||
boolean rejected = false; try { new HeritagePiece20(context, corrupt); } catch (IllegalArgumentException expected) { rejected = true; }
|
||||
helper.assertTrue(rejected, "Unknown schema must fail closed");
|
||||
piece.getBoundingBox().intersectingChunks().forEach(at -> helper.assertTrue(level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true)
|
||||
.getReferencesForStructure(structure.value()).contains(origin.pack()), "FULL chunk lost its native structure reference"));
|
||||
var expected = rendered(piece); blocks += expected.blocks().size();
|
||||
for (var entry : expected.blocks().entrySet()) {
|
||||
var at = entry.getKey(); var wanted = entry.getValue(); helper.assertTrue(piece.getBoundingBox().isInside(at), "Renderer escaped saved bounds");
|
||||
if (reload && at.equals(gold)) continue;
|
||||
helper.assertTrue(level.getBlockState(at).is(wanted.getBlock()), "Clipping or decoration changed heritage at " + at + ": expected " + wanted + " got " + level.getBlockState(at));
|
||||
helper.assertTrue(!wanted.is(Blocks.SPAWNER), "Heritage must not create spawners");
|
||||
}
|
||||
connections = connections.add(NativeConnections20.verify(helper, expected.blocks(), reload ? Set.of(gold) : Set.of()));
|
||||
if (piece.isSite()) {
|
||||
supports += verifySiteBearing(helper, generator, plan, piece.site());
|
||||
if (!reload && gold == null) {
|
||||
gold = expected.blocks().entrySet().stream().filter(e -> isOuterWall(piece.site(), e.getKey()) && e.getValue().isCollisionShapeFullBlock(level, e.getKey()))
|
||||
.map(Map.Entry::getKey).sorted(Comparator.<BlockPos>comparingInt(p -> p.getY()).thenComparingInt(p -> p.getX()).thenComparingInt(p -> p.getZ())).findFirst().orElse(null);
|
||||
}
|
||||
} else for (var cell : piece.segment().cells()) { supports++; assertRock(helper, new BlockPos(cell.x(), cell.minY() - 1, cell.z())); }
|
||||
for (var chest : expected.chests()) {
|
||||
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Deferred chest is missing");
|
||||
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
|
||||
helper.assertTrue(entity.getLootTable() != null && entity.getLootTable().equals(chest.table()) && entity.getLootTableSeed() == chest.seed(), "Deferred loot table/seed was lost or consumed");
|
||||
if (piece.isSite()) helper.assertTrue(chest.table().equals(lootKey(piece.site().kind())), "Site uses another kind's tool table");
|
||||
loot.add(Map.of("position", coords(chest.position()), "table", chest.table().identifier().toString(), "seed", chest.seed())); chests++;
|
||||
}
|
||||
}
|
||||
helper.assertTrue(actualPayloads.equals(expectedPayloads) && start.getPieces().size() == expectedPayloads.size(), "Native start lost or duplicated planned pieces");
|
||||
summaries.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", nativeHash, "pieces", start.getPieces().size(), "unopened_chests", loot));
|
||||
}
|
||||
report.put("connected_blocks", connections);
|
||||
helper.assertTrue(connections.panes() > 0 && connections.stairs() > 0 && connections.nativeOracles() > 0,
|
||||
"Reference20 needs real panes/stairs validated by vanilla neighbor rules");
|
||||
if (level.getSeed() == 0 && generator.initialDiameter() == 724)
|
||||
helper.assertTrue(connections.crossChunkPaneLinks() > 0, "Reference20 must demonstrate a real pane join across a chunk boundary");
|
||||
verifyRuins();
|
||||
report.put("starts", summaries); report.put("native_pieces", pieces); report.put("native_blocks_checked", blocks);
|
||||
report.put("full_chunks_checked", chunks.size()); report.put("rock_supports_checked", supports); report.put("unopened_deferred_chests", chests);
|
||||
report.put("stage", "verify_site_routes"); var sites = new ArrayList<Map<String, Object>>(); for (var site : plan.sites()) sites.add(verifyAccess(helper, site)); report.put("native_sites", sites);
|
||||
report.put("stage", "verify_read_only_landings");
|
||||
report.put("read_only_landmarks", verifyReadOnlyLandings(helper, generator, plan));
|
||||
report.put("stage", "verify_island_routes"); report.put("native_links", verifyLinks(helper, plan));
|
||||
int greatestSpan = plan.links().stream().mapToInt(Heritage20WorldGameTests::routeSpan).max().orElse(0);
|
||||
report.put("greatest_single_route_span", greatestSpan);
|
||||
if (level.getSeed() == 0 && generator.initialDiameter() == 724) {
|
||||
int requiredSpan = generator.initialDiameter() / 4;
|
||||
report.put("reference_minimum_route_span", requiredSpan);
|
||||
helper.assertTrue(greatestSpan >= requiredSpan, "Seed0 Medium must demonstrate an island-scale route spanning at least "
|
||||
+ requiredSpan + " blocks on X or Z; got " + greatestSpan);
|
||||
}
|
||||
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
|
||||
helper.assertTrue(located != null && located.getSecond().is(HeritageStructures19.KEY) && plan.sites().stream().anyMatch(s -> HeritagePiece20.locator(s).equals(located.getFirst())), "Locate must lead to a real exterior site entry");
|
||||
report.put("locate", coords(located.getFirst()));
|
||||
}
|
||||
void verifyRuins() {
|
||||
var context = StructurePieceSerializationContext.fromLevel(level);
|
||||
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
|
||||
var summaries = new ArrayList<Map<String, Object>>(); int count = 0, blockCount = 0, lootCount = 0;
|
||||
var connections = NativeConnections20.Stats.empty();
|
||||
for (var start : ruinsStarts) {
|
||||
var origin = start.getChunkPos(); var saved = start.createTag(context, origin); var digest = hash(saved.toString());
|
||||
var loaded = StructureStart.loadStaticStart(context, saved, level.getSeed());
|
||||
helper.assertTrue(loaded != null && loaded.createTag(context, origin).equals(saved), "Forgotten20 native start NBT changed");
|
||||
var expectedRooms = generator.ruinsPlan17().networks().stream().filter(n -> n.startChunkX() == origin.x() && n.startChunkZ() == origin.z()).flatMap(n -> n.rooms().stream()).toList();
|
||||
var actualRooms = start.getPieces().stream().map(RuinsPiece20.class::cast).map(RuinsPiece20::room).toList();
|
||||
helper.assertTrue(expectedRooms.size() == actualRooms.size() && new HashSet<>(expectedRooms).equals(new HashSet<>(actualRooms)), "Forgotten20 native rooms differ from plan");
|
||||
if (reload) {
|
||||
JsonObject prior = null;
|
||||
for (var value : previous.getAsJsonArray("ruins_starts")) { var item = value.getAsJsonObject(); var at = item.getAsJsonArray("start_chunk"); if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = item; }
|
||||
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(digest), "Forgotten20 native start changed on a second JVM load");
|
||||
}
|
||||
for (var raw : start.getPieces()) {
|
||||
var piece = (RuinsPiece20) raw; var room = piece.room(); count++;
|
||||
var tag = piece.createTag(context); var roundtrip = new RuinsPiece20(context, tag);
|
||||
helper.assertTrue(roundtrip.room().equals(room) && roundtrip.createTag(context).equals(tag), "Forgotten20 piece lost saved parameters");
|
||||
piece.getBoundingBox().intersectingChunks().forEach(at -> helper.assertTrue(level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true).getReferencesForStructure(registry.value()).contains(origin.pack()), "Forgotten20 chunk lost native references"));
|
||||
var expected = rendered(room); blockCount += expected.blocks().size();
|
||||
for (var entry : expected.blocks().entrySet()) {
|
||||
helper.assertTrue(piece.getBoundingBox().isInside(entry.getKey()), "Forgotten20 renderer escaped saved bounds");
|
||||
helper.assertTrue(level.getBlockState(entry.getKey()).is(entry.getValue().getBlock()), "Forgotten20 native geometry changed at " + entry.getKey());
|
||||
}
|
||||
connections = connections.add(NativeConnections20.verify(helper, expected.blocks(), Set.of()));
|
||||
for (var support : room.supports()) assertRock(helper, new BlockPos(support.x(), support.minY() - 1, support.z()));
|
||||
for (var chest : expected.chests()) {
|
||||
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Forgotten20 missing deferred chest");
|
||||
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
|
||||
helper.assertTrue(chest.table().equals(entity.getLootTable()) && chest.seed() == entity.getLootTableSeed(), "Forgotten20 consumed/changed saved loot"); lootCount++;
|
||||
}
|
||||
}
|
||||
summaries.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", digest, "pieces", start.getPieces().size()));
|
||||
}
|
||||
report.put("ruins_starts", summaries); report.put("ruins_native_pieces", count); report.put("ruins_native_blocks_checked", blockCount);
|
||||
report.put("ruins_unopened_deferred_chests", lootCount); report.put("ruins_connections", connections);
|
||||
report.put("ruins_access", generator.ruinsPlan17().networks().stream().map(n -> verifyAccess(helper, n)).toList());
|
||||
}
|
||||
}
|
||||
private record Chest(BlockPos position, ResourceKey<LootTable> table, long seed) {}
|
||||
private record Rendered(Map<BlockPos, BlockState> blocks, List<Chest> chests) {}
|
||||
private static Rendered rendered(HeritagePiece20 piece) {
|
||||
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
|
||||
int x0 = piece.isSite() ? piece.site().minX() : piece.segment().minX();
|
||||
int y0 = piece.isSite() ? piece.site().floorY() : piece.segment().minY();
|
||||
int z0 = piece.isSite() ? piece.site().minZ() : piece.segment().minZ();
|
||||
var writer = new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(new BlockPos(x0 + x, y0 + y, z0 + z), state); }
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
|
||||
var at = new BlockPos(x0 + x, y0 + y, z0 + z); blocks.put(at, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(at, table, RandomSource.create(seed).nextLong()));
|
||||
}
|
||||
};
|
||||
if (piece.isSite()) HeritageRenderer20.render(piece.site(), writer); else HeritageRenderer20.renderSegment(piece.segment(), writer);
|
||||
return new Rendered(blocks, chests);
|
||||
}
|
||||
private static Rendered rendered(RuinsPlan17.Room room) {
|
||||
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
|
||||
RuinsRenderer20.render(room, new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(at(room, x, y, z), state); }
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
|
||||
var position = at(room, x, y, z); blocks.put(position, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(position, table, RandomSource.create(seed).nextLong()));
|
||||
}
|
||||
});
|
||||
return new Rendered(blocks, chests);
|
||||
}
|
||||
private static Map<String, Object> verifyAccess(GameTestHelper helper, RuinsPlan17.Network network) {
|
||||
var level = helper.getLevel(); var access = network.rooms().stream().filter(r -> r.kind() == RuinsPlan17.Kind.ACCESS).findFirst().orElseThrow();
|
||||
var hatches = new HashMap<BlockPos, BlockState>();
|
||||
rendered(access).blocks().forEach((at, state) -> { if (state.is(Blocks.DARK_OAK_TRAPDOOR)) hatches.put(at, level.getBlockState(at)); });
|
||||
helper.assertTrue(hatches.size() == 1, "Each network must have one usable wooden surface hatch");
|
||||
hatches.forEach((at, state) -> {
|
||||
helper.assertTrue(state.is(Blocks.DARK_OAK_TRAPDOOR) && state.canSurvive(level, at), "Surface hatch is missing its native support");
|
||||
level.setBlock(at, state.setValue(BlockStateProperties.OPEN, true), 2);
|
||||
});
|
||||
try {
|
||||
Predicate<BlockPos> inside = pos -> network.rooms().stream().anyMatch(r -> r.contains(pos.getX(), pos.getY(), pos.getZ()));
|
||||
var start = RuinsPiece20.locator(access); var reached = walk(helper, start, inside);
|
||||
int targets = 0;
|
||||
for (var room : network.rooms()) for (var target : RuinsRenderer20.targets(room)) {
|
||||
var point = at(room, target.x(), target.y(), target.z());
|
||||
helper.assertTrue(reached.contains(point), "Native network " + network.id() + " cannot reach " + room.kind() + "/" + target.name() + " at " + point);
|
||||
targets++;
|
||||
}
|
||||
for (var hatch : hatches.keySet()) helper.assertTrue(reached.contains(hatch), "Surface approach cannot enter its ladder shaft");
|
||||
return Map.of("id", network.id(), "family", network.family().name(), "rooms", network.rooms().size(),
|
||||
"entrance", coords(start), "walkable_targets", targets, "standing_cells", reached.size());
|
||||
} finally { hatches.forEach((at, state) -> level.setBlock(at, state, 2)); }
|
||||
}
|
||||
private static BlockPos at(RuinsPlan17.Room room, int x, int y, int z) { return new BlockPos(room.minX() + x, room.minY() + y, room.minZ() + z); }
|
||||
private static boolean isOuterWall(HeritagePlan19.Site site, BlockPos pos) {
|
||||
return pos.getY() >= site.floorY() + 2 && pos.getY() <= site.floorY() + 3
|
||||
&& (pos.getX() == site.minX() + 2 || pos.getX() == site.maxX() - 2 || pos.getZ() == site.minZ() + 2 || pos.getZ() == site.maxZ() - 2)
|
||||
&& Math.abs(pos.getX() - site.entry().x()) + Math.abs(pos.getZ() - site.entry().z()) > 6;
|
||||
}
|
||||
private static Map<String, Object> verifyAccess(GameTestHelper helper, HeritagePlan19.Site site) {
|
||||
var level = helper.getLevel(); var expected = rendered(new HeritagePiece20(site)); var doors = new HashMap<BlockPos, BlockState>();
|
||||
expected.blocks().forEach((at, state) -> {
|
||||
if ((state.getBlock() instanceof DoorBlock || state.getBlock() instanceof TrapDoorBlock) && state.hasProperty(BlockStateProperties.OPEN)) doors.put(at, level.getBlockState(at));
|
||||
});
|
||||
doors.forEach((at, state) -> level.setBlock(at, state.setValue(BlockStateProperties.OPEN, true), 2));
|
||||
try {
|
||||
var entry = HeritagePiece20.locator(site);
|
||||
Predicate<BlockPos> inside = p -> p.equals(entry) || p.getX() >= site.minX() && p.getX() <= site.maxX()
|
||||
&& p.getZ() >= site.minZ() && p.getZ() <= site.maxZ() && p.getY() >= site.floorY() - 4 && p.getY() < site.maxY();
|
||||
var reached = walk(helper, entry, inside); int targets = 0;
|
||||
for (var target : HeritageRenderer20.targets(site)) {
|
||||
var at = new BlockPos(site.minX() + target.x(), site.floorY() + target.y(), site.minZ() + target.z());
|
||||
helper.assertTrue(reached.contains(at), "Site " + site.id() + " cannot reach " + target.name() + " at " + at); targets++;
|
||||
}
|
||||
return Map.of("id", site.id(), "kind", site.kind().name(), "entry", coords(entry), "standing_cells", reached.size(), "walkable_targets", targets);
|
||||
} finally { doors.forEach((at, state) -> level.setBlock(at, state, 2)); }
|
||||
}
|
||||
private static List<Map<String, Object>> verifyLinks(GameTestHelper helper, HeritagePlan19 plan) {
|
||||
helper.assertTrue(!plan.links().isEmpty(), "Reference island needs a real route between accepted destinations");
|
||||
var entries = new HashMap<String, HeritagePlan19.Entry>(); plan.sites().forEach(s -> entries.put(s.id(), s.entry())); plan.landmarks().forEach(s -> entries.put(s.id(), s.entry()));
|
||||
var cells = new HashMap<Long, HeritagePlan19.Cell>(); plan.segments().forEach(s -> s.cells().forEach(c -> cells.put(column(c.x(), c.z()), c)));
|
||||
var result = new ArrayList<Map<String, Object>>();
|
||||
for (var link : plan.links()) {
|
||||
var from = entries.get(link.fromId()); var to = entries.get(link.toId());
|
||||
helper.assertTrue(from != null && to != null, "Route points to an absent destination");
|
||||
var first = link.path().getFirst(); var last = link.path().getLast();
|
||||
helper.assertTrue(first.x() == from.x() && first.z() == from.z() && first.floorY() + 1 == from.y()
|
||||
&& last.x() == to.x() && last.z() == to.z() && last.floorY() + 1 == to.y(), "Route does not meet its real entrances");
|
||||
int present = 0, missing = 0;
|
||||
for (int i = 0; i < link.path().size(); i++) {
|
||||
var point = link.path().get(i); var at = new BlockPos(point.x(), point.floorY() + 1, point.z());
|
||||
helper.assertTrue(standable(helper.getLevel(), at), "Ballast route " + link.id() + " is blocked at " + at);
|
||||
if (i > 0) { var prior = link.path().get(i - 1); if (point.floorY() > prior.floorY()) helper.assertTrue(clearBody(helper.getLevel(), new BlockPos(prior.x(), prior.floorY() + 2, prior.z())), "Route slope cannot be climbed by a player"); }
|
||||
var cell = cells.get(column(point.x(), point.z()));
|
||||
if (cell != null && cell.rail() != HeritagePlan19.Rail.NONE) {
|
||||
var actual = helper.getLevel().getBlockState(at);
|
||||
if (cell.railPresent()) {
|
||||
helper.assertTrue(actual.is(Blocks.RAIL), "Saved existing rail disappeared");
|
||||
helper.assertTrue(actual.getValue(BlockStateProperties.RAIL_SHAPE).name().equals(cell.rail().name()), "Native rail update changed the intended slope or curve at " + at); present++;
|
||||
} else { helper.assertTrue(!actual.is(Blocks.RAIL), "An intentionally missing rail regenerated"); missing++; }
|
||||
}
|
||||
}
|
||||
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
|
||||
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
|
||||
result.add(Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(),
|
||||
"span_x", spanX, "span_z", spanZ, "existing_rails", present, "deliberately_missing_rails", missing, "path", pathCoords(link)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private static int routeSpan(HeritagePlan19.Link link) {
|
||||
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow()
|
||||
- link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
|
||||
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow()
|
||||
- link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
|
||||
return Math.max(spanX, spanZ);
|
||||
}
|
||||
private static List<List<Integer>> pathCoords(HeritagePlan19.Link link) {
|
||||
return link.path().stream().map(point -> List.of(point.x(), point.floorY(), point.z())).toList();
|
||||
}
|
||||
private static int verifySiteBearing(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan, HeritagePlan19.Site site) {
|
||||
int checked = 0;
|
||||
// The planner certifies a nine-point bearing grid, either directly below its slab or below a saved short footing.
|
||||
for (int dx : new int[] {1, 8, 15}) for (int dz : new int[] {1, 8, 15}) {
|
||||
int x = site.minX() + dx, z = site.minZ() + dz;
|
||||
var support = site.supports().stream().filter(s -> s.x() == x && s.z() == z).findFirst();
|
||||
int bottom = support.map(HeritagePlan19.Support::minY).orElse(site.minY());
|
||||
for (int below = 1; below <= 2; below++) {
|
||||
var at = new BlockPos(x, bottom - below, z); assertRock(helper, at);
|
||||
helper.assertTrue(plan.protects(at.getX(), at.getY(), at.getZ()) && generator.protectsCityDecoration(at),
|
||||
"Site's certified bearing layer is not published in the decoration mask: " + site.id() + " at " + at);
|
||||
helper.assertTrue(!plan.contains(at.getX(), at.getY(), at.getZ()), "Original bearing rock became a writable heritage volume: " + at);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
return checked;
|
||||
}
|
||||
private static List<Map<String, Object>> verifyReadOnlyLandings(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan) {
|
||||
var used = new HashSet<String>(); plan.links().forEach(link -> { used.add(link.fromId()); used.add(link.toId()); });
|
||||
var result = new ArrayList<Map<String, Object>>();
|
||||
for (var landmark : plan.landmarks()) if (used.contains(landmark.id())) {
|
||||
var e = landmark.entry(); int checked = 0;
|
||||
for (int side = -1; side <= 1; side++) {
|
||||
int x = e.x() + ((e.facing() & 1) == 0 ? side : 0), z = e.z() + ((e.facing() & 1) == 1 ? side : 0);
|
||||
for (int y = e.y() - 2; y <= e.y() + 2; y++) {
|
||||
var at = new BlockPos(x, y, z);
|
||||
helper.assertTrue(plan.protects(x, y, z) && generator.protectsCityDecoration(at), "Read-only arrival lost its decoration mask: " + landmark.id() + " at " + at);
|
||||
helper.assertTrue(!plan.contains(x, y, z), "Heritage gained write ownership of a historical arrival: " + landmark.id() + " at " + at);
|
||||
if (y == e.y() - 1) assertRock(helper, at);
|
||||
if (y >= e.y()) helper.assertTrue(helper.getLevel().getBlockState(at).isAir(), "Decoration obstructed a certified historical arrival: " + at);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
result.add(Map.of("id", landmark.id(), "entry", List.of(e.x(), e.y(), e.z()), "read_only_cells", checked));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private static void assertRock(GameTestHelper helper, BlockPos at) {
|
||||
helper.assertTrue(helper.getLevel().getBlockState(at).isSolid() && helper.getLevel().getFluidState(at).isEmpty(), "Lost original rock bearing at " + at);
|
||||
}
|
||||
private static void verifyLootTables(GameTestHelper helper, Map<String, Object> report) {
|
||||
var result = new ArrayList<Map<String, Object>>(); var level = helper.getLevel();
|
||||
var params = new LootParams.Builder(level).withParameter(LootContextParams.ORIGIN, new Vec3(0, 160, 0)).create(LootContextParamSets.CHEST);
|
||||
for (var kind : HeritagePlan19.Kind.values()) {
|
||||
var table = level.getServer().reloadableRegistries().getLootTable(lootKey(kind));
|
||||
helper.assertTrue(table != LootTable.EMPTY, "Heritage tool table did not load: " + kind);
|
||||
for (long seed : new long[] {0, 42, -1}) helper.assertTrue(table.getRandomItems(params, seed).stream().anyMatch(stack -> stack.is(tool(kind))), "Tool table is empty or lacks its expected primary tool: " + kind);
|
||||
result.add(Map.of("kind", kind.name(), "table", lootKey(kind).identifier().toString(), "sampled_seeds", List.of(0, 42, -1)));
|
||||
}
|
||||
report.put("tool_tables_sampled_separately", result);
|
||||
}
|
||||
private static ResourceKey<LootTable> lootKey(HeritagePlan19.Kind kind) { return ResourceKey.create(Registries.LOOT_TABLE, SanctuaryMod.id("chests/heritage19/" + kind.name().toLowerCase(Locale.ROOT))); }
|
||||
private static Item tool(HeritagePlan19.Kind kind) {
|
||||
return switch (kind) {
|
||||
case OBSERVATORY -> Items.SPYGLASS;
|
||||
case POST_OFFICE, EXPEDITION_OFFICE, RADIO_RELAY -> Items.COMPASS;
|
||||
case SUBSTATION, IMPOSSIBLE_SERVICE -> Items.CLOCK;
|
||||
case FREIGHT_STATION -> Items.MINECART;
|
||||
case COMMUNAL_DEPOT -> Items.IRON_AXE;
|
||||
case RAIL_WORKSHOP -> Items.IRON_PICKAXE;
|
||||
case PUMP_STATION -> Items.BUCKET;
|
||||
case GREENHOUSE -> Items.IRON_HOE;
|
||||
case VOID_DOCK -> Items.LEAD;
|
||||
case PHOTO_CLUB -> Items.BOOK;
|
||||
case CONSTRUCTION_REFUGE -> Items.IRON_SHOVEL;
|
||||
};
|
||||
}
|
||||
private static Set<BlockPos> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside) {
|
||||
var level = helper.getLevel(); helper.assertTrue(inside.test(start) && standable(level, start), "Blocked or unsupported exterior entrance: " + start);
|
||||
var seen = new HashSet<BlockPos>(); var queue = new ArrayDeque<BlockPos>(); seen.add(start); queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
var pos = queue.removeFirst();
|
||||
for (var direction : HORIZONTAL) for (int dy = -1; dy <= 1; dy++) {
|
||||
var next = pos.relative(direction).offset(0, dy, 0);
|
||||
if (!inside.test(next) || dy > 0 && !clearBody(level, pos.above())) continue;
|
||||
if (standable(level, next) && seen.add(next)) queue.add(next);
|
||||
}
|
||||
for (int dy : new int[] {-1, 1}) {
|
||||
var next = pos.offset(0, dy, 0); if (!inside.test(next)) continue;
|
||||
if ((level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(next).is(Blocks.LADDER) || level.getBlockState(pos.below()).is(Blocks.LADDER))
|
||||
&& standable(level, next) && seen.add(next)) queue.add(next);
|
||||
}
|
||||
helper.assertTrue(seen.size() <= 50_000, "Native site traversal escaped its bounds");
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
private static boolean standable(ServerLevel level, BlockPos pos) {
|
||||
if (!clearBody(level, pos)) return false;
|
||||
if (level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(pos.below()).is(Blocks.LADDER)) return true;
|
||||
var below = pos.below(); return level.getBlockState(below).getCollisionShape(level, below).toAabbs().stream()
|
||||
.anyMatch(b -> b.maxY >= .5 && b.minX <= .5 && b.maxX >= .5 && b.minZ <= .5 && b.maxZ >= .5);
|
||||
}
|
||||
private static boolean clearBody(ServerLevel level, BlockPos pos) {
|
||||
var body = new AABB(pos.getX() + .2, pos.getY(), pos.getZ() + .2, pos.getX() + .8, pos.getY() + 1.8, pos.getZ() + .8);
|
||||
for (var at : new BlockPos[] {pos, pos.above()}) {
|
||||
if (!level.getFluidState(at).isEmpty()) return false;
|
||||
for (var shape : level.getBlockState(at).getCollisionShape(level, at).toAabbs()) if (shape.move(at.getX(), at.getY(), at.getZ()).intersects(body)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private static long column(int x, int z) { return (long) x << 32 ^ z & 0xffffffffL; }
|
||||
private static List<Integer> coords(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
|
||||
private static BlockPos readPos(com.google.gson.JsonArray value) { return new BlockPos(value.get(0).getAsInt(), value.get(1).getAsInt(), value.get(2).getAsInt()); }
|
||||
private static Path world(ServerLevel level) { return level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); }
|
||||
private static Path proof(ServerLevel level) { return world(level).resolve("heritage20-create-proof.json"); }
|
||||
private static String planHash(HeritagePlan19 plan) { return hash(plan.sites().toString() + plan.landmarks() + plan.links() + plan.segments()); }
|
||||
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
|
||||
var path = Path.of("diagnostics", "heritage20-" + (reload ? "reload" : "create") + ".json"); Files.createDirectories(path.getParent());
|
||||
var value = new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n"; Files.writeString(path, value); if (!reload) Files.writeString(proof(level), value);
|
||||
}
|
||||
private static String hash(String value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
|
||||
}
|
||||
}
|
||||
+554
@@ -0,0 +1,554 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
|
||||
import fr.koka.sanctuary.worldgen.heritage.HeritagePiece21;
|
||||
import fr.koka.sanctuary.worldgen.heritage.HeritagePlan19;
|
||||
import fr.koka.sanctuary.worldgen.heritage.HeritageRenderer21;
|
||||
import fr.koka.sanctuary.worldgen.heritage.HeritageStructures19;
|
||||
import fr.koka.sanctuary.worldgen.ruins.RuinsPiece20;
|
||||
import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17;
|
||||
import fr.koka.sanctuary.worldgen.ruins.RuinsRenderer20;
|
||||
import fr.koka.sanctuary.worldgen.ruins.RuinsStructures17;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.DoorBlock;
|
||||
import net.minecraft.world.level.block.TrapDoorBlock;
|
||||
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.minecraft.world.level.storage.loot.LootParams;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
|
||||
import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Native creation/reload proof confined to a disposable generation21 starter island. */
|
||||
public final class Heritage21WorldGameTests {
|
||||
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
|
||||
@GameTest(maxTicks = 12000)
|
||||
public void heritagePersistsWithRealDestinations(GameTestHelper helper) {
|
||||
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
|
||||
"The heritage proof requires a Sanctuary generator");
|
||||
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
helper.assertTrue(generator.rootGeneration() == 21, "Use a fresh generation21 test world; do not convert an old save");
|
||||
new Scenario(helper, generator, Boolean.getBoolean("sanctuary.test.expansionReload")).advance();
|
||||
}
|
||||
private static final class Scenario {
|
||||
final GameTestHelper helper;
|
||||
final ServerLevel level;
|
||||
final SanctuaryChunkGenerator generator;
|
||||
final HeritagePlan19 plan;
|
||||
final boolean reload;
|
||||
final Map<String, Object> report = new LinkedHashMap<>();
|
||||
final List<ChunkPos> chunks = new ArrayList<>();
|
||||
final List<StructureStart> starts = new ArrayList<>();
|
||||
final List<StructureStart> ruinsStarts = new ArrayList<>();
|
||||
JsonObject previous;
|
||||
BlockPos gold;
|
||||
int nextChunk;
|
||||
boolean prepared;
|
||||
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator, boolean reload) {
|
||||
this.helper = helper; this.level = helper.getLevel(); this.generator = generator; this.reload = reload;
|
||||
this.plan = generator.heritagePlan19();
|
||||
report.put("seed", level.getSeed()); report.put("size", generator.initialDiameter());
|
||||
report.put("process_id", ProcessHandle.current().pid()); report.put("world_directory", world(level).toString());
|
||||
report.put("experimental_structures", generator.experimentalStructures());
|
||||
report.put("structures_enabled", level.structureManager().shouldGenerateStructures());
|
||||
report.put("scope", "Native local starts, saved sites and routes, player-sized entrances, supports and unopened chests on the starter island. "
|
||||
+ "All14 tool tables are sampled separately without consuming a saved chest. Door states are restored after traversal. "
|
||||
+ "No personal save or expansion is opened; one disposable outer-wall edit is checked in a second JVM. "
|
||||
+ "Missing rails are intentional; this proof checks walking and saved rail shapes, not an uninterrupted minecart journey.");
|
||||
}
|
||||
void advance() {
|
||||
try {
|
||||
if (!prepared) { report.put("stage", "prepare"); prepare(); prepared = true; if (plan.isEmpty()) { finishDisabled(); return; } }
|
||||
if (nextChunk < chunks.size()) {
|
||||
report.put("stage", "load_full_chunks"); var pos = chunks.get(nextChunk++);
|
||||
helper.assertTrue(level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true) instanceof LevelChunk,
|
||||
"Heritage footprint must reach native FULL: " + pos);
|
||||
if (nextChunk % 20 == 0) SanctuaryMod.LOGGER.info("Heritage21 loaded {}/{} footprint chunks", nextChunk, chunks.size());
|
||||
helper.runAtTickTime(helper.getTick() + 1, this::advance); return;
|
||||
}
|
||||
report.put("stage", "verify_native_geometry"); verifyPlaced(); report.put("passed", true);
|
||||
if (!reload) {
|
||||
helper.assertTrue(gold != null && level.getBlockState(gold).isCollisionShapeFullBlock(level, gold), "Gold witness must replace a solid wall away from circulation");
|
||||
level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3); report.put("gold_witness", coords(gold));
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native starts, unopened loot and the wall edit");
|
||||
report.put("world_flushed_before_shutdown", true);
|
||||
} else {
|
||||
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Reload replaced the player's wall edit");
|
||||
report.put("gold_witness", coords(gold)); report.put("gold_witness_preserved", true);
|
||||
}
|
||||
write(level, report, reload); helper.succeed();
|
||||
} catch (Exception failure) {
|
||||
report.put("passed", false); report.put("failure", failure.toString());
|
||||
try { write(level, report, reload); } catch (IOException ignored) { }
|
||||
throw new IllegalStateException("Heritage21 native " + (reload ? "reload" : "creation") + " failed", failure);
|
||||
}
|
||||
}
|
||||
void prepare() throws IOException {
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
|
||||
var encoded = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow();
|
||||
var decoded = ChunkGenerator.CODEC.parse(ops, encoded).getOrThrow();
|
||||
helper.assertTrue(decoded instanceof SanctuaryChunkGenerator copy && copy.rootGeneration() == 21
|
||||
&& copy.experimentalStructures() == generator.experimentalStructures(), "Generation21 codec lost its experimental switch");
|
||||
report.put("generator_codec", encoded); report.put("plan_sha256", planHash(plan));
|
||||
report.put("planned_sites", plan.sites().stream().map(site -> Map.of("id", site.id(), "kind", site.kind().name(),
|
||||
"entry", coords(HeritagePiece21.locator(site)), "floor_y", site.floorY(), "foundation_depth", site.foundationDepth(), "footings", site.supports().size())).toList());
|
||||
report.put("planned_links", plan.links().stream().map(link -> Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(), "path", pathCoords(link))).toList());
|
||||
report.put("planning_samples", plan.samples()); report.put("planning_columns", plan.columns()); report.put("planning_diagnostics", plan.diagnostics());
|
||||
helper.assertTrue(plan.samples() <= HeritagePlan19.MAX_SAMPLES && plan.columns() <= HeritagePlan19.MAX_COLUMNS, "Heritage planning exceeded its budget");
|
||||
verifyLootTables(helper, report);
|
||||
if (reload) {
|
||||
previous = JsonParser.parseString(Files.readString(proof(level))).getAsJsonObject();
|
||||
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(), "Reload needs a successful flushed creation proof");
|
||||
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must use a second JVM");
|
||||
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed() && previous.get("size").getAsInt() == generator.initialDiameter()
|
||||
&& previous.get("world_directory").getAsString().equals(world(level).toString()), "Reload selected another world");
|
||||
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("plan_sha256").getAsString().equals(planHash(plan)), "Saved settings or planned identities changed on reload");
|
||||
if (previous.has("gold_witness")) gold = readPos(previous.getAsJsonArray("gold_witness"));
|
||||
}
|
||||
if (!generator.experimentalStructures() || !level.structureManager().shouldGenerateStructures()) {
|
||||
helper.assertTrue(plan.isEmpty() && plan.segments().isEmpty() && plan.links().isEmpty(), "Disabled generation published heritage geometry"); return;
|
||||
}
|
||||
helper.assertTrue(!plan.isEmpty(), "Enabled reference island has no heritage site");
|
||||
report.put("present_site_kinds", plan.sites().stream().map(s -> s.kind().name()).distinct().sorted().toList());
|
||||
report.put("missing_site_kinds", java.util.Arrays.stream(HeritagePlan19.Kind.values()).filter(k -> plan.sites().stream().noneMatch(s -> s.kind() == k)).map(Enum::name).toList());
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
|
||||
var origins = new HashSet<ChunkPos>(); var uniqueChunks = new HashSet<ChunkPos>();
|
||||
plan.sites().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
|
||||
plan.segments().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
|
||||
for (var origin : origins.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList()) {
|
||||
var chunk = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true);
|
||||
var start = chunk.getStartForStructure(structure.value());
|
||||
helper.assertTrue(start != null && start.isValid(), "Accepted site/route has no native start at " + origin); starts.add(start);
|
||||
for (var piece : start.getPieces()) {
|
||||
helper.assertTrue(piece instanceof HeritagePiece21, "Heritage loaded a foreign piece type");
|
||||
var box = piece.getBoundingBox();
|
||||
helper.assertTrue(Math.abs((long) (box.minX() >> 4) - origin.x()) <= 7 && Math.abs((long) (box.maxX() >> 4) - origin.x()) <= 7
|
||||
&& Math.abs((long) (box.minZ() >> 4) - origin.z()) <= 7 && Math.abs((long) (box.maxZ() >> 4) - origin.z()) <= 7, "Native start references a distant route endpoint");
|
||||
box.intersectingChunks().forEach(uniqueChunks::add);
|
||||
}
|
||||
}
|
||||
for (var site : plan.sites()) uniqueChunks.add(ChunkPos.containing(HeritagePiece21.locator(site)));
|
||||
for (var link : plan.links()) for (var point : link.path()) uniqueChunks.add(new ChunkPos(point.x() >> 4, point.z() >> 4));
|
||||
helper.assertTrue(uniqueChunks.size() <= 1024, "Bounded proof footprint exceeds1024 chunks");
|
||||
var ruins = generator.ruinsPlan17();
|
||||
report.put("ruins_plan_sha256", hash(ruins.networks().toString()));
|
||||
if (reload) helper.assertTrue(previous.get("ruins_plan_sha256").getAsString().equals(hash(ruins.networks().toString())), "Reload changed forgotten room planning");
|
||||
helper.assertTrue(!ruins.isEmpty(), "Reference20 must demonstrate native experimental room architecture");
|
||||
var ruinsType = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
|
||||
var ruinsOrigins = new HashSet<ChunkPos>();
|
||||
for (var network : ruins.networks()) {
|
||||
var origin = new ChunkPos(network.startChunkX(), network.startChunkZ()); if (!ruinsOrigins.add(origin)) continue;
|
||||
var nativeStart = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(ruinsType.value());
|
||||
helper.assertTrue(nativeStart != null && nativeStart.isValid(), "Missing native20 forgotten network: " + network.id());
|
||||
ruinsStarts.add(nativeStart);
|
||||
for (var piece : nativeStart.getPieces()) {
|
||||
helper.assertTrue(piece instanceof RuinsPiece20, "Generation21 loaded a historical forgotten piece");
|
||||
piece.getBoundingBox().intersectingChunks().forEach(uniqueChunks::add);
|
||||
}
|
||||
}
|
||||
chunks.addAll(uniqueChunks.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList());
|
||||
}
|
||||
void finishDisabled() throws IOException {
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
|
||||
var start = level.getChunk(0, 0, ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(structure.value());
|
||||
helper.assertTrue(start == null || !start.isValid(), "Disabled heritage emitted a native start");
|
||||
helper.assertTrue(generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false) == null, "Disabled heritage is locatable");
|
||||
report.put("empty_plan_verified", true); report.put("passed", true);
|
||||
if (!reload) { helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save disabled experiment setting"); report.put("world_flushed_before_shutdown", true); }
|
||||
write(level, report, reload); helper.succeed();
|
||||
}
|
||||
void verifyPlaced() {
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
|
||||
var context = StructurePieceSerializationContext.fromLevel(level); var summaries = new ArrayList<Map<String, Object>>();
|
||||
int blocks = 0, chests = 0, pieces = 0, supports = 0;
|
||||
var connections = NativeConnections20.Stats.empty();
|
||||
for (var start : starts) {
|
||||
var origin = start.getChunkPos(); var saved = start.createTag(context, origin); String nativeHash = hash(saved.toString());
|
||||
var copy = StructureStart.loadStaticStart(context, saved, level.getSeed());
|
||||
helper.assertTrue(copy != null && copy.isValid() && copy.createTag(context, origin).equals(saved), "Native start failed an exact NBT roundtrip");
|
||||
var expectedPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
|
||||
plan.sites().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece21.encodeSite(s)));
|
||||
plan.segments().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece21.encodeSegment(s)));
|
||||
var actualPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
|
||||
if (reload) {
|
||||
JsonObject prior = null;
|
||||
for (var value : previous.getAsJsonArray("starts")) { var item = value.getAsJsonObject(); var at = item.getAsJsonArray("start_chunk"); if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = item; }
|
||||
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(nativeHash), "Reload changed saved geometry or native reference count");
|
||||
}
|
||||
var loot = new ArrayList<Map<String, Object>>();
|
||||
for (var value : start.getPieces()) {
|
||||
var piece = (HeritagePiece21) value; pieces++;
|
||||
var tag = piece.createTag(context); var restored = new HeritagePiece21(context, tag);
|
||||
helper.assertTrue(restored.createTag(context).equals(tag), "Saved piece parameters changed on native load");
|
||||
actualPayloads.add(tag.getCompound("SanctuaryHeritage").orElseThrow());
|
||||
var corrupt = tag.copy(); corrupt.getCompound("SanctuaryHeritage").orElseThrow().putInt("Schema", 22);
|
||||
boolean rejected = false; try { new HeritagePiece21(context, corrupt); } catch (IllegalArgumentException expected) { rejected = true; }
|
||||
helper.assertTrue(rejected, "Unknown schema must fail closed");
|
||||
piece.getBoundingBox().intersectingChunks().forEach(at -> helper.assertTrue(level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true)
|
||||
.getReferencesForStructure(structure.value()).contains(origin.pack()), "FULL chunk lost its native structure reference"));
|
||||
var expected = rendered(piece); blocks += expected.blocks().size();
|
||||
for (var entry : expected.blocks().entrySet()) {
|
||||
var at = entry.getKey(); var wanted = entry.getValue(); helper.assertTrue(piece.getBoundingBox().isInside(at), "Renderer escaped saved bounds");
|
||||
if (reload && at.equals(gold)) continue;
|
||||
helper.assertTrue(level.getBlockState(at).is(wanted.getBlock()), "Clipping or decoration changed heritage at " + at + ": expected " + wanted + " got " + level.getBlockState(at));
|
||||
helper.assertTrue(!wanted.is(Blocks.SPAWNER), "Heritage must not create spawners");
|
||||
}
|
||||
connections = connections.add(NativeConnections20.verify(helper, expected.blocks(), reload ? Set.of(gold) : Set.of()));
|
||||
if (piece.isSite()) {
|
||||
supports += verifySiteBearing(helper, generator, plan, piece.site());
|
||||
if (!reload && gold == null) {
|
||||
gold = expected.blocks().entrySet().stream().filter(e -> isOuterWall(piece.site(), e.getKey()) && e.getValue().isCollisionShapeFullBlock(level, e.getKey()))
|
||||
.map(Map.Entry::getKey).sorted(Comparator.<BlockPos>comparingInt(p -> p.getY()).thenComparingInt(p -> p.getX()).thenComparingInt(p -> p.getZ())).findFirst().orElse(null);
|
||||
}
|
||||
} else for (var cell : piece.segment().cells()) { supports++; assertRock(helper, new BlockPos(cell.x(), cell.minY() - 1, cell.z())); }
|
||||
for (var chest : expected.chests()) {
|
||||
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Deferred chest is missing");
|
||||
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
|
||||
helper.assertTrue(entity.getLootTable() != null && entity.getLootTable().equals(chest.table()) && entity.getLootTableSeed() == chest.seed(), "Deferred loot table/seed was lost or consumed");
|
||||
if (piece.isSite()) helper.assertTrue(chest.table().equals(lootKey(piece.site().kind())), "Site uses another kind's tool table");
|
||||
loot.add(Map.of("position", coords(chest.position()), "table", chest.table().identifier().toString(), "seed", chest.seed())); chests++;
|
||||
}
|
||||
}
|
||||
helper.assertTrue(actualPayloads.equals(expectedPayloads) && start.getPieces().size() == expectedPayloads.size(), "Native start lost or duplicated planned pieces");
|
||||
summaries.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", nativeHash, "pieces", start.getPieces().size(), "unopened_chests", loot));
|
||||
}
|
||||
report.put("connected_blocks", connections);
|
||||
helper.assertTrue(connections.panes() > 0 && connections.stairs() > 0 && connections.nativeOracles() > 0,
|
||||
"Reference20 needs real panes/stairs validated by vanilla neighbor rules");
|
||||
if (level.getSeed() == 0 && generator.initialDiameter() == 724)
|
||||
helper.assertTrue(connections.crossChunkPaneLinks() > 0, "Reference20 must demonstrate a real pane join across a chunk boundary");
|
||||
verifyRuins();
|
||||
report.put("starts", summaries); report.put("native_pieces", pieces); report.put("native_blocks_checked", blocks);
|
||||
report.put("full_chunks_checked", chunks.size()); report.put("rock_supports_checked", supports); report.put("unopened_deferred_chests", chests);
|
||||
report.put("stage", "verify_site_routes"); var sites = new ArrayList<Map<String, Object>>(); for (var site : plan.sites()) sites.add(verifyAccess(helper, site)); report.put("native_sites", sites);
|
||||
report.put("stage", "verify_read_only_landings");
|
||||
report.put("read_only_landmarks", verifyReadOnlyLandings(helper, generator, plan));
|
||||
report.put("stage", "verify_island_routes"); report.put("native_links", verifyLinks(helper, plan));
|
||||
int greatestSpan = plan.links().stream().mapToInt(Heritage21WorldGameTests::routeSpan).max().orElse(0);
|
||||
report.put("greatest_single_route_span", greatestSpan);
|
||||
var reachedIds = new HashSet<String>(); plan.links().forEach(l -> { reachedIds.add(l.fromId()); reachedIds.add(l.toId()); });
|
||||
report.put("connected_destination_ids", reachedIds.stream().sorted().toList());
|
||||
report.put("isolated_destination_ids", plan.sites().stream().map(HeritagePlan19.Site::id).filter(id -> !reachedIds.contains(id)).toList());
|
||||
report.put("connected_city_arrivals", plan.landmarks().stream().filter(l -> l.kind() == HeritagePlan19.LandmarkKind.CITY && reachedIds.contains(l.id())).map(HeritagePlan19.Landmark::id).toList());
|
||||
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
|
||||
helper.assertTrue(located != null && located.getSecond().is(HeritageStructures19.KEY) && plan.sites().stream().anyMatch(s -> HeritagePiece21.locator(s).equals(located.getFirst())), "Locate must lead to a real exterior site entry");
|
||||
report.put("locate", coords(located.getFirst()));
|
||||
}
|
||||
void verifyRuins() {
|
||||
var context = StructurePieceSerializationContext.fromLevel(level);
|
||||
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
|
||||
var summaries = new ArrayList<Map<String, Object>>(); int count = 0, blockCount = 0, lootCount = 0;
|
||||
var connections = NativeConnections20.Stats.empty();
|
||||
for (var start : ruinsStarts) {
|
||||
var origin = start.getChunkPos(); var saved = start.createTag(context, origin); var digest = hash(saved.toString());
|
||||
var loaded = StructureStart.loadStaticStart(context, saved, level.getSeed());
|
||||
helper.assertTrue(loaded != null && loaded.createTag(context, origin).equals(saved), "Forgotten20 native start NBT changed");
|
||||
var expectedRooms = generator.ruinsPlan17().networks().stream().filter(n -> n.startChunkX() == origin.x() && n.startChunkZ() == origin.z()).flatMap(n -> n.rooms().stream()).toList();
|
||||
var actualRooms = start.getPieces().stream().map(RuinsPiece20.class::cast).map(RuinsPiece20::room).toList();
|
||||
helper.assertTrue(expectedRooms.size() == actualRooms.size() && new HashSet<>(expectedRooms).equals(new HashSet<>(actualRooms)), "Forgotten20 native rooms differ from plan");
|
||||
if (reload) {
|
||||
JsonObject prior = null;
|
||||
for (var value : previous.getAsJsonArray("ruins_starts")) { var item = value.getAsJsonObject(); var at = item.getAsJsonArray("start_chunk"); if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = item; }
|
||||
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(digest), "Forgotten20 native start changed on a second JVM load");
|
||||
}
|
||||
for (var raw : start.getPieces()) {
|
||||
var piece = (RuinsPiece20) raw; var room = piece.room(); count++;
|
||||
var tag = piece.createTag(context); var roundtrip = new RuinsPiece20(context, tag);
|
||||
helper.assertTrue(roundtrip.room().equals(room) && roundtrip.createTag(context).equals(tag), "Forgotten20 piece lost saved parameters");
|
||||
piece.getBoundingBox().intersectingChunks().forEach(at -> helper.assertTrue(level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true).getReferencesForStructure(registry.value()).contains(origin.pack()), "Forgotten20 chunk lost native references"));
|
||||
var expected = rendered(room); blockCount += expected.blocks().size();
|
||||
for (var entry : expected.blocks().entrySet()) {
|
||||
helper.assertTrue(piece.getBoundingBox().isInside(entry.getKey()), "Forgotten20 renderer escaped saved bounds");
|
||||
helper.assertTrue(level.getBlockState(entry.getKey()).is(entry.getValue().getBlock()), "Forgotten20 native geometry changed at " + entry.getKey());
|
||||
}
|
||||
connections = connections.add(NativeConnections20.verify(helper, expected.blocks(), Set.of()));
|
||||
for (var support : room.supports()) assertRock(helper, new BlockPos(support.x(), support.minY() - 1, support.z()));
|
||||
for (var chest : expected.chests()) {
|
||||
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Forgotten20 missing deferred chest");
|
||||
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
|
||||
helper.assertTrue(chest.table().equals(entity.getLootTable()) && chest.seed() == entity.getLootTableSeed(), "Forgotten20 consumed/changed saved loot"); lootCount++;
|
||||
}
|
||||
}
|
||||
summaries.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", digest, "pieces", start.getPieces().size()));
|
||||
}
|
||||
report.put("ruins_starts", summaries); report.put("ruins_native_pieces", count); report.put("ruins_native_blocks_checked", blockCount);
|
||||
report.put("ruins_unopened_deferred_chests", lootCount); report.put("ruins_connections", connections);
|
||||
report.put("ruins_access", generator.ruinsPlan17().networks().stream().map(n -> verifyAccess(helper, n)).toList());
|
||||
}
|
||||
}
|
||||
private record Chest(BlockPos position, ResourceKey<LootTable> table, long seed) {}
|
||||
private record Rendered(Map<BlockPos, BlockState> blocks, List<Chest> chests) {}
|
||||
private static Rendered rendered(HeritagePiece21 piece) {
|
||||
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
|
||||
int x0 = piece.isSite() ? piece.site().minX() : piece.segment().minX();
|
||||
int y0 = piece.isSite() ? piece.site().floorY() : piece.segment().minY();
|
||||
int z0 = piece.isSite() ? piece.site().minZ() : piece.segment().minZ();
|
||||
var writer = new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(new BlockPos(x0 + x, y0 + y, z0 + z), state); }
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
|
||||
var at = new BlockPos(x0 + x, y0 + y, z0 + z); blocks.put(at, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(at, table, RandomSource.create(seed).nextLong()));
|
||||
}
|
||||
};
|
||||
if (piece.isSite()) HeritageRenderer21.render(piece.site(), writer); else HeritageRenderer21.renderSegment(piece.segment(), writer);
|
||||
return new Rendered(blocks, chests);
|
||||
}
|
||||
private static Rendered rendered(RuinsPlan17.Room room) {
|
||||
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
|
||||
RuinsRenderer20.render(room, new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(at(room, x, y, z), state); }
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
|
||||
var position = at(room, x, y, z); blocks.put(position, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(position, table, RandomSource.create(seed).nextLong()));
|
||||
}
|
||||
});
|
||||
return new Rendered(blocks, chests);
|
||||
}
|
||||
private static Map<String, Object> verifyAccess(GameTestHelper helper, RuinsPlan17.Network network) {
|
||||
var level = helper.getLevel(); var access = network.rooms().stream().filter(r -> r.kind() == RuinsPlan17.Kind.ACCESS).findFirst().orElseThrow();
|
||||
var hatches = new HashMap<BlockPos, BlockState>();
|
||||
rendered(access).blocks().forEach((at, state) -> { if (state.is(Blocks.DARK_OAK_TRAPDOOR)) hatches.put(at, level.getBlockState(at)); });
|
||||
helper.assertTrue(hatches.size() == 1, "Each network must have one usable wooden surface hatch");
|
||||
hatches.forEach((at, state) -> {
|
||||
helper.assertTrue(state.is(Blocks.DARK_OAK_TRAPDOOR) && state.canSurvive(level, at), "Surface hatch is missing its native support");
|
||||
level.setBlock(at, state.setValue(BlockStateProperties.OPEN, true), 2);
|
||||
});
|
||||
try {
|
||||
Predicate<BlockPos> inside = pos -> network.rooms().stream().anyMatch(r -> r.contains(pos.getX(), pos.getY(), pos.getZ()));
|
||||
var start = RuinsPiece20.locator(access); var reached = walk(helper, start, inside);
|
||||
int targets = 0;
|
||||
for (var room : network.rooms()) for (var target : RuinsRenderer20.targets(room)) {
|
||||
var point = at(room, target.x(), target.y(), target.z());
|
||||
helper.assertTrue(reached.contains(point), "Native network " + network.id() + " cannot reach " + room.kind() + "/" + target.name() + " at " + point);
|
||||
targets++;
|
||||
}
|
||||
for (var hatch : hatches.keySet()) helper.assertTrue(reached.contains(hatch), "Surface approach cannot enter its ladder shaft");
|
||||
return Map.of("id", network.id(), "family", network.family().name(), "rooms", network.rooms().size(),
|
||||
"entrance", coords(start), "walkable_targets", targets, "standing_cells", reached.size());
|
||||
} finally { hatches.forEach((at, state) -> level.setBlock(at, state, 2)); }
|
||||
}
|
||||
private static BlockPos at(RuinsPlan17.Room room, int x, int y, int z) { return new BlockPos(room.minX() + x, room.minY() + y, room.minZ() + z); }
|
||||
private static boolean isOuterWall(HeritagePlan19.Site site, BlockPos pos) {
|
||||
return pos.getY() >= site.floorY() + 2 && pos.getY() <= site.floorY() + 3
|
||||
&& (pos.getX() == site.minX() + 2 || pos.getX() == site.maxX() - 2 || pos.getZ() == site.minZ() + 2 || pos.getZ() == site.maxZ() - 2)
|
||||
&& Math.abs(pos.getX() - site.entry().x()) + Math.abs(pos.getZ() - site.entry().z()) > 6;
|
||||
}
|
||||
private static Map<String, Object> verifyAccess(GameTestHelper helper, HeritagePlan19.Site site) {
|
||||
var level = helper.getLevel(); var expected = rendered(new HeritagePiece21(site)); var doors = new HashMap<BlockPos, BlockState>();
|
||||
expected.blocks().forEach((at, state) -> {
|
||||
if ((state.getBlock() instanceof DoorBlock || state.getBlock() instanceof TrapDoorBlock) && state.hasProperty(BlockStateProperties.OPEN)) doors.put(at, level.getBlockState(at));
|
||||
});
|
||||
doors.forEach((at, state) -> level.setBlock(at, state.setValue(BlockStateProperties.OPEN, true), 2));
|
||||
try {
|
||||
var entry = HeritagePiece21.locator(site);
|
||||
Predicate<BlockPos> inside = p -> p.equals(entry) || p.getX() >= site.minX() && p.getX() <= site.maxX()
|
||||
&& p.getZ() >= site.minZ() && p.getZ() <= site.maxZ() && p.getY() >= site.floorY() - 4 && p.getY() < site.maxY();
|
||||
var reached = walk(helper, entry, inside); int targets = 0;
|
||||
for (var target : HeritageRenderer21.targets(site)) {
|
||||
var at = new BlockPos(site.minX() + target.x(), site.floorY() + target.y(), site.minZ() + target.z());
|
||||
helper.assertTrue(reached.contains(at), "Site " + site.id() + " cannot reach " + target.name() + " at " + at); targets++;
|
||||
}
|
||||
return Map.of("id", site.id(), "kind", site.kind().name(), "entry", coords(entry), "standing_cells", reached.size(), "walkable_targets", targets);
|
||||
} finally { doors.forEach((at, state) -> level.setBlock(at, state, 2)); }
|
||||
}
|
||||
private static List<Map<String, Object>> verifyLinks(GameTestHelper helper, HeritagePlan19 plan) {
|
||||
helper.assertTrue(!plan.links().isEmpty(), "Reference island needs a real route between accepted destinations");
|
||||
var entries = new HashMap<String, HeritagePlan19.Entry>(); plan.sites().forEach(s -> entries.put(s.id(), s.entry())); plan.landmarks().forEach(s -> entries.put(s.id(), s.entry()));
|
||||
var cells = new HashMap<Long, HeritagePlan19.Cell>(); plan.segments().forEach(s -> s.cells().forEach(c -> cells.put(column(c.x(), c.z()), c)));
|
||||
var result = new ArrayList<Map<String, Object>>();
|
||||
for (var link : plan.links()) {
|
||||
var from = entries.get(link.fromId()); var to = entries.get(link.toId());
|
||||
helper.assertTrue(from != null && to != null, "Route points to an absent destination");
|
||||
var first = link.path().getFirst(); var last = link.path().getLast();
|
||||
helper.assertTrue(first.x() == from.x() && first.z() == from.z() && first.floorY() + 1 == from.y()
|
||||
&& last.x() == to.x() && last.z() == to.z() && last.floorY() + 1 == to.y(), "Route does not meet its real entrances");
|
||||
int present = 0, missing = 0;
|
||||
for (int i = 0; i < link.path().size(); i++) {
|
||||
var point = link.path().get(i); var at = new BlockPos(point.x(), point.floorY() + 1, point.z());
|
||||
helper.assertTrue(standable(helper.getLevel(), at), "Ballast route " + link.id() + " is blocked at " + at);
|
||||
if (i > 0) { var prior = link.path().get(i - 1); if (point.floorY() > prior.floorY()) helper.assertTrue(clearBody(helper.getLevel(), new BlockPos(prior.x(), prior.floorY() + 2, prior.z())), "Route slope cannot be climbed by a player"); }
|
||||
var cell = cells.get(column(point.x(), point.z()));
|
||||
if (cell != null && cell.rail() != HeritagePlan19.Rail.NONE) {
|
||||
var actual = helper.getLevel().getBlockState(at);
|
||||
if (cell.railPresent()) {
|
||||
helper.assertTrue(actual.is(Blocks.RAIL), "Saved existing rail disappeared");
|
||||
helper.assertTrue(actual.getValue(BlockStateProperties.RAIL_SHAPE).name().equals(cell.rail().name()), "Native rail update changed the intended slope or curve at " + at); present++;
|
||||
} else { helper.assertTrue(!actual.is(Blocks.RAIL), "An intentionally missing rail regenerated"); missing++; }
|
||||
}
|
||||
}
|
||||
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
|
||||
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
|
||||
result.add(Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(),
|
||||
"span_x", spanX, "span_z", spanZ, "existing_rails", present, "deliberately_missing_rails", missing, "path", pathCoords(link)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private static int routeSpan(HeritagePlan19.Link link) {
|
||||
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow()
|
||||
- link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
|
||||
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow()
|
||||
- link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
|
||||
return Math.max(spanX, spanZ);
|
||||
}
|
||||
private static List<List<Integer>> pathCoords(HeritagePlan19.Link link) {
|
||||
return link.path().stream().map(point -> List.of(point.x(), point.floorY(), point.z())).toList();
|
||||
}
|
||||
private static int verifySiteBearing(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan, HeritagePlan19.Site site) {
|
||||
int checked = 0;
|
||||
// The planner certifies a nine-point bearing grid, either directly below its slab or below a saved short footing.
|
||||
for (int dx : new int[] {1, 8, 15}) for (int dz : new int[] {1, 8, 15}) {
|
||||
int x = site.minX() + dx, z = site.minZ() + dz;
|
||||
var support = site.supports().stream().filter(s -> s.x() == x && s.z() == z).findFirst();
|
||||
int bottom = support.map(HeritagePlan19.Support::minY).orElse(site.minY());
|
||||
for (int below = 1; below <= 2; below++) {
|
||||
var at = new BlockPos(x, bottom - below, z); assertRock(helper, at);
|
||||
helper.assertTrue(plan.protects(at.getX(), at.getY(), at.getZ()) && generator.protectsCityDecoration(at),
|
||||
"Site's certified bearing layer is not published in the decoration mask: " + site.id() + " at " + at);
|
||||
helper.assertTrue(!plan.contains(at.getX(), at.getY(), at.getZ()), "Original bearing rock became a writable heritage volume: " + at);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
return checked;
|
||||
}
|
||||
private static List<Map<String, Object>> verifyReadOnlyLandings(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan) {
|
||||
var used = new HashSet<String>(); plan.links().forEach(link -> { used.add(link.fromId()); used.add(link.toId()); });
|
||||
var result = new ArrayList<Map<String, Object>>();
|
||||
for (var landmark : plan.landmarks()) if (used.contains(landmark.id())) {
|
||||
var e = landmark.entry(); int checked = 0;
|
||||
for (int side = -1; side <= 1; side++) {
|
||||
int x = e.x() + ((e.facing() & 1) == 0 ? side : 0), z = e.z() + ((e.facing() & 1) == 1 ? side : 0);
|
||||
for (int y = e.y() - 2; y <= e.y() + 2; y++) {
|
||||
var at = new BlockPos(x, y, z);
|
||||
helper.assertTrue(plan.protects(x, y, z) && generator.protectsCityDecoration(at), "Read-only arrival lost its decoration mask: " + landmark.id() + " at " + at);
|
||||
helper.assertTrue(!plan.contains(x, y, z), "Heritage gained write ownership of a historical arrival: " + landmark.id() + " at " + at);
|
||||
if (y == e.y() - 1) assertRock(helper, at);
|
||||
if (y >= e.y()) helper.assertTrue(helper.getLevel().getBlockState(at).isAir(), "Decoration obstructed a certified historical arrival: " + at);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
result.add(Map.of("id", landmark.id(), "entry", List.of(e.x(), e.y(), e.z()), "read_only_cells", checked));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private static void assertRock(GameTestHelper helper, BlockPos at) {
|
||||
helper.assertTrue(helper.getLevel().getBlockState(at).isSolid() && helper.getLevel().getFluidState(at).isEmpty(), "Lost original rock bearing at " + at);
|
||||
}
|
||||
private static void verifyLootTables(GameTestHelper helper, Map<String, Object> report) {
|
||||
var result = new ArrayList<Map<String, Object>>(); var level = helper.getLevel();
|
||||
var params = new LootParams.Builder(level).withParameter(LootContextParams.ORIGIN, new Vec3(0, 160, 0)).create(LootContextParamSets.CHEST);
|
||||
for (var kind : HeritagePlan19.Kind.values()) {
|
||||
var table = level.getServer().reloadableRegistries().getLootTable(lootKey(kind));
|
||||
helper.assertTrue(table != LootTable.EMPTY, "Heritage tool table did not load: " + kind);
|
||||
for (long seed : new long[] {0, 42, -1}) helper.assertTrue(table.getRandomItems(params, seed).stream().anyMatch(stack -> stack.is(tool(kind))), "Tool table is empty or lacks its expected primary tool: " + kind);
|
||||
result.add(Map.of("kind", kind.name(), "table", lootKey(kind).identifier().toString(), "sampled_seeds", List.of(0, 42, -1)));
|
||||
}
|
||||
report.put("tool_tables_sampled_separately", result);
|
||||
}
|
||||
private static ResourceKey<LootTable> lootKey(HeritagePlan19.Kind kind) { return ResourceKey.create(Registries.LOOT_TABLE, SanctuaryMod.id("chests/heritage19/" + kind.name().toLowerCase(Locale.ROOT))); }
|
||||
private static Item tool(HeritagePlan19.Kind kind) {
|
||||
return switch (kind) {
|
||||
case OBSERVATORY -> Items.SPYGLASS;
|
||||
case POST_OFFICE, EXPEDITION_OFFICE, RADIO_RELAY -> Items.COMPASS;
|
||||
case SUBSTATION, IMPOSSIBLE_SERVICE -> Items.CLOCK;
|
||||
case FREIGHT_STATION -> Items.MINECART;
|
||||
case COMMUNAL_DEPOT -> Items.IRON_AXE;
|
||||
case RAIL_WORKSHOP -> Items.IRON_PICKAXE;
|
||||
case PUMP_STATION -> Items.BUCKET;
|
||||
case GREENHOUSE -> Items.IRON_HOE;
|
||||
case VOID_DOCK -> Items.LEAD;
|
||||
case PHOTO_CLUB -> Items.BOOK;
|
||||
case CONSTRUCTION_REFUGE -> Items.IRON_SHOVEL;
|
||||
};
|
||||
}
|
||||
private static Set<BlockPos> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside) {
|
||||
var level = helper.getLevel(); helper.assertTrue(inside.test(start) && standable(level, start), "Blocked or unsupported exterior entrance: " + start);
|
||||
var seen = new HashSet<BlockPos>(); var queue = new ArrayDeque<BlockPos>(); seen.add(start); queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
var pos = queue.removeFirst();
|
||||
for (var direction : HORIZONTAL) for (int dy = -1; dy <= 1; dy++) {
|
||||
var next = pos.relative(direction).offset(0, dy, 0);
|
||||
if (!inside.test(next) || dy > 0 && !clearBody(level, pos.above())) continue;
|
||||
if (standable(level, next) && seen.add(next)) queue.add(next);
|
||||
}
|
||||
for (int dy : new int[] {-1, 1}) {
|
||||
var next = pos.offset(0, dy, 0); if (!inside.test(next)) continue;
|
||||
if ((level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(next).is(Blocks.LADDER) || level.getBlockState(pos.below()).is(Blocks.LADDER))
|
||||
&& standable(level, next) && seen.add(next)) queue.add(next);
|
||||
}
|
||||
helper.assertTrue(seen.size() <= 50_000, "Native site traversal escaped its bounds");
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
private static boolean standable(ServerLevel level, BlockPos pos) {
|
||||
if (!clearBody(level, pos)) return false;
|
||||
if (level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(pos.below()).is(Blocks.LADDER)) return true;
|
||||
var below = pos.below(); return level.getBlockState(below).getCollisionShape(level, below).toAabbs().stream()
|
||||
.anyMatch(b -> b.maxY >= .5 && b.minX <= .5 && b.maxX >= .5 && b.minZ <= .5 && b.maxZ >= .5);
|
||||
}
|
||||
private static boolean clearBody(ServerLevel level, BlockPos pos) {
|
||||
var body = new AABB(pos.getX() + .2, pos.getY(), pos.getZ() + .2, pos.getX() + .8, pos.getY() + 1.8, pos.getZ() + .8);
|
||||
for (var at : new BlockPos[] {pos, pos.above()}) {
|
||||
if (!level.getFluidState(at).isEmpty()) return false;
|
||||
for (var shape : level.getBlockState(at).getCollisionShape(level, at).toAabbs()) if (shape.move(at.getX(), at.getY(), at.getZ()).intersects(body)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private static long column(int x, int z) { return (long) x << 32 ^ z & 0xffffffffL; }
|
||||
private static List<Integer> coords(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
|
||||
private static BlockPos readPos(com.google.gson.JsonArray value) { return new BlockPos(value.get(0).getAsInt(), value.get(1).getAsInt(), value.get(2).getAsInt()); }
|
||||
private static Path world(ServerLevel level) { return level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); }
|
||||
private static Path proof(ServerLevel level) { return world(level).resolve("heritage21-create-proof.json"); }
|
||||
private static String planHash(HeritagePlan19 plan) { return hash(plan.sites().toString() + plan.landmarks() + plan.links() + plan.segments()); }
|
||||
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
|
||||
var path = Path.of("diagnostics", "heritage21-" + (reload ? "reload" : "create") + ".json"); Files.createDirectories(path.getParent());
|
||||
var value = new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n"; Files.writeString(path, value); if (!reload) Files.writeString(proof(level), value);
|
||||
}
|
||||
private static String hash(String value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
|
||||
}
|
||||
}
|
||||
+911
@@ -0,0 +1,911 @@
|
||||
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.LostCityInteriors15;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPalette;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPiece15;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlan;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityStructures;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCitySurface15;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCitySupportPiece15;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityUnderground15;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityUndergroundPiece15;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Predicate;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.registries.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.DoorBlock;
|
||||
import net.minecraft.world.level.block.StairBlock;
|
||||
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.BedPart;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.block.state.properties.DoubleBlockHalf;
|
||||
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.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
|
||||
import net.minecraft.world.level.material.Fluids;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
|
||||
/** Opt-in generation-15 proof: bounded natural prospects, native placement, fluid ticks and a second JVM.
|
||||
* It never imports an artificial city into the natural-coverage totals or opens a personal world. */
|
||||
public final class LostCity15WorldGameTests {
|
||||
private static final Set<LostCityUnderground15.Kind> REQUIRED_UNDERGROUND = Set.of(
|
||||
LostCityUnderground15.Kind.SEWER, LostCityUnderground15.Kind.POOLROOM, LostCityUnderground15.Kind.GARAGE);
|
||||
private static final Set<LostCityPlan.Kind> SURFACE_INTEREST = Set.of(
|
||||
LostCityPlan.Kind.BRIDGE, LostCityPlan.Kind.POOL, LostCityPlan.Kind.PARKING);
|
||||
private static final int MAX_DISTRICTS_PER_ISLAND = 6;
|
||||
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
|
||||
|
||||
@GameTest(maxTicks = 12000)
|
||||
public void cityFragmentsAndUtilitiesPersistNatively(GameTestHelper helper) {
|
||||
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
|
||||
"Lost City15 requires the unified generator");
|
||||
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
helper.assertTrue(generator.rootGeneration() == 15, "Lost City15 must use a fresh generation-15 world");
|
||||
if (Boolean.getBoolean("sanctuary.test.expansionReload")) reload(helper, generator);
|
||||
else 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<>();
|
||||
final List<Map<String, Object>> prospects = new ArrayList<>();
|
||||
final List<Map<String, Object>> naturalPlans = new ArrayList<>();
|
||||
final List<LostCityUnderground15.Module> wetModules = new ArrayList<>();
|
||||
final List<LostCityPlan.Parcel> wetSurfacePools = new ArrayList<>();
|
||||
final Set<LostCityUnderground15.Kind> observedKinds = new HashSet<>();
|
||||
final Set<LostCityPlan.Kind> observedSurfaceKinds = new HashSet<>();
|
||||
final Set<ChunkPos> forced = new HashSet<>();
|
||||
final List<BlockPos> tickWitnesses = new ArrayList<>();
|
||||
int phase, round;
|
||||
String childId;
|
||||
long readinessWaitTick, simulationStartWorldTick = -1;
|
||||
|
||||
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
this.helper = helper; this.level = helper.getLevel(); this.generator = generator;
|
||||
session = ExpansionRuntime.session(level); report = report(level);
|
||||
report.put("sites", sites); report.put("city_prospects", prospects); report.put("natural_plans", naturalPlans);
|
||||
}
|
||||
|
||||
void advance() {
|
||||
try {
|
||||
if (phase == 0) {
|
||||
verifyLegacy(helper, generator);
|
||||
report.put("saved_interior_models", verifyInteriorModels(helper));
|
||||
observeIsland(session.island("sanctuary"));
|
||||
// Always exercise one actual expansion; a second is reserved for missing natural utility types.
|
||||
childId = prospect();
|
||||
helper.assertTrue(childId != null, "The bounded natural prospects found no city with a building; see city_prospects");
|
||||
session.create(childId, "sanctuary", "east", "lost_city", 256, "natural", 2048);
|
||||
phase = 1;
|
||||
}
|
||||
if (phase == 1 && session.island(childId).status().equals("ready")) {
|
||||
observeIsland(session.island(childId));
|
||||
if (!observedKinds.containsAll(REQUIRED_UNDERGROUND) && round < 2) {
|
||||
childId = prospect();
|
||||
helper.assertTrue(childId != null, "Second bounded search cannot supply a natural city reference");
|
||||
session.create(childId, "sanctuary", "east", "lost_city", 256, "natural", 2048);
|
||||
} else {
|
||||
helper.assertTrue(observedKinds.containsAll(REQUIRED_UNDERGROUND),
|
||||
"Natural reference coverage must include sewer, poolroom and garage; found " + observedKinds);
|
||||
helper.assertTrue(sites.size() >= 2, "The reference must inspect several distinct native district starts");
|
||||
for (var module : wetModules) {
|
||||
forceBounds(LostCityUndergroundPiece15.bounds(module));
|
||||
}
|
||||
for (var pool : wetSurfacePools) {
|
||||
forceBounds(new LostCityPiece15(pool).getBoundingBox());
|
||||
}
|
||||
readinessWaitTick = helper.getTick(); phase = 2;
|
||||
}
|
||||
}
|
||||
if (phase == 2 && simulationStartWorldTick < 0) awaitFluidReadiness();
|
||||
if (phase == 2 && simulationStartWorldTick >= 0 && level.getGameTime() - simulationStartWorldTick >= 120) {
|
||||
verifyFluidWitnesses();
|
||||
report.put("natural_fluid_checks", wetModules.stream().map(m -> verifyWater(helper, m, true)).toList());
|
||||
report.put("natural_surface_pool_checks", wetSurfacePools.stream().map(p -> verifySurfacePool(helper, p, true)).toList());
|
||||
removeFluidWitnesses();
|
||||
var witnessSite = sites.stream().filter(site -> site.containsKey("edit_position")).findFirst().orElseThrow();
|
||||
@SuppressWarnings("unchecked") var at = (List<Integer>) witnessSite.get("edit_position");
|
||||
var gold = new BlockPos(at.get(0), at.get(1), at.get(2));
|
||||
level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3);
|
||||
report.put("gold_witness", coords(gold));
|
||||
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))));
|
||||
releaseForced();
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush all native starts, utility bodies, supports, loot and gameplay edit");
|
||||
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 City15 integration phase {}: {}", phase, session.progress());
|
||||
helper.runAtTickTime(helper.getTick() + 10, this::advance);
|
||||
} catch (Exception error) {
|
||||
removeFluidWitnesses();
|
||||
releaseForced(); report.put("passed", false); report.put("failure", error.toString());
|
||||
try { write(level, report, false); } catch (IOException ignored) { }
|
||||
throw new IllegalStateException("Lost City15 native creation failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
void releaseForced() {
|
||||
for (var pos : forced) level.setChunkForced(pos.x(), pos.z(), false);
|
||||
forced.clear();
|
||||
}
|
||||
void forceBounds(BoundingBox bounds) {
|
||||
bounds.intersectingChunks().forEach(pos -> { level.setChunkForced(pos.x(), pos.z(), true); forced.add(pos); });
|
||||
}
|
||||
|
||||
void awaitFluidReadiness() throws IOException {
|
||||
var pending = forced.stream().filter(pos -> !level.isPositionTickingWithEntitiesLoaded(pos.pack())).toList();
|
||||
report.put("stage", "waiting_for_native_fluid_scheduler"); report.put("fluid_game_time", level.getGameTime());
|
||||
if (!pending.isEmpty()) {
|
||||
report.put("pending_fluid_chunks", pending.stream().map(pos -> Map.of("chunk", List.of(pos.x(), pos.z()),
|
||||
"ticket", level.shouldTickBlocksAt(pos.pack()), "entities", level.areEntitiesLoaded(pos.pack()),
|
||||
"ticking_future", level.getChunkSource().isPositionTicking(pos.pack()))).toList());
|
||||
helper.assertTrue(helper.getTick() - readinessWaitTick < 800,
|
||||
"Forced natural water chunks did not become scheduler-ready within800ticks: " + pending);
|
||||
if (helper.getTick() % 100 == 0) {
|
||||
SanctuaryMod.LOGGER.info("Lost City15 waiting for actual fluid scheduler: {} pending chunks at world time {}", pending.size(), level.getGameTime());
|
||||
write(level, report, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
report.remove("pending_fluid_chunks");
|
||||
// Establish retention before simulation, so a pre-existing leak cannot masquerade as a scheduling delay.
|
||||
report.put("natural_water_before_simulation", wetModules.stream().map(m -> verifyWater(helper, m, false)).toList());
|
||||
for (var p : wetSurfacePools) verifySurfacePool(helper, p, false);
|
||||
for (var pos : forced.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList()) placeFluidWitness(pos);
|
||||
for (var module : wetModules) for (var water : expectedWater(module)) level.scheduleTick(water, Fluids.WATER, 1);
|
||||
for (var pool : wetSurfacePools) for (var water : expectedSurfaceWater(pool)) level.scheduleTick(water, Fluids.WATER, 1);
|
||||
simulationStartWorldTick = level.getGameTime();
|
||||
report.put("stage", "observed_native_fluid_simulation"); report.put("fluid_simulation_start_game_time", simulationStartWorldTick);
|
||||
report.put("temporary_scheduler_witnesses", tickWitnesses.size()); write(level, report, false);
|
||||
}
|
||||
|
||||
void placeFluidWitness(ChunkPos chunk) {
|
||||
BlockPos witness = null;
|
||||
for (int localX : new int[]{4, 8, 12}) for (int localZ : new int[]{4, 8, 12}) {
|
||||
var candidate = new BlockPos(chunk.x() * 16 + localX, 378, chunk.z() * 16 + localZ);
|
||||
boolean empty = true;
|
||||
for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) for (int y = -4; y <= 0; y++)
|
||||
empty &= level.getBlockState(candidate.offset(x, y, z)).isAir();
|
||||
if (empty && witness == null) witness = candidate;
|
||||
}
|
||||
helper.assertTrue(witness != null, "The independent fluid witness needs empty development sky in " + chunk);
|
||||
tickWitnesses.add(witness);
|
||||
for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) for (int y = -4; y <= 0; y++)
|
||||
if (x != 0 || z != 0 || y == -4) level.setBlock(witness.offset(x, y, z), Blocks.STONE.defaultBlockState(), 3);
|
||||
helper.assertTrue(level.getFluidState(witness.below()).isEmpty(), "Independent witness starts with an empty cell below its source");
|
||||
level.setBlock(witness, Blocks.WATER.defaultBlockState(), 3); level.scheduleTick(witness, Fluids.WATER, 1);
|
||||
}
|
||||
|
||||
void verifyFluidWitnesses() {
|
||||
helper.assertTrue(forced.stream().allMatch(pos -> level.isPositionTickingWithEntitiesLoaded(pos.pack())),
|
||||
"Every observed natural water chunk must remain scheduler-ready throughout the measured interval");
|
||||
for (var witness : tickWitnesses) helper.assertTrue(level.getFluidState(witness.below()).is(net.minecraft.tags.FluidTags.WATER),
|
||||
"A real native water tick must flow downward in each forced natural-water chunk: " + witness);
|
||||
helper.assertTrue(!tickWitnesses.isEmpty(), "Fluid execution proof cannot be empty");
|
||||
report.put("fluid_execution_proof", Map.of("scope", "Independent enclosed temporary sky witnesses, excluded from natural city and water counts",
|
||||
"chunks", tickWitnesses.size(), "downward_flow_observed", true, "measured_world_ticks", level.getGameTime() - simulationStartWorldTick));
|
||||
}
|
||||
|
||||
void removeFluidWitnesses() {
|
||||
for (var witness : tickWitnesses) {
|
||||
// Empty the fluid before removing its enclosing walls; this leaves the originally empty sky unchanged.
|
||||
for (int y = 0; y >= -3; y--) level.setBlock(witness.offset(0, y, 0), Blocks.AIR.defaultBlockState(), 3);
|
||||
level.getFluidTicks().clearArea(new BoundingBox(witness.getX() - 1, witness.getY() - 4, witness.getZ() - 1,
|
||||
witness.getX() + 1, witness.getY(), witness.getZ() + 1));
|
||||
for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) for (int y = -4; y <= 0; y++)
|
||||
level.setBlock(witness.offset(x, y, z), Blocks.AIR.defaultBlockState(), 3);
|
||||
}
|
||||
tickWitnesses.clear();
|
||||
}
|
||||
|
||||
String prospect() throws IOException {
|
||||
int bestScore = -1; String best = null;
|
||||
for (int candidateIndex = 0; candidateIndex < 3; candidateIndex++) {
|
||||
String id = "city15_" + round + "_" + candidateIndex;
|
||||
var candidate = session.candidate(id, "sanctuary", "east", "lost_city", 256, "natural", 2048);
|
||||
try {
|
||||
var plan = generator.lostCityPlan(candidate);
|
||||
var underground = generator.undergroundPlan(candidate);
|
||||
var kinds = new HashSet<LostCityUnderground15.Kind>();
|
||||
underground.networks().forEach(n -> n.modules().forEach(m -> kinds.add(m.kind())));
|
||||
var surfaceKinds = new HashSet<LostCityPlan.Kind>();
|
||||
plan.districts().forEach(d -> d.parcels().forEach(p -> surfaceKinds.add(p.kind())));
|
||||
long buildings = plan.districts().stream().flatMap(d -> d.parcels().stream()).filter(p -> p.kind().building()).count();
|
||||
prospects.add(Map.of("id", id, "seed", candidate.seed(), "diameter", candidate.diameter(),
|
||||
"districts", plan.districts().size(), "buildings", buildings, "underground_networks", underground.networks().size(),
|
||||
"underground_kinds", kinds.stream().map(Enum::name).sorted().toList(), "read_only", true,
|
||||
"surface_kinds", surfaceKinds.stream().map(Enum::name).sorted().toList(), "viaduct_piers", generator.citySupports(candidate).size()));
|
||||
SanctuaryMod.LOGGER.info("Lost City15 read-only prospect {} seed {}: {} districts, {} buildings, utility kinds {}",
|
||||
id, candidate.seed(), plan.districts().size(), buildings, kinds);
|
||||
report.put("passed", false); report.put("stage", "read_only_prospects"); write(level, report, false);
|
||||
int score = (int) kinds.stream().filter(REQUIRED_UNDERGROUND::contains).filter(k -> !observedKinds.contains(k)).count() * 1000
|
||||
+ (kinds.contains(LostCityUnderground15.Kind.EXPANSION_HALL) && !observedKinds.contains(LostCityUnderground15.Kind.EXPANSION_HALL) ? 150 : 0)
|
||||
+ (int) surfaceKinds.stream().filter(SURFACE_INTEREST::contains).filter(k -> !observedSurfaceKinds.contains(k)).count() * 100
|
||||
+ Math.min(20, underground.networks().size() * 4) + Math.min(10, plan.districts().size());
|
||||
if (buildings > 0 && score > bestScore) { bestScore = score; best = id; }
|
||||
} finally { generator.discardUnpublishedContext(candidate); }
|
||||
}
|
||||
round++;
|
||||
return best;
|
||||
}
|
||||
|
||||
void observeIsland(ExpansionIsland island) throws IOException {
|
||||
helper.assertTrue(island.generation() == 15, "Reference islands must retain generation 15");
|
||||
var plan = generator.lostCityPlan(island);
|
||||
var underground = generator.undergroundPlan(island);
|
||||
Map<String, Integer> surfaceTypes = new TreeMap<>(), undergroundTypes = new TreeMap<>();
|
||||
plan.districts().forEach(d -> d.parcels().forEach(p -> surfaceTypes.merge(p.kind().name(), 1, Integer::sum)));
|
||||
underground.networks().forEach(n -> n.modules().forEach(m -> undergroundTypes.merge(m.kind().name(), 1, Integer::sum)));
|
||||
naturalPlans.add(Map.of("island", island.id(), "seed", island.seed(), "diameter", island.diameter(),
|
||||
"districts", plan.districts().size(), "surface_kinds", surfaceTypes, "viaduct_supports", generator.citySupports(island).size(),
|
||||
"underground_networks", underground.networks().size(), "underground_kinds", undergroundTypes));
|
||||
SanctuaryMod.LOGGER.info("Lost City15 natural reference {} seed {} before native inspection: {} districts, surface {}, utility {}, {} piers",
|
||||
island.id(), island.seed(), plan.districts().size(), surfaceTypes, undergroundTypes, generator.citySupports(island).size());
|
||||
report.put("passed", false); report.put("stage", "native_inspection_" + island.id()); write(level, report, false);
|
||||
List<LostCityPlan.District> selected = new ArrayList<>(plan.districts());
|
||||
if (selected.isEmpty()) {
|
||||
report.put(island.origin() ? "starter_city_absent" : island.id() + "_city_absent", true);
|
||||
return;
|
||||
}
|
||||
for (int inspected = 0; inspected < MAX_DISTRICTS_PER_ISLAND && !selected.isEmpty(); inspected++) {
|
||||
selected.sort(Comparator.comparingInt((LostCityPlan.District district) -> districtScore(district, underground)).reversed()
|
||||
.thenComparingInt(LostCityPlan.District::startChunkX).thenComparingInt(LostCityPlan.District::startChunkZ));
|
||||
var district = selected.removeFirst();
|
||||
var result = observe(helper, generator, island, district, underground);
|
||||
sites.add(result);
|
||||
district.parcels().forEach(p -> {
|
||||
observedSurfaceKinds.add(p.kind());
|
||||
if (p.kind() == LostCityPlan.Kind.POOL && !expectedSurfaceWater(p).isEmpty()) wetSurfacePools.add(p);
|
||||
});
|
||||
for (var network : networks(district, underground)) for (var module : network.modules()) {
|
||||
observedKinds.add(module.kind());
|
||||
if (!expectedWater(module).isEmpty()) wetModules.add(module);
|
||||
}
|
||||
}
|
||||
}
|
||||
int districtScore(LostCityPlan.District district, LostCityUnderground15.Plan underground) {
|
||||
Set<LostCityUnderground15.Kind> kinds = new HashSet<>();
|
||||
networks(district, underground).forEach(n -> n.modules().forEach(m -> kinds.add(m.kind())));
|
||||
var surfaceKinds = district.parcels().stream().map(LostCityPlan.Parcel::kind).distinct().toList();
|
||||
return (int) kinds.stream().filter(REQUIRED_UNDERGROUND::contains).filter(k -> !observedKinds.contains(k)).count() * 1000
|
||||
+ (kinds.contains(LostCityUnderground15.Kind.EXPANSION_HALL) && !observedKinds.contains(LostCityUnderground15.Kind.EXPANSION_HALL) ? 150 : 0)
|
||||
+ (int) surfaceKinds.stream().filter(SURFACE_INTEREST::contains).filter(k -> !observedSurfaceKinds.contains(k)).count() * 300
|
||||
+ (int) surfaceKinds.stream().filter(LostCityPlan.Kind::building).filter(k -> !observedSurfaceKinds.contains(k)).count() * 20
|
||||
+ (int) district.parcels().stream().filter(p -> p.kind().building()).count();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<LostCityUnderground15.Network> networks(LostCityPlan.District d, LostCityUnderground15.Plan plan) {
|
||||
return plan.networks().stream().filter(n -> n.startChunkX() == d.startChunkX() && n.startChunkZ() == d.startChunkZ()).toList();
|
||||
}
|
||||
|
||||
private static Map<String, Object> observe(GameTestHelper helper, SanctuaryChunkGenerator generator, ExpansionIsland island,
|
||||
LostCityPlan.District district, LostCityUnderground15.Plan underground) {
|
||||
var level = helper.getLevel();
|
||||
var holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(LostCityStructures.KEY);
|
||||
var startChunk = level.getChunkSource().getChunk(district.startChunkX(), district.startChunkZ(), ChunkStatus.STRUCTURE_STARTS, true);
|
||||
var start = startChunk.getStartForStructure(holder.value());
|
||||
helper.assertTrue(start != null && start.isValid(), "Accepted generation-15 district needs a native start");
|
||||
int modules = networks(district, underground).stream().mapToInt(n -> n.modules().size()).sum();
|
||||
long piers = generator.citySupports(island).stream().filter(s -> s.startChunkX() == district.startChunkX()
|
||||
&& s.startChunkZ() == district.startChunkZ()).count();
|
||||
helper.assertTrue(start.getPieces().size() == district.parcels().size() + modules + piers,
|
||||
"Native start must save every surface parcel, utility module and viaduct pier");
|
||||
Set<Long> chunks = new HashSet<>();
|
||||
for (var piece : start.getPieces()) piece.getBoundingBox().intersectingChunks().forEach(pos -> {
|
||||
if (!chunks.add(pos.pack())) return;
|
||||
helper.assertTrue(chunks.size() <= 225, "District inspection must stay inside the native reference footprint");
|
||||
var chunk = level.getChunkSource().getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
|
||||
helper.assertTrue(chunk instanceof LevelChunk && 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 loaded = StructureStart.loadStaticStart(context, tag, island.seed());
|
||||
helper.assertTrue(loaded != null && loaded.isValid() && loaded.createTag(context, loaded.getChunkPos()).equals(tag),
|
||||
"Native starts must round-trip all surface/underground fields, openings, supports and anchors");
|
||||
Map<String, Integer> surfaceKinds = new TreeMap<>(), moduleKinds = new TreeMap<>();
|
||||
List<Map<String, Object>> chests = new ArrayList<>(), rooms = new ArrayList<>(), surfaceAccess = new ArrayList<>();
|
||||
int biomes = 0, supports = 0;
|
||||
for (var piece : start.getPieces()) {
|
||||
if (piece instanceof LostCityPiece15 surface) {
|
||||
var p = surface.parcel(); surfaceKinds.merge(p.kind().name(), 1, Integer::sum);
|
||||
var centre = new BlockPos(p.minX() + p.width() / 2, p.floorY() + 1, p.minZ() + p.depth() / 2);
|
||||
if (level.getBiome(centre).is(SanctuaryMod.id("lost_city"))) biomes++;
|
||||
var monsters = generator.getMobsAt(level, level.structureManager(), MobCategory.MONSTER, centre).unwrap();
|
||||
helper.assertTrue(!monsters.isEmpty() && monsters.stream().allMatch(e -> e.value().type() == EntityTypes.ZOMBIE),
|
||||
"Native city monster overrides must contain vanilla zombies only");
|
||||
if (p.kind().building()) rooms.add(Map.of("kind", p.kind().name(), "min_x", p.minX(), "min_z", p.minZ(),
|
||||
"facing", p.facing(), "walkable_rooms", verifyInterior(helper, p, piece.getBoundingBox())));
|
||||
else if (p.kind() == LostCityPlan.Kind.BRIDGE || p.kind() == LostCityPlan.Kind.PARKING)
|
||||
surfaceAccess.add(verifySurfaceAccess(helper, p, piece.getBoundingBox()));
|
||||
scanBlocks(helper, piece.getBoundingBox(), p.kind().building() ? p.floorY() : Integer.MAX_VALUE, chests);
|
||||
} else if (piece instanceof LostCityUndergroundPiece15 utility) {
|
||||
var m = utility.module(); moduleKinds.merge(m.kind().name(), 1, Integer::sum);
|
||||
var pieceTag = utility.createTag(context);
|
||||
var decoded = new LostCityUndergroundPiece15(context, pieceTag);
|
||||
helper.assertTrue(decoded.module().equals(m) && decoded.createTag(context).equals(pieceTag), "Utility bodies and support records must persist exactly");
|
||||
supports += verifySupports(helper, m);
|
||||
scanBlocks(helper, new BoundingBox(m.minX(), m.minY(), m.minZ(), m.maxX(), m.maxY(), m.maxZ()), m.minY(), chests);
|
||||
} else if (piece instanceof LostCitySupportPiece15 pier) {
|
||||
var pieceTag = pier.createTag(context);
|
||||
var decoded = new LostCitySupportPiece15(context, pieceTag);
|
||||
helper.assertTrue(decoded.support().equals(pier.support()) && decoded.createTag(context).equals(pieceTag),
|
||||
"Viaduct pier geometry and footing must round-trip through native NBT");
|
||||
verifyPier(helper, pier); supports++;
|
||||
} else helper.assertTrue(false, "Generation 15 loaded a historical or unknown native piece type");
|
||||
}
|
||||
for (var network : networks(district, underground)) verifyNetworkAccess(helper, network);
|
||||
helper.assertTrue(chests.size() >= rooms.size(), "Every observed building needs its unopened deferred native loot chest");
|
||||
helper.assertTrue(biomes > 0, "At least one real saved city biome sample must exist per inspected district");
|
||||
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 native district must not spawn 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), "Exact native locate must find Lost City");
|
||||
var result = new LinkedHashMap<String, Object>();
|
||||
result.put("island", island.id()); result.put("generation", island.generation()); result.put("island_seed", island.seed());
|
||||
result.put("start_chunk", List.of(district.startChunkX(), district.startChunkZ()));
|
||||
result.put("native_start_sha256", hash(tag.toString())); result.put("native_pieces", start.getPieces().size());
|
||||
result.put("full_chunks_inspected", chunks.size()); result.put("surface_kinds", surfaceKinds); result.put("underground_kinds", moduleKinds);
|
||||
result.put("supports_checked", supports); result.put("viaduct_piers", piers);
|
||||
result.put("walkable_buildings", rooms); result.put("biome_witnesses", biomes);
|
||||
result.put("native_surface_access", surfaceAccess);
|
||||
result.put("native_city_circulation", verifyCityCirculation(helper, district.parcels()));
|
||||
result.put("unopened_chests", chests); result.put("native_zombie_only_monster_spawns", true);
|
||||
result.put("native_locate", coords(located.getFirst()));
|
||||
district.parcels().stream().filter(p -> p.kind().building()).findFirst().ifPresent(first ->
|
||||
result.put("edit_position", List.of(first.minX(), first.floorY() + 1, first.minZ())));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void scanBlocks(GameTestHelper helper, BoundingBox box, int noOreFromY, List<Map<String, Object>> chests) {
|
||||
var level = helper.getLevel();
|
||||
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 block = level.getBlockState(pos);
|
||||
helper.assertTrue(!block.is(Blocks.SPAWNER), "Native city must not contain a zombie spawner");
|
||||
if (y >= noOreFromY) helper.assertTrue(!BuiltInRegistries.BLOCK.getKey(block.getBlock()).getPath().endsWith("_ore"),
|
||||
"Native ore decoration replaced protected masonry/interiors at " + pos);
|
||||
if (block.is(Blocks.CHEST) && level.getBlockEntity(pos) instanceof RandomizableContainerBlockEntity chest && chest.getLootTable() != null)
|
||||
chests.add(Map.of("position", coords(pos), "table", chest.getLootTable().identifier().toString(), "seed", chest.getLootTableSeed()));
|
||||
}
|
||||
}
|
||||
|
||||
private record StreetSection(String description, List<BlockPos> candidates) { }
|
||||
|
||||
/** Only drawn roads and the two front porch columns are walkable in this proof. Natural terrain,
|
||||
* building interiors and roofs cannot disguise disconnected streets or an isolated raised lot. */
|
||||
private static Map<String, Object> verifyCityCirculation(GameTestHelper helper, List<LostCityPlan.Parcel> parcels) {
|
||||
Set<BlockPos> permitted = new HashSet<>();
|
||||
List<StreetSection> sections = new ArrayList<>();
|
||||
List<Map<String, Object>> entrances = new ArrayList<>();
|
||||
List<BlockPos> targets = new ArrayList<>();
|
||||
int streets = 0;
|
||||
for (var parcel : parcels) {
|
||||
if (parcel.kind() == LostCityPlan.Kind.STREET || parcel.kind() == LostCityPlan.Kind.BRIDGE) {
|
||||
streets++;
|
||||
for (int x = 0; x < parcel.width(); x++) for (int z = 0; z < parcel.depth(); z++)
|
||||
for (int rise = 1; rise <= 2; rise++) permitted.add(new BlockPos(parcel.minX() + x, parcel.surfaceY(x, z) + rise, parcel.minZ() + z));
|
||||
boolean eastWest = parcel.width() >= parcel.depth();
|
||||
int length = eastWest ? parcel.width() : parcel.depth();
|
||||
int breadth = eastWest ? parcel.depth() : parcel.width();
|
||||
for (int along = 0; along < length; along++) {
|
||||
List<BlockPos> options = new ArrayList<>();
|
||||
// A closed utility hatch may occupy the exact middle; the adjacent traffic lane
|
||||
// must still cross this complete street section without leaving the road.
|
||||
for (int across = Math.max(0, breadth / 2 - 1); across <= Math.min(breadth - 1, breadth / 2 + 1); across++)
|
||||
for (int rise = 1; rise <= 2; rise++) {
|
||||
int x = eastWest ? along : across, z = eastWest ? across : along;
|
||||
options.add(new BlockPos(parcel.minX() + x, parcel.surfaceY(x, z) + rise, parcel.minZ() + z));
|
||||
}
|
||||
sections.add(new StreetSection(parcel.kind() + " at " + parcel.minX() + "," + parcel.minZ() + " section " + along, options));
|
||||
}
|
||||
} else {
|
||||
BlockPos entrance = rotatedWorld(parcel, parcel.width() / 2, 1, 0);
|
||||
targets.add(entrance);
|
||||
entrances.add(Map.of("kind", parcel.kind().name(), "position", coords(entrance)));
|
||||
permitted.add(entrance);
|
||||
permitted.add(rotatedWorld(parcel, parcel.width() / 2 - 1, 1, 0));
|
||||
}
|
||||
}
|
||||
helper.assertTrue(streets > 0 && !targets.isEmpty(), "A native city must connect actual lots to drawn streets");
|
||||
var source = targets.getFirst();
|
||||
var reached = walk(helper, source, permitted::contains);
|
||||
for (var target : targets) helper.assertTrue(reached.contains(target),
|
||||
"Native city entrance is isolated from its road network: " + source + " to " + target);
|
||||
List<List<Integer>> witnessedSections = new ArrayList<>();
|
||||
for (var section : sections) {
|
||||
var witness = section.candidates().stream().filter(reached::contains).findFirst();
|
||||
helper.assertTrue(witness.isPresent(), "Native street is blocked or disconnected: " + section.description()
|
||||
+ "; candidates " + section.candidates());
|
||||
witnessedSections.add(coords(witness.orElseThrow()));
|
||||
}
|
||||
SanctuaryMod.LOGGER.info("Lost City15 native circulation: {} lots, {} street/bridge pieces, {} crossed sections, {} standing cells from {}",
|
||||
targets.size(), streets, sections.size(), reached.size(), source);
|
||||
return Map.of("scope", "Connected drawn streets and front porches only; no natural terrain, interior or roof shortcuts",
|
||||
"source", coords(source), "entrances", entrances, "street_pieces", streets,
|
||||
"crossed_street_sections", witnessedSections, "reachable_standing_cells", reached.size(), "connected", true);
|
||||
}
|
||||
|
||||
private static int verifyInterior(GameTestHelper helper, LostCityPlan.Parcel p, BoundingBox bounds) {
|
||||
var level = helper.getLevel();
|
||||
Map<BlockPos, BlockState> doors = openDoors(helper, bounds, false);
|
||||
try {
|
||||
var targets = LostCityInteriors15.rooms(p);
|
||||
Set<BlockPos> reached = walk(helper, world(p, targets.getFirst()), bounds::isInside);
|
||||
for (var room : targets) helper.assertTrue(reached.contains(world(p, room)),
|
||||
"Native " + p.kind() + " cannot reach room " + room.name() + " at " + world(p, room));
|
||||
helper.assertTrue(!doors.isEmpty(), "Detailed interiors need real two-block doors");
|
||||
for (var entry : doors.entrySet()) if (entry.getValue().getValue(BlockStateProperties.DOUBLE_BLOCK_HALF) == DoubleBlockHalf.LOWER) {
|
||||
Direction normal = entry.getValue().getValue(BlockStateProperties.HORIZONTAL_FACING);
|
||||
helper.assertTrue(reached.contains(entry.getKey().relative(normal)) && reached.contains(entry.getKey().relative(normal.getOpposite())),
|
||||
"A placed native door has an inaccessible side at " + entry.getKey());
|
||||
for (Direction side : new Direction[]{normal.getClockWise(), normal.getCounterClockWise()}) {
|
||||
var neighbor = doors.get(entry.getKey().relative(side));
|
||||
if (neighbor == null || neighbor.getValue(BlockStateProperties.DOUBLE_BLOCK_HALF) != DoubleBlockHalf.LOWER
|
||||
|| neighbor.getValue(BlockStateProperties.HORIZONTAL_FACING) != normal) continue;
|
||||
var opened = level.getBlockState(entry.getKey()).getCollisionShape(level, entry.getKey()).bounds();
|
||||
helper.assertTrue(switch (side) {
|
||||
case EAST -> opened.maxX <= .25; case WEST -> opened.minX >= .75;
|
||||
case SOUTH -> opened.maxZ <= .25; case NORTH -> opened.minZ >= .75;
|
||||
default -> false;
|
||||
}, "Native double door opens into its partner instead of against its outer jamb at " + entry.getKey());
|
||||
}
|
||||
}
|
||||
for (int x = bounds.minX(); x <= bounds.maxX(); x++) for (int z = bounds.minZ(); z <= bounds.maxZ(); z++)
|
||||
for (int y = p.floorY() + 1; y <= p.maxY(); y++) {
|
||||
var at = new BlockPos(x, y, z); var state = level.getBlockState(at);
|
||||
if (state.is(Blocks.CHEST)) helper.assertTrue(adjacent(at, reached), "Native loot chest cannot be approached from its room");
|
||||
if (state.is(Blocks.LANTERN)) {
|
||||
var support = state.getValue(BlockStateProperties.HANGING) ? at.above() : at.below();
|
||||
helper.assertTrue(level.getBlockState(support).isCollisionShapeFullBlock(level, support) && state.canSurvive(level, at),
|
||||
"Native lantern floats against a slab or lacks a complete support at " + at);
|
||||
}
|
||||
if (state.is(Blocks.DARK_OAK_FENCE) && level.getBlockState(at.above()).is(Blocks.DARK_OAK_PRESSURE_PLATE)) {
|
||||
for (var side : HORIZONTAL) {
|
||||
var chair = at.relative(side); var seat = level.getBlockState(chair);
|
||||
if (!seat.is(Blocks.DARK_OAK_STAIRS)) continue;
|
||||
var shape = seat.getCollisionShape(level, chair).toAabbs();
|
||||
double nearX = .5 - side.getStepX() * .25, nearZ = .5 - side.getStepZ() * .25;
|
||||
double farX = .5 + side.getStepX() * .25, farZ = .5 + side.getStepZ() * .25;
|
||||
helper.assertTrue(shape.stream().noneMatch(box -> box.contains(nearX, .75, nearZ))
|
||||
&& shape.stream().anyMatch(box -> box.contains(farX, .75, farZ)),
|
||||
"Native seat has its back toward its table or desk at " + chair);
|
||||
}
|
||||
}
|
||||
if (!state.is(Blocks.BED.white())) continue;
|
||||
var part = state.getValue(BlockStateProperties.BED_PART);
|
||||
var facing = state.getValue(BlockStateProperties.HORIZONTAL_FACING);
|
||||
var partner = at.relative(part == BedPart.FOOT ? facing : facing.getOpposite());
|
||||
helper.assertTrue(level.getBlockState(partner).equals(state.setValue(BlockStateProperties.BED_PART,
|
||||
part == BedPart.FOOT ? BedPart.HEAD : BedPart.FOOT)), "Native placement severed a bed at " + at);
|
||||
helper.assertTrue(adjacent(at, reached) || adjacent(partner, reached), "Native bed cannot be approached");
|
||||
}
|
||||
return targets.size();
|
||||
} finally { doors.forEach((pos, state) -> level.setBlock(pos, state, 2)); }
|
||||
}
|
||||
|
||||
private static Map<BlockPos, BlockState> openDoors(GameTestHelper helper, BoundingBox bounds, boolean hatch) {
|
||||
var level = helper.getLevel(); Map<BlockPos, BlockState> originals = new HashMap<>();
|
||||
for (int x = bounds.minX(); x <= bounds.maxX(); x++) for (int z = bounds.minZ(); z <= bounds.maxZ(); z++)
|
||||
for (int y = bounds.minY(); y <= bounds.maxY(); y++) {
|
||||
var pos = new BlockPos(x, y, z); var state = level.getBlockState(pos);
|
||||
if (state.getBlock() instanceof DoorBlock) {
|
||||
helper.assertTrue(state.is(Blocks.DARK_OAK_DOOR), "Room doors must be manually openable");
|
||||
boolean lower = state.getValue(BlockStateProperties.DOUBLE_BLOCK_HALF) == DoubleBlockHalf.LOWER;
|
||||
helper.assertTrue(level.getBlockState(lower ? pos.above() : pos.below()).equals(state.setValue(
|
||||
BlockStateProperties.DOUBLE_BLOCK_HALF, lower ? DoubleBlockHalf.UPPER : DoubleBlockHalf.LOWER)),
|
||||
"Native door halves disagree at " + pos);
|
||||
originals.put(pos, state);
|
||||
} else if (hatch && state.is(Blocks.DARK_OAK_TRAPDOOR)) originals.put(pos, state);
|
||||
}
|
||||
// The test opens both native halves, measures the route, then restores all original states.
|
||||
originals.forEach((pos, state) -> level.setBlock(pos, state.setValue(BlockStateProperties.OPEN, true), 2));
|
||||
return originals;
|
||||
}
|
||||
|
||||
private static void verifyNetworkAccess(GameTestHelper helper, LostCityUnderground15.Network network) {
|
||||
var access = network.modules().stream().filter(m -> m.kind() == LostCityUnderground15.Kind.ACCESS).findFirst().orElseThrow();
|
||||
var bounds = LostCityUndergroundPiece15.bounds(access);
|
||||
var doors = openDoors(helper, bounds, true);
|
||||
try {
|
||||
BlockPos hatch = doors.keySet().stream().filter(pos -> doors.get(pos).is(Blocks.DARK_OAK_TRAPDOOR)).findFirst().orElseThrow();
|
||||
Predicate<BlockPos> inside = pos -> network.modules().stream().anyMatch(m -> m.contains(pos.getX(), pos.getY(), pos.getZ()));
|
||||
BlockPos approach = java.util.Arrays.stream(HORIZONTAL).map(hatch::relative)
|
||||
.filter(inside).filter(pos -> standable(helper.getLevel(), pos)).findFirst().orElseThrow();
|
||||
Set<BlockPos> reached = walk(helper, approach, inside);
|
||||
helper.assertTrue(reached.contains(hatch), "Surface hatch must be accessible from its street-level approach");
|
||||
for (var m : network.modules()) helper.assertTrue(reached.stream().anyMatch(pos -> pos.getY() == m.floorY() + 1
|
||||
&& pos.getX() > m.minX() && pos.getX() < m.maxX() && pos.getZ() > m.minZ() && pos.getZ() < m.maxZ()),
|
||||
"Native surface hatch cannot reach utility module " + m.kind() + " at " + m.centerX() + "," + m.centerZ());
|
||||
for (var m : network.modules()) if (m.kind() == LostCityUnderground15.Kind.EXPANSION_HALL) {
|
||||
helper.assertTrue(reached.contains(new BlockPos(m.centerX(), m.minY() + 3, m.centerZ())),
|
||||
"Native expansion hall's reserved central plinth cannot be reached from the surface hatch");
|
||||
for (int dx : new int[]{-6, 6}) for (int dz : new int[]{-6, 6})
|
||||
helper.assertTrue(reached.contains(new BlockPos(m.centerX() + dx, m.floorY() + 1, m.centerZ() + dz)),
|
||||
"Native expansion hall has an inaccessible quarter at " + m.centerX() + "," + m.centerZ());
|
||||
}
|
||||
} finally { doors.forEach((pos, state) -> helper.getLevel().setBlock(pos, state, 2)); }
|
||||
}
|
||||
|
||||
private static int verifySupports(GameTestHelper helper, LostCityUnderground15.Module m) {
|
||||
var level = helper.getLevel();
|
||||
for (var support : m.supports()) {
|
||||
helper.assertTrue(support.maxY() - support.minY() < 8, "Native support exceeds its bounded rock anchor distance");
|
||||
for (int y = support.minY(); y <= support.maxY(); y++) {
|
||||
var expected = support.type() == LostCityUnderground15.Type.PILLAR ? Blocks.STONE_BRICKS
|
||||
: y == support.maxY() ? Blocks.POLISHED_ANDESITE : Blocks.IRON_CHAIN;
|
||||
helper.assertTrue(level.getBlockState(new BlockPos(support.x(), y, support.z())).is(expected),
|
||||
"Native supported module has a broken or overwritten pillar/chain");
|
||||
}
|
||||
int neighborY = support.type() == LostCityUnderground15.Type.PILLAR ? support.minY() - 1 : support.maxY() + 1;
|
||||
var neighbor = level.getBlockState(new BlockPos(support.x(), neighborY, support.z()));
|
||||
helper.assertTrue(neighbor.isSolid() && neighbor.getFluidState().isEmpty(), "Native support lost its rock anchor");
|
||||
}
|
||||
return m.supports().size();
|
||||
}
|
||||
|
||||
private static void verifyPier(GameTestHelper helper, LostCitySupportPiece15 piece) {
|
||||
var support = piece.support(); var level = helper.getLevel();
|
||||
helper.assertTrue(support.maxY() - support.minY() < 16, "Native viaduct grew an unbounded pier");
|
||||
for (int x = support.minX(); x <= support.maxX(); x++) for (int z = support.minZ(); z <= support.maxZ(); z++) {
|
||||
var anchor = level.getBlockState(new BlockPos(x, support.minY() - 1, z));
|
||||
helper.assertTrue(anchor.isSolid() && anchor.getFluidState().isEmpty(), "Native viaduct pier lost its natural footing");
|
||||
for (int y = support.minY(); y <= support.maxY(); y++) {
|
||||
var expected = y == support.minY() || y == support.maxY() ? Blocks.SMOOTH_STONE.defaultBlockState()
|
||||
: LostCityPalette.masonry(support.seed(), x - support.minX(), y - support.minY(), z - support.minZ());
|
||||
helper.assertTrue(level.getBlockState(new BlockPos(x, y, z)).equals(expected), "Native viaduct pier is interrupted or overwritten");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> verifySurfaceAccess(GameTestHelper helper, LostCityPlan.Parcel p, BoundingBox bounds) {
|
||||
BlockPos start, target;
|
||||
if (p.kind() == LostCityPlan.Kind.BRIDGE) {
|
||||
boolean eastWest = p.width() >= p.depth();
|
||||
int startX = eastWest ? 0 : p.width() / 2, startZ = eastWest ? p.depth() / 2 : 0;
|
||||
int endX = eastWest ? p.width() - 1 : p.width() / 2, endZ = eastWest ? p.depth() / 2 : p.depth() - 1;
|
||||
start = new BlockPos(p.minX() + startX, p.surfaceY(startX, startZ) + 1, p.minZ() + startZ);
|
||||
target = new BlockPos(p.minX() + endX, p.surfaceY(endX, endZ) + 1, p.minZ() + endZ);
|
||||
if (!standable(helper.getLevel(), start) && helper.getLevel().getBlockState(start).is(Blocks.STONE_BRICK_STAIRS)) start = start.above();
|
||||
if (!standable(helper.getLevel(), target) && helper.getLevel().getBlockState(target).is(Blocks.STONE_BRICK_STAIRS)) target = target.above();
|
||||
} else {
|
||||
start = rotatedWorld(p, 8, 1, 0);
|
||||
target = rotatedWorld(p, 8, p.foundationDepth() >= 8 ? -4 : 1, 10);
|
||||
}
|
||||
Set<BlockPos> reached = walk(helper, start, bounds::isInside);
|
||||
helper.assertTrue(reached.contains(target), "Native " + p.kind() + " lost its traversable access: " + start + " to " + target);
|
||||
return Map.of("kind", p.kind().name(), "start", coords(start), "destination", coords(target),
|
||||
"underground_garage", p.kind() == LostCityPlan.Kind.PARKING && p.foundationDepth() >= 8, "walkable", true);
|
||||
}
|
||||
|
||||
private static Set<BlockPos> expectedSurfaceWater(LostCityPlan.Parcel p) {
|
||||
return surfaceWater(p).sources();
|
||||
}
|
||||
|
||||
private record SurfaceWater(Set<BlockPos> sources, Set<BlockPos> flowEnvelope,
|
||||
Map<BlockPos, BlockState> shell, Map<BlockPos, BlockState> steps) { }
|
||||
|
||||
private static SurfaceWater surfaceWater(LostCityPlan.Parcel p) {
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
LostCitySurface15.render(p, new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) {
|
||||
blocks.put(new BlockPos(p.minX() + x, p.floorY() + y, p.minZ() + z), state);
|
||||
}
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> loot, long seed) { }
|
||||
});
|
||||
Set<BlockPos> sources = new HashSet<>(), basin = new HashSet<>();
|
||||
Map<BlockPos, BlockState> shell = new HashMap<>(), steps = new HashMap<>();
|
||||
blocks.forEach((pos, state) -> { if (!state.getFluidState().isEmpty()) sources.add(pos); });
|
||||
for (int x = 4; x <= p.width() - 5; x++) for (int z = 5; z <= p.depth() - 5; z++)
|
||||
for (int y = -3; y <= -1; y++) basin.add(rotatedWorld(p, x, y, z));
|
||||
// The submerged steps deliberately clear cells above their lower treads. Native water
|
||||
// may fill those cells; this finite closure permits that flow without permitting a leak.
|
||||
Set<BlockPos> envelope = new HashSet<>(sources);
|
||||
ArrayDeque<BlockPos> queue = new ArrayDeque<>(sources);
|
||||
while (!queue.isEmpty()) {
|
||||
var pos = queue.removeFirst();
|
||||
if (!basin.contains(pos)) throw new IllegalStateException("Rendered pool has an open water boundary at " + pos);
|
||||
for (var direction : Direction.values()) if (direction != Direction.UP) {
|
||||
var next = pos.relative(direction);
|
||||
var state = blocks.getOrDefault(next, Blocks.AIR.defaultBlockState());
|
||||
if (state.isAir() || !state.getFluidState().isEmpty()) {
|
||||
if (envelope.add(next)) queue.addLast(next);
|
||||
} else shell.put(next, state);
|
||||
}
|
||||
}
|
||||
for (int step = 1; step <= 3; step++) for (int x = p.width() / 2 - 1; x <= p.width() / 2 + 1; x++) {
|
||||
var pos = rotatedWorld(p, x, -step, step + 4);
|
||||
if (!sources.isEmpty()) steps.put(pos, blocks.get(pos));
|
||||
}
|
||||
return new SurfaceWater(Set.copyOf(sources), Set.copyOf(envelope), Map.copyOf(shell), Map.copyOf(steps));
|
||||
}
|
||||
|
||||
private static Map<String, Object> verifySurfacePool(GameTestHelper helper, LostCityPlan.Parcel p, boolean ticked) {
|
||||
var level = helper.getLevel(); var water = surfaceWater(p);
|
||||
helper.assertTrue(water.sources().size() >= 150, "Naturally planned surface pool must contain a substantial basin");
|
||||
var witness = water.sources().stream().sorted(BlockPos::compareTo).findFirst().orElseThrow();
|
||||
if (ticked) {
|
||||
helper.assertTrue(level.isPositionTickingWithEntitiesLoaded(ChunkPos.containing(witness).pack()), "Natural surface pool must actually tick");
|
||||
}
|
||||
int count = 0;
|
||||
for (int x = p.minX(); x <= p.maxX(); x++) for (int z = p.minZ(); z <= p.maxZ(); z++)
|
||||
for (int y = p.minY(); y <= p.maxY(); y++) {
|
||||
var at = new BlockPos(x, y, z); boolean wet = !level.getFluidState(at).isEmpty();
|
||||
helper.assertTrue(!wet || water.flowEnvelope().contains(at), "Native surface pool leaked outside its sealed basin at " + at);
|
||||
helper.assertTrue(!wet || level.getFluidState(at).is(net.minecraft.tags.FluidTags.WATER), "Native surface pool contains a foreign fluid at " + at);
|
||||
helper.assertTrue(!water.sources().contains(at) || wet && level.getFluidState(at).isSource(),
|
||||
"Native surface pool lost an original water source at " + at);
|
||||
if (wet) count++;
|
||||
}
|
||||
water.shell().forEach((pos, expected) -> helper.assertTrue(level.getBlockState(pos).equals(expected),
|
||||
"Native surface pool lost its continuous floor or walls at " + pos));
|
||||
water.steps().forEach((pos, expected) -> helper.assertTrue(level.getBlockState(pos).equals(expected),
|
||||
"Native pool steps changed or lost their waterlogging after fluid ticks/reload at " + pos));
|
||||
return Map.of("scope", "Naturally planned surface pool, not an isolated fixture", "min", List.of(p.minX(), p.floorY(), p.minZ()),
|
||||
"facing", p.facing(), "retained_water_cells", count, "checked_after_observed_fluid_simulation", ticked,
|
||||
"contained", true, "steps_preserved", true, "original_water_cells", water.sources().size(),
|
||||
"flow_reachable_cells", water.flowEnvelope().size(), "new_intra_basin_water_cells", count - water.sources().size());
|
||||
}
|
||||
|
||||
private static Set<BlockPos> expectedWater(LostCityUnderground15.Module module) {
|
||||
Set<BlockPos> water = new HashSet<>();
|
||||
LostCityUnderground15.render(module, new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) {
|
||||
var pos = new BlockPos(module.minX() + x, module.minY() + y, module.minZ() + z);
|
||||
if (!state.getFluidState().isEmpty()) water.add(pos); else water.remove(pos);
|
||||
}
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> loot, long seed) { }
|
||||
});
|
||||
return water;
|
||||
}
|
||||
|
||||
private static Map<String, Object> verifyWater(GameTestHelper helper, LostCityUnderground15.Module m, boolean ticked) {
|
||||
var level = helper.getLevel(); var water = expectedWater(m);
|
||||
BlockPos witness = water.stream().sorted(BlockPos::compareTo).findFirst().orElseThrow();
|
||||
if (ticked) {
|
||||
helper.assertTrue(level.isPositionTickingWithEntitiesLoaded(ChunkPos.containing(witness).pack()), "Natural poolroom/sewer fixture must actually tick");
|
||||
}
|
||||
int count = 0;
|
||||
for (int x = m.minX(); x <= m.maxX(); x++) for (int z = m.minZ(); z <= m.maxZ(); z++)
|
||||
for (int y = m.minY(); y <= m.maxY(); y++) {
|
||||
var pos = new BlockPos(x, y, z); boolean wet = !level.getFluidState(pos).isEmpty();
|
||||
helper.assertTrue(wet == water.contains(pos), "Natural utility water leaked or disappeared at " + pos + " in " + m.kind());
|
||||
if (wet) count++;
|
||||
}
|
||||
helper.assertTrue(count > 0, "Native wet module needs retained water");
|
||||
return Map.of("scope", "Naturally planned and natively generated utility module", "kind", m.kind().name(),
|
||||
"min", List.of(m.minX(), m.minY(), m.minZ()), "retained_water_cells", count,
|
||||
"checked_after_observed_fluid_simulation", ticked, "contained", true);
|
||||
}
|
||||
|
||||
private static Set<BlockPos> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside) {
|
||||
var level = helper.getLevel();
|
||||
helper.assertTrue(inside.test(start) && standable(level, start), "Native route has a blocked starting position: " + start);
|
||||
Set<BlockPos> reached = new HashSet<>(); ArrayDeque<BlockPos> queue = new ArrayDeque<>();
|
||||
reached.add(start); queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
var pos = queue.removeFirst();
|
||||
for (Direction d : HORIZONTAL) for (int dy = -1; dy <= 1; dy++) {
|
||||
var next = pos.relative(d).offset(0, dy, 0);
|
||||
if (!inside.test(next) || dy > 0 && !passable(level.getBlockState(pos.above(2)))) continue;
|
||||
if (standable(level, next) && reached.add(next)) queue.add(next);
|
||||
}
|
||||
for (int dy : new int[]{-1, 1}) {
|
||||
var next = pos.offset(0, dy, 0);
|
||||
if (!inside.test(next)) continue;
|
||||
if ((climbable(level.getBlockState(pos)) || climbable(level.getBlockState(next)) || climbable(level.getBlockState(pos.below())))
|
||||
&& standable(level, next) && reached.add(next)) queue.add(next);
|
||||
}
|
||||
helper.assertTrue(reached.size() < 100_000, "Native room traversal exceeded its bounded structural footprint");
|
||||
}
|
||||
return reached;
|
||||
}
|
||||
private static boolean standable(ServerLevel level, BlockPos pos) {
|
||||
var below = level.getBlockState(pos.below());
|
||||
return passable(level.getBlockState(pos)) && passable(level.getBlockState(pos.above()))
|
||||
&& (below.isSolid() || below.getBlock() instanceof StairBlock || climbable(level.getBlockState(pos)) || climbable(below));
|
||||
}
|
||||
private static boolean climbable(BlockState state) { return state.is(Blocks.LADDER); }
|
||||
private static boolean passable(BlockState state) {
|
||||
return state.isAir() || state.is(Blocks.LADDER) || state.is(Blocks.REDSTONE_WALL_TORCH)
|
||||
|| (state.is(Blocks.DARK_OAK_DOOR) || state.is(Blocks.DARK_OAK_TRAPDOOR)) && state.getValue(BlockStateProperties.OPEN);
|
||||
}
|
||||
private static boolean adjacent(BlockPos pos, Set<BlockPos> reached) {
|
||||
for (Direction direction : HORIZONTAL) if (reached.contains(pos.relative(direction))) return true;
|
||||
return false;
|
||||
}
|
||||
private static BlockPos world(LostCityPlan.Parcel p, LostCityInteriors15.Room room) {
|
||||
return new BlockPos(p.minX() + room.x(), p.floorY() + room.y(), p.minZ() + room.z());
|
||||
}
|
||||
private static BlockPos rotatedWorld(LostCityPlan.Parcel p, int x, int y, int z) {
|
||||
int rx = switch (p.facing()) { case 0 -> x; case 1 -> p.width() - 1 - z; case 2 -> p.width() - 1 - x; default -> z; };
|
||||
int rz = switch (p.facing()) { case 0 -> z; case 1 -> x; case 2 -> p.depth() - 1 - z; default -> p.depth() - 1 - x; };
|
||||
return new BlockPos(p.minX() + rx, p.floorY() + y, p.minZ() + rz);
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> verifyInteriorModels(GameTestHelper helper) {
|
||||
var context = StructurePieceSerializationContext.fromLevel(helper.getLevel());
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (var kind : LostCityPlan.Kind.values()) if (kind.building()) for (int facing = 0; facing < 4; facing++) {
|
||||
var parcel = new LostCityPlan.Parcel(kind, -32, 48, 17, 17, 160, 3, facing, 1515, 4, new int[0]);
|
||||
var piece = new LostCityPiece15(parcel); var tag = piece.createTag(context);
|
||||
var reloaded = new LostCityPiece15(context, tag);
|
||||
helper.assertTrue(reloaded.createTag(context).equals(tag), "Generation-15 interior parcel fields did not persist");
|
||||
helper.assertTrue(LostCityInteriors15.rooms(parcel).equals(LostCityInteriors15.rooms(reloaded.parcel())), "Reload changed room targets");
|
||||
result.add(Map.of("kind", kind.name(), "facing", facing, "native_piece_sha256", hash(tag.toString()),
|
||||
"scope", "Serialization fixture only; natural occurrence is recorded separately in sites"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void verifyLegacy(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, helper.getLevel().registryAccess());
|
||||
for (int generation : new int[]{13, 14}) {
|
||||
var json = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow().getAsJsonObject();
|
||||
json.addProperty("type", "sanctuary:island_v" + generation);
|
||||
json.getAsJsonObject("biome_source").addProperty("type", "sanctuary:island_v" + generation);
|
||||
var legacy = (SanctuaryChunkGenerator) ChunkGenerator.CODEC.parse(ops, json).getOrThrow();
|
||||
helper.assertTrue(legacy.rootGeneration() == generation && legacy.journalDirectory().equals("sanctuary-world-v" + generation),
|
||||
"Historical codecs must retain their exact generation and journal");
|
||||
helper.assertTrue(legacy.undergroundPlan(ExpansionIsland.origin(generator.initialDiameter(), helper.getLevel().getSeed(), generation)).isEmpty(),
|
||||
"Historical worlds must not acquire generation-15 underground utilities");
|
||||
}
|
||||
}
|
||||
|
||||
private static void reload(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
var level = helper.getLevel(); var result = report(level);
|
||||
try {
|
||||
var previous = JsonParser.parseString(Files.readString(path(level, false))).getAsJsonObject();
|
||||
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(), "Reload requires the successful creation proof");
|
||||
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "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 use the same development world and seed");
|
||||
var session = ExpansionRuntime.session(level);
|
||||
helper.assertTrue(previous.get("islands").equals(ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow()), "Island identities changed on reload");
|
||||
helper.assertTrue(hash(Files.readString(journal(level, generator))).equals(previous.get("journal_sha256").getAsString()), "Expansion journal bytes changed on reload");
|
||||
var gold = readPos(previous.getAsJsonArray("gold_witness"));
|
||||
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Existing gameplay edit was lost or regenerated");
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(LostCityStructures.KEY);
|
||||
var context = StructurePieceSerializationContext.fromLevel(level);
|
||||
int savedPieces = 0, unopened = 0, roomTargets = 0, supports = 0;
|
||||
List<Map<String, Object>> fluids = new ArrayList<>(), surfacePools = new ArrayList<>(), surfaceAccess = new ArrayList<>(), circulation = new ArrayList<>();
|
||||
Set<String> checkedNetworks = new HashSet<>();
|
||||
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(), "Saved generation-15 native start was lost");
|
||||
helper.assertTrue(hash(start.createTag(context, start.getChunkPos()).toString()).equals(site.get("native_start_sha256").getAsString()),
|
||||
"Native pieces, utility bodies, openings or support anchors changed on reload");
|
||||
savedPieces += start.getPieces().size();
|
||||
for (var piece : start.getPieces()) {
|
||||
piece.getBoundingBox().intersectingChunks().forEach(at -> {
|
||||
var full = level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true);
|
||||
helper.assertTrue(full.getReferencesForStructure(structure.value()).contains(start.getChunkPos().pack()), "Saved FULL reference disappeared");
|
||||
});
|
||||
if (piece instanceof LostCityPiece15 surface) {
|
||||
var p = surface.parcel();
|
||||
if (p.kind().building()) roomTargets += verifyInterior(helper, p, piece.getBoundingBox());
|
||||
else if (p.kind() == LostCityPlan.Kind.POOL && !expectedSurfaceWater(p).isEmpty()) surfacePools.add(verifySurfacePool(helper, p, false));
|
||||
else if (p.kind() == LostCityPlan.Kind.BRIDGE || p.kind() == LostCityPlan.Kind.PARKING)
|
||||
surfaceAccess.add(verifySurfaceAccess(helper, p, piece.getBoundingBox()));
|
||||
} else if (piece instanceof LostCityUndergroundPiece15 utility) {
|
||||
supports += verifySupports(helper, utility.module());
|
||||
if (!expectedWater(utility.module()).isEmpty()) fluids.add(verifyWater(helper, utility.module(), false));
|
||||
} else if (piece instanceof LostCitySupportPiece15 pier) { verifyPier(helper, pier); supports++; }
|
||||
}
|
||||
var savedParcels = start.getPieces().stream().filter(LostCityPiece15.class::isInstance)
|
||||
.map(LostCityPiece15.class::cast).map(LostCityPiece15::parcel).toList();
|
||||
circulation.add(verifyCityCirculation(helper, savedParcels));
|
||||
for (var chestElement : site.getAsJsonArray("unopened_chests")) {
|
||||
var saved = chestElement.getAsJsonObject(); var at = readPos(saved.getAsJsonArray("position"));
|
||||
helper.assertTrue(level.getBlockEntity(at) instanceof RandomizableContainerBlockEntity, "Saved deferred chest entity disappeared");
|
||||
var chest = (RandomizableContainerBlockEntity) level.getBlockEntity(at);
|
||||
helper.assertTrue(chest.getLootTable() != null && chest.getLootTable().identifier().toString().equals(saved.get("table").getAsString())
|
||||
&& chest.getLootTableSeed() == saved.get("seed").getAsLong(), "Unopened loot table or seed changed on reload");
|
||||
unopened++;
|
||||
}
|
||||
var island = session.island(site.get("island").getAsString());
|
||||
for (var network : generator.undergroundPlan(island).networks()) {
|
||||
String identity = island.id() + "/" + network.startChunkX() + "/" + network.startChunkZ();
|
||||
if (network.startChunkX() == pos.get(0).getAsInt() && network.startChunkZ() == pos.get(1).getAsInt() && checkedNetworks.add(identity))
|
||||
verifyNetworkAccess(helper, network);
|
||||
}
|
||||
}
|
||||
var readback = new ExpansionJournal(journal(level, generator).getParent(), level.getSeed(), generator.initialDiameter(), 15);
|
||||
helper.assertTrue(readback.healthy() && readback.islands().equals(session.islands()), "Generation-15 journal failed its own strict reopen");
|
||||
result.put("saved_native_pieces", savedPieces); result.put("preserved_deferred_chests", unopened);
|
||||
result.put("walkable_room_targets", roomTargets); result.put("preserved_supports", supports);
|
||||
result.put("natural_surface_pool_checks", surfacePools); result.put("native_surface_access", surfaceAccess);
|
||||
result.put("native_city_circulation", circulation);
|
||||
result.put("natural_fluid_checks", fluids); result.put("gold_witness_preserved", true); result.put("journal_preserved", true);
|
||||
result.put("passed", true); write(level, result, true); helper.succeed();
|
||||
} catch (Exception error) {
|
||||
result.put("passed", false); result.put("failure", error.toString());
|
||||
try { write(level, result, true); } catch (IOException ignored) { }
|
||||
throw new IllegalStateException("Lost City15 native reload failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> report(ServerLevel level) {
|
||||
var result = new LinkedHashMap<String, Object>();
|
||||
result.put("seed", level.getSeed()); result.put("process_id", ProcessHandle.current().pid());
|
||||
result.put("world_directory", level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize().toString());
|
||||
result.put("scope", "Naturally planned generation-15 districts and utility networks in a disposable development world. "
|
||||
+ "At most six read-only prospects and two 256-block expansions; inspection loads at most six district footprints per island, not an entire island. "
|
||||
+ "Normal expansion preparation retains its own bounded chunk scope. Interior serialization fixtures are not natural occurrence evidence. "
|
||||
+ "Independent enclosed temporary sky witnesses prove native fluid execution in each forced chunk and are removed before saving; they are excluded from natural water counts. "
|
||||
+ "Native doors/hatches are temporarily opened and restored for player-sized route checks. No personal world is opened.");
|
||||
return result;
|
||||
}
|
||||
private static List<Integer> coords(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
|
||||
private static BlockPos readPos(com.google.gson.JsonArray value) { return new BlockPos(value.get(0).getAsInt(), value.get(1).getAsInt(), value.get(2).getAsInt()); }
|
||||
private static Path 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-alpha15" + (reload ? "-reload" : "") + "-seed-" + level.getSeed() + ".json");
|
||||
}
|
||||
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
|
||||
var 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(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
|
||||
}
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.expansion.ExpansionIsland;
|
||||
import fr.koka.sanctuary.worldgen.PopulationGenerationContext;
|
||||
import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryDecorationProtection;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlan;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlanner16;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityProtection16;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityTerrain16;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityUnderground16;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
|
||||
/** Six isolated, real starter-noise witnesses. This helper never requests a chunk, attaches a
|
||||
* generator, mutates the active biome source or creates an expansion session. Hydrology is the
|
||||
* production planner and its exact protection mask, rather than an all-dry testing substitute.
|
||||
* Surface availability is asserted; underground coverage is reported as a survey, not a guarantee. */
|
||||
public final class LostCity16PlanningDiagnostics {
|
||||
private LostCity16PlanningDiagnostics() {}
|
||||
|
||||
public static List<Map<String, Object>> verify(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
helper.assertTrue(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
|
||||
"Native city matrix requires the Sanctuary source");
|
||||
var active = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
|
||||
helper.assertTrue(active.rootGeneration() == 16 && level.getMinY() == 0 && level.getHeight() == 384,
|
||||
"Native city matrix uses generation16 and its actual 384-block height");
|
||||
var originalIslands = active.source().islands();
|
||||
var access = level.registryAccess();
|
||||
var settings = access.lookupOrThrow(Registries.NOISE_SETTINGS);
|
||||
var noises = access.lookupOrThrow(Registries.NOISE);
|
||||
var reports = new ArrayList<Map<String, Object>>();
|
||||
for (int diameter : new int[] {512, 724, 1024}) for (long seed : new long[] {0, 1}) {
|
||||
long started = System.nanoTime();
|
||||
int capacity = diameter == 512 ? 5 : diameter == 724 ? 10 : 20;
|
||||
var key = ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("unified_" + capacity));
|
||||
var registered = settings.getOrThrow(key);
|
||||
var island = ExpansionIsland.origin(diameter, seed, 16);
|
||||
var source = active.source().copy();
|
||||
helper.assertTrue(source != active.source() && source.generation() == 16, "Matrix shares a mutable biome source");
|
||||
source.setIslands(List.of(island));
|
||||
var generator = new NoiseBasedChunkGenerator(source, registered);
|
||||
var random = RandomState.create(noises, seed, registered.value());
|
||||
var binding = new PopulationGenerationContext.Binding(generator, random);
|
||||
Map<Long, SanctuaryDecorationProtection> protection = new HashMap<>();
|
||||
var terrain = new LostCityTerrain16(island, generator, random, level, position -> {
|
||||
int cx = position.getX() >> 4, cz = position.getZ() >> 4;
|
||||
return protection.computeIfAbsent(key(cx, cz), ignored -> SanctuaryDecorationProtection.create(binding, cx, cz))
|
||||
.protects(position);
|
||||
});
|
||||
var plan = LostCityPlanner16.plan(seed, 0, 0, diameter, true, terrain);
|
||||
int plannedColumns = terrain.columnsUsed();
|
||||
helper.assertTrue(plan.columns() == plannedColumns && plan.columns() <= LostCityPlanner16.MAX_COLUMNS,
|
||||
label(diameter, seed) + " exceeds or bypasses its surface column budget");
|
||||
helper.assertTrue(plan.volumeChecks() <= LostCityPlanner16.MAX_VOLUME_CHECKS,
|
||||
label(diameter, seed) + " exceeds its volume-check budget");
|
||||
var report = verifyPlan(helper, diameter, seed, island, plan, terrain);
|
||||
helper.assertTrue(terrain.columnsUsed() == plannedColumns,
|
||||
label(diameter, seed) + " accepted a foundation that the planner never sampled");
|
||||
report.put("surface_elapsed_ms", (System.nanoTime() - started) / 1_000_000);
|
||||
var underground = surveyUnderground(seed, plan, terrain);
|
||||
report.put("underground", underground);
|
||||
report.put("noise_settings", key.identifier().toString());
|
||||
report.put("columns", plan.columns());
|
||||
report.put("total_columns", terrain.columnsUsed());
|
||||
report.put("volume_checks", plan.volumeChecks());
|
||||
report.put("hydrology_regions", PopulationHydrologyRuntime.cachedRegionCount(random));
|
||||
report.put("hydrology_mask_chunks", protection.size());
|
||||
report.put("native_noise", true);
|
||||
report.put("chunk_requests", 0);
|
||||
report.put("elapsed_ms", (System.nanoTime() - started) / 1_000_000);
|
||||
reports.add(Map.copyOf(report));
|
||||
SanctuaryMod.LOGGER.info("Lost City16 matrix seed {} size {}: {} lots, fallback {}, {} anchored piles, {} columns, {} ms",
|
||||
seed, diameter, report.get("lots"), report.get("fallback"), report.get("foundation_anchors"),
|
||||
plan.columns(), report.get("elapsed_ms"));
|
||||
SanctuaryMod.LOGGER.info("Lost City16 utility survey seed {} size {}: types {}, missing {}, {} modules, {} garage levels, {} total columns, {} UG samples",
|
||||
seed, diameter, underground.get("types"), underground.get("missing_requested_types"),
|
||||
underground.get("modules"), underground.get("garage_levels"), terrain.columnsUsed(), underground.get("samples"));
|
||||
}
|
||||
helper.assertTrue(active.source().islands().equals(originalIslands), "Read-only city matrix changed the live island snapshot");
|
||||
return List.copyOf(reports);
|
||||
}
|
||||
|
||||
/** Mirrors SanctuaryChunkGenerator's generation16 adapter, sharing its sampled columns and
|
||||
* reserving exactly the same piers/architectural foundation before underground prospecting. */
|
||||
private static Map<String, Object> surveyUnderground(long seed, LostCityPlanner16.Result placement, LostCityTerrain16 sampled) {
|
||||
long started = System.nanoTime();
|
||||
int surfaceColumns = sampled.columnsUsed();
|
||||
var supports = new LostCityProtection16.Builder();
|
||||
for (var support : placement.supports()) supports.addViaductSupport(support);
|
||||
if (placement.reservation() != null) supports.addReservation(placement.reservation());
|
||||
var supportMask = supports.build();
|
||||
var underground = LostCityUnderground16.plan(seed, placement.surface(), new LostCityUnderground16.Terrain() {
|
||||
@Override public boolean solid(int x, int y, int z) { return sampled.solid(x, y, z); }
|
||||
@Override public boolean owns(int x, int z) { return sampled.owns(x, z); }
|
||||
@Override public boolean exteriorAir(int x, int y, int z, int dx, int dz) { return sampled.exteriorAir(x, y, z, dx, dz); }
|
||||
@Override public boolean protectedAt(int x, int y, int z) {
|
||||
return supportMask.contains(x, y, z) || sampled.protectedAt(x, y, z);
|
||||
}
|
||||
});
|
||||
Map<String, Integer> types = new TreeMap<>();
|
||||
var details = new ArrayList<Map<String, Object>>();
|
||||
int garageLevels = 0, largestGarage = 0;
|
||||
for (var network : underground.networks()) for (var module : network.modules()) {
|
||||
types.merge(module.kind().name(), 1, Integer::sum);
|
||||
var floors = module.parkingFloors();
|
||||
garageLevels += floors.size(); largestGarage = Math.max(largestGarage, floors.size());
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("kind", module.kind().name());
|
||||
detail.put("bounds", List.of(module.minX(), module.minY(), module.minZ(), module.maxX(), module.maxY(), module.maxZ()));
|
||||
detail.put("floor_y", module.floorY()); detail.put("parking_floors", floors);
|
||||
detail.put("supports", module.supports().size());
|
||||
if (module.kind() == LostCityUnderground16.Kind.SEWER_OUTLET) {
|
||||
var mouth = module.outletMouth();
|
||||
detail.put("outlet_mouth", List.of(mouth.getX(), mouth.getY(), mouth.getZ()));
|
||||
detail.put("outlet_direction", module.outletDirection());
|
||||
}
|
||||
details.add(Map.copyOf(detail));
|
||||
}
|
||||
var requested = List.of(LostCityUnderground16.Kind.SEWER, LostCityUnderground16.Kind.POOLROOM,
|
||||
LostCityUnderground16.Kind.GARAGE, LostCityUnderground16.Kind.EXPANSION_HALL, LostCityUnderground16.Kind.SEWER_OUTLET);
|
||||
Map<String, Object> report = new LinkedHashMap<>();
|
||||
report.put("mode", "survey"); report.put("networks", underground.networks().size());
|
||||
report.put("types", types); report.put("modules", details.size()); report.put("module_details", details);
|
||||
report.put("missing_requested_types", requested.stream().map(Enum::name).filter(kind -> !types.containsKey(kind)).toList());
|
||||
report.put("garage_levels", garageLevels); report.put("largest_garage_levels", largestGarage);
|
||||
report.put("surface_columns", surfaceColumns); report.put("total_columns", sampled.columnsUsed());
|
||||
report.put("additional_columns", sampled.columnsUsed() - surfaceColumns);
|
||||
report.put("samples", underground.samples()); report.put("sample_limit", LostCityUnderground16.MAX_SAMPLES);
|
||||
report.put("column_limit", LostCityTerrain16.MAX_COLUMNS);
|
||||
report.put("elapsed_ms", (System.nanoTime() - started) / 1_000_000);
|
||||
return Map.copyOf(report);
|
||||
}
|
||||
|
||||
private static Map<String, Object> verifyPlan(GameTestHelper helper, int diameter, long seed, ExpansionIsland island,
|
||||
LostCityPlanner16.Result result, LostCityTerrain16 terrain) {
|
||||
String label = label(diameter, seed);
|
||||
helper.assertTrue(result.surface().districts().size() == 1, label + " must contain exactly one starter town");
|
||||
var district = result.surface().districts().getFirst();
|
||||
helper.assertTrue(district.maxX() - district.minX() + 1 <= 71 && district.maxZ() - district.minZ() + 1 <= 71,
|
||||
label + " escapes the compact starter envelope");
|
||||
Map<Long, Street> streets = new HashMap<>();
|
||||
Set<Long> written = new HashSet<>();
|
||||
var kinds = new TreeMap<String, Integer>();
|
||||
int lots = 0, buildings = 0;
|
||||
for (var parcel : district.parcels()) {
|
||||
kinds.merge(parcel.kind().name(), 1, Integer::sum);
|
||||
boolean street = parcel.kind() == LostCityPlan.Kind.STREET || parcel.kind() == LostCityPlan.Kind.BRIDGE;
|
||||
if (!street) lots++;
|
||||
if (parcel.kind().building()) buildings++;
|
||||
helper.assertTrue(parcel.minY() >= 0 && parcel.maxY() <= 383
|
||||
&& Math.abs((parcel.minX() >> 4) - district.startChunkX()) <= 7
|
||||
&& Math.abs((parcel.maxX() >> 4) - district.startChunkX()) <= 7
|
||||
&& Math.abs((parcel.minZ() >> 4) - district.startChunkZ()) <= 7
|
||||
&& Math.abs((parcel.maxZ() >> 4) - district.startChunkZ()) <= 7,
|
||||
label + " has a parcel outside native height/reference bounds");
|
||||
for (int z = parcel.minZ(); z <= parcel.maxZ(); z++) for (int x = parcel.minX(); x <= parcel.maxX(); x++) {
|
||||
helper.assertTrue(written.add(key(x, z)) && island.contains(x, z),
|
||||
label + " has overlapping parcels or an unowned column");
|
||||
int floor = parcel.surfaceY(x - parcel.minX(), z - parcel.minZ());
|
||||
if (result.reservation() == null) {
|
||||
var ground = terrain.ground(x, z);
|
||||
helper.assertTrue(ground.top() <= floor + LostCityPlanner16.MAX_CUT,
|
||||
label + " requires an unapproved natural terrain cut");
|
||||
if (parcel.kind() != LostCityPlan.Kind.BRIDGE) {
|
||||
int bottom = floor - parcel.foundationDepth();
|
||||
helper.assertTrue(ground.dry() && ground.top() >= bottom
|
||||
&& ground.top() - bottom + 1 <= ground.solidDepth(), label + " has an unsupported natural floor");
|
||||
}
|
||||
}
|
||||
if (street) {
|
||||
boolean railing = parcel.kind() == LostCityPlan.Kind.BRIDGE && (parcel.width() > parcel.depth()
|
||||
? z == parcel.minZ() || z == parcel.maxZ() : x == parcel.minX() || x == parcel.maxX());
|
||||
streets.put(key(x, z), new Street(x, z, floor, parcel, !railing));
|
||||
}
|
||||
}
|
||||
}
|
||||
helper.assertTrue(lots >= 3 && buildings >= (lots * 3 + 4) / 5, label + " is not a populated starter town");
|
||||
Set<Long> reached = walk(helper, label, streets);
|
||||
for (var parcel : district.parcels()) if (parcel.kind() != LostCityPlan.Kind.STREET && parcel.kind() != LostCityPlan.Kind.BRIDGE) {
|
||||
helper.assertTrue(parcel.facing() == 0, label + " rotates its entrance away from its certified road");
|
||||
for (int dx = 7; dx <= 9; dx++) {
|
||||
long threshold = key(parcel.minX() + dx, parcel.minZ() - 1);
|
||||
var road = streets.get(threshold);
|
||||
helper.assertTrue(road != null && road.walkable() && road.y() == parcel.floorY() && reached.contains(threshold),
|
||||
label + " has an isolated or obstructed entrance");
|
||||
}
|
||||
for (int x = parcel.minX(); x <= parcel.maxX(); x++)
|
||||
helper.assertTrue(streets.containsKey(key(x, parcel.minZ() - 1)) && streets.containsKey(key(x, parcel.maxZ() + 1)),
|
||||
label + " leaves natural soil between facades and streets");
|
||||
for (int z = parcel.minZ(); z <= parcel.maxZ(); z++)
|
||||
helper.assertTrue(streets.containsKey(key(parcel.minX() - 1, z)) && streets.containsKey(key(parcel.maxX() + 1, z)),
|
||||
label + " lacks a side street beside an admitted lot");
|
||||
}
|
||||
for (var support : result.supports()) for (int z = support.minZ(); z <= support.maxZ(); z++)
|
||||
for (int x = support.minX(); x <= support.maxX(); x++) {
|
||||
var deck = streets.get(key(x, z));
|
||||
helper.assertTrue(deck != null && deck.parcel().kind() == LostCityPlan.Kind.BRIDGE && deck.y() == support.maxY() + 1,
|
||||
label + " has a pier that misses its bridge deck");
|
||||
helper.assertTrue(terrain.solid(x, support.minY(), z) && terrain.solid(x, support.minY() - 1, z),
|
||||
label + " has a viaduct pier without native rock at its foot");
|
||||
}
|
||||
var reservation = result.reservation();
|
||||
if (reservation != null) {
|
||||
helper.assertTrue(reservation.width() == 49 && reservation.depth() == 49 && lots == 4
|
||||
&& reservation.minX() == district.minX() && reservation.maxX() == district.maxX()
|
||||
&& reservation.minZ() == district.minZ() && reservation.maxZ() == district.maxZ(),
|
||||
label + " fallback is not the explicit compact four-lot reservation");
|
||||
for (var anchor : reservation.anchors()) for (int z = anchor.minZ(); z <= anchor.maxZ(); z++)
|
||||
for (int x = anchor.minX(); x <= anchor.maxX(); x++)
|
||||
helper.assertTrue(terrain.solid(x, anchor.minY(), z) && terrain.solid(x, anchor.minY() + 1, z),
|
||||
label + " fallback invents a two-block native rock footing");
|
||||
}
|
||||
Map<String, Object> report = new LinkedHashMap<>();
|
||||
report.put("generation", 16); report.put("diameter", diameter); report.put("seed", seed);
|
||||
report.put("districts", 1); report.put("lots", lots); report.put("buildings", buildings); report.put("kinds", kinds);
|
||||
report.put("bounds", List.of(district.minX(), district.minZ(), district.maxX(), district.maxZ()));
|
||||
report.put("start_chunk", List.of(district.startChunkX(), district.startChunkZ()));
|
||||
report.put("road_columns", streets.size()); report.put("walkable_road_columns", reached.size());
|
||||
report.put("connected_roads", true); report.put("flush_entrances", true);
|
||||
report.put("fallback", reservation != null); report.put("viaduct_piers", result.supports().size());
|
||||
report.put("foundation_anchors", reservation == null ? 0 : reservation.anchors().size());
|
||||
report.put("passed", true);
|
||||
return report;
|
||||
}
|
||||
|
||||
private static Set<Long> walk(GameTestHelper helper, String label, Map<Long, Street> streets) {
|
||||
Set<Long> walkable = new HashSet<>();
|
||||
streets.forEach((position, street) -> { if (street.walkable()) walkable.add(position); });
|
||||
helper.assertTrue(!walkable.isEmpty(), label + " has no walkable street");
|
||||
Set<Long> reached = new HashSet<>(); var queue = new ArrayDeque<Long>();
|
||||
long start = walkable.iterator().next(); reached.add(start); queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
var current = streets.get(queue.removeFirst());
|
||||
for (int[] delta : new int[][] {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}) {
|
||||
long position = key(current.x() + delta[0], current.z() + delta[1]);
|
||||
var next = streets.get(position);
|
||||
if (next == null || !next.walkable()) continue;
|
||||
helper.assertTrue(Math.abs(current.y() - next.y()) <= 1, label + " contains a street cliff");
|
||||
if (current.parcel() != next.parcel()) helper.assertTrue(current.y() == next.y(),
|
||||
label + " requires an unpainted stair between two independent pieces");
|
||||
if (reached.add(position)) queue.addLast(position);
|
||||
}
|
||||
}
|
||||
helper.assertTrue(reached.equals(walkable), label + " contains disconnected streets");
|
||||
return reached;
|
||||
}
|
||||
private record Street(int x, int z, int y, LostCityPlan.Parcel parcel, boolean walkable) {}
|
||||
private static long key(int x, int z) { return ((long) x << 32) ^ (z & 0xffffffffL); }
|
||||
private static String label(int diameter, long seed) { return "Native city size=" + diameter + " seed=" + seed; }
|
||||
}
|
||||
+1022
File diff suppressed because it is too large
Load Diff
+1034
File diff suppressed because it is too large
Load Diff
+1083
File diff suppressed because it is too large
Load Diff
+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); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.block.FenceBlock;
|
||||
import net.minecraft.world.level.block.IronBarsBlock;
|
||||
import net.minecraft.world.level.block.StairBlock;
|
||||
import net.minecraft.world.level.block.WallBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
|
||||
/** Independent oracle: actual native neighbor-update rules, with datapack tags loaded by the test server. */
|
||||
public final class NativeConnections20 {
|
||||
private NativeConnections20() {}
|
||||
public record Stats(int panes, int fences, int walls, int stairs, int nativeOracles, int externalNeighbors,
|
||||
int crossChunkPaneLinks, int settledExternalStates, int unsettledExternalStates) {
|
||||
public Stats add(Stats other) { return new Stats(panes + other.panes, fences + other.fences, walls + other.walls,
|
||||
stairs + other.stairs, nativeOracles + other.nativeOracles, externalNeighbors + other.externalNeighbors,
|
||||
crossChunkPaneLinks + other.crossChunkPaneLinks, settledExternalStates + other.settledExternalStates,
|
||||
unsettledExternalStates + other.unsettledExternalStates); }
|
||||
public static Stats empty() { return new Stats(0, 0, 0, 0, 0, 0, 0, 0, 0); }
|
||||
}
|
||||
public static Stats verify(GameTestHelper helper, Map<BlockPos, BlockState> geometry, Set<BlockPos> edits) {
|
||||
var level = helper.getLevel(); int panes = 0, fences = 0, walls = 0, stairs = 0, oracles = 0, exterior = 0, cross = 0, settled = 0, unsettled = 0;
|
||||
for (var entry : geometry.entrySet()) {
|
||||
var pos = entry.getKey(); var wanted = entry.getValue(); if (edits.contains(pos)) continue;
|
||||
boolean pane = wanted.getBlock() instanceof IronBarsBlock, fence = wanted.getBlock() instanceof FenceBlock;
|
||||
boolean wall = wanted.getBlock() instanceof WallBlock, stair = wanted.getBlock() instanceof StairBlock;
|
||||
if (!pane && !fence && !wall && !stair) continue;
|
||||
if (pane) panes++; if (fence) fences++; if (wall) walls++; if (stair) stairs++;
|
||||
var actual = level.getBlockState(pos);
|
||||
helper.assertTrue(sameNonConnectionState(actual, wanted),
|
||||
"Material, orientation, half or waterlogging changed at " + pos + ": expected " + wanted + " got " + actual);
|
||||
boolean knownNeighborhood = true;
|
||||
for (var side : Direction.values()) {
|
||||
var at = pos.relative(side); var expectedNeighbor = geometry.get(at); var actualNeighbor = level.getBlockState(at);
|
||||
// A changed neighbor's shape can propagate a legitimate exterior connection, e.g. stacked walls.
|
||||
if (edits.contains(at) || (expectedNeighbor == null ? !actualNeighbor.isAir()
|
||||
: !expectedNeighbor.equals(actualNeighbor))) knownNeighborhood = false;
|
||||
}
|
||||
var updated = actual;
|
||||
for (var side : Direction.values()) {
|
||||
var at = pos.relative(side);
|
||||
updated = updated.updateShape(level, level, pos, side, at, level.getBlockState(at), RandomSource.create(20));
|
||||
}
|
||||
if (knownNeighborhood) {
|
||||
helper.assertTrue(actual.equals(wanted), "Native detailed state differs inside known geometry at " + pos
|
||||
+ ": expected " + wanted + " got " + actual);
|
||||
helper.assertTrue(updated.equals(actual), "Vanilla neighbor rules disagree with generation20 connections at " + pos
|
||||
+ ": saved " + actual + " native " + updated);
|
||||
oracles++;
|
||||
} else {
|
||||
exterior++;
|
||||
if (updated.equals(actual)) { settled++; oracles++; }
|
||||
else {
|
||||
// A fresh structure has not necessarily received an update from untouched surrounding rock.
|
||||
// Permit only its exact planned state until that update; a partially/wrongly changed state fails.
|
||||
helper.assertTrue(actual.equals(wanted) && sameNonConnectionState(updated, wanted),
|
||||
"Exterior connection is neither the untouched plan nor a vanilla fixed point at " + pos
|
||||
+ ": plan " + wanted + " saved " + actual + " native " + updated);
|
||||
unsettled++;
|
||||
}
|
||||
}
|
||||
if (pane) for (var side : new Direction[] {Direction.EAST, Direction.SOUTH}) {
|
||||
var at = pos.relative(side); var neighbor = level.getBlockState(at);
|
||||
if (neighbor.getBlock() instanceof IronBarsBlock && (pos.getX() >> 4 != at.getX() >> 4 || pos.getZ() >> 4 != at.getZ() >> 4)) {
|
||||
var property = side == Direction.EAST ? BlockStateProperties.EAST : BlockStateProperties.SOUTH;
|
||||
helper.assertTrue(actual.getValue(property), "Pane connection disappeared exactly at a native chunk boundary: " + pos);
|
||||
cross++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Stats(panes, fences, walls, stairs, oracles, exterior, cross, settled, unsettled);
|
||||
}
|
||||
/** Normalize only geometry-derived properties. Everything else, including fluids and facing, stays exact. */
|
||||
private static boolean sameNonConnectionState(BlockState actual, BlockState wanted) {
|
||||
if (!actual.is(wanted.getBlock())) return false;
|
||||
if (wanted.getBlock() instanceof IronBarsBlock || wanted.getBlock() instanceof FenceBlock) {
|
||||
for (var property : new net.minecraft.world.level.block.state.properties.BooleanProperty[] {
|
||||
BlockStateProperties.NORTH, BlockStateProperties.EAST, BlockStateProperties.SOUTH, BlockStateProperties.WEST})
|
||||
actual = actual.setValue(property, wanted.getValue(property));
|
||||
} else if (wanted.getBlock() instanceof WallBlock) {
|
||||
for (var property : WallBlock.PROPERTY_BY_DIRECTION.values()) actual = actual.setValue(property, wanted.getValue(property));
|
||||
actual = actual.setValue(WallBlock.UP, wanted.getValue(WallBlock.UP));
|
||||
} else if (wanted.getBlock() instanceof StairBlock) actual = actual.setValue(StairBlock.SHAPE, wanted.getValue(StairBlock.SHAPE));
|
||||
return actual.equals(wanted);
|
||||
}
|
||||
|
||||
}
|
||||
+66
-20
@@ -37,9 +37,11 @@ public final class PopulationDiagnostics {
|
||||
|
||||
static void verifyLegacyIsolation(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
var generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
|
||||
var binding = binding(level);
|
||||
var generator = binding.generator();
|
||||
var capacity = IslandCapacity.forGenerator(generator);
|
||||
helper.assertTrue(capacity != null && capacity.players() == Integer.getInteger("sanctuary.test.players", 10),
|
||||
helper.assertTrue(capacity != null && capacity.players() == (level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator unified
|
||||
? unified.capacity().players() : Integer.getInteger("sanctuary.test.players", 10)),
|
||||
"The engine must create the requested production world preset");
|
||||
helper.assertFalse(HydrologyRuntime.enabled(generator) || NaturalHydrologyRuntime.enabled(generator)
|
||||
|| LayeredHydrologyRuntime.enabled(generator) || WoodlandHydrologyRuntime.enabled(generator)
|
||||
@@ -56,24 +58,34 @@ public final class PopulationDiagnostics {
|
||||
helper.assertFalse(new PopulationDecorationsFeature().place(level, old, RandomSource.create(0), BlockPos.ZERO),
|
||||
"New cave decorations must refuse older saved worlds");
|
||||
}
|
||||
helper.assertTrue(generator.getBiomeSource() instanceof PopulationIslandBiomeSource,
|
||||
"The biome palette must use the new versioned resource family");
|
||||
helper.assertTrue(generator.getBiomeSource().possibleBiomes().size() == 10
|
||||
&& generator.getBiomeSource().possibleBiomes().stream().allMatch(b -> b.getRegisteredName().startsWith("sanctuary:population_")),
|
||||
"All ten layers of the ecology must remain available at each capacity");
|
||||
if (level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator) {
|
||||
helper.assertTrue(generator.getBiomeSource() instanceof SanctuaryBiomeSource
|
||||
&& generator.getBiomeSource().possibleBiomes().size() == (((SanctuaryChunkGenerator) level.getChunkSource().getGenerator()).source().citiesEnabled() ? 45 : 44)
|
||||
&& generator.getBiomeSource().possibleBiomes().stream().map(b -> b.getRegisteredName()).collect(java.util.stream.Collectors.toSet()).containsAll(SanctuaryBiomeSource.REQUIRED_BIOMES),
|
||||
"The unified generator retains all 44 historical biome holders, with Lost City added in city-enabled generations");
|
||||
helper.assertTrue(binding.random() != level.getChunkSource().randomState(),
|
||||
"The unified starter must use its real terrain state, never the expansion host fallback");
|
||||
} else {
|
||||
helper.assertTrue(generator.getBiomeSource() instanceof PopulationIslandBiomeSource,
|
||||
"The biome palette must use the new versioned resource family");
|
||||
helper.assertTrue(generator.getBiomeSource().possibleBiomes().size() == 10
|
||||
&& generator.getBiomeSource().possibleBiomes().stream().allMatch(b -> b.getRegisteredName().startsWith("sanctuary:population_")),
|
||||
"All ten layers of the ecology must remain available at each capacity");
|
||||
}
|
||||
}
|
||||
|
||||
static void verifyDensity(GameTestHelper helper) throws IOException {
|
||||
var level = helper.getLevel();
|
||||
var generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
|
||||
var binding = binding(level);
|
||||
var generator = binding.generator();
|
||||
var capacity = IslandCapacity.forGenerator(generator);
|
||||
var density = level.registryAccess().lookupOrThrow(Registries.DENSITY_FUNCTION).getOrThrow(
|
||||
ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("population_" + capacity.players()))).value();
|
||||
helper.assertTrue(density instanceof PopulationIslandDensity, "Inspect the actual compiled population density");
|
||||
var island = (PopulationIslandDensity) density;
|
||||
helper.assertTrue(island.capacity() == capacity, "Noise settings and density must agree on saved capacity");
|
||||
var random = level.getChunkSource().randomState();
|
||||
var replay = RandomState.create(level.registryAccess().lookupOrThrow(Registries.NOISE), level.getSeed(), generator.generatorSettings().value());
|
||||
var random = binding.random();
|
||||
var replay = RandomState.create(level.registryAccess().lookupOrThrow(Registries.NOISE), random.seed(), generator.generatorSettings().value());
|
||||
int step = Math.max(8, (int) Math.ceil(capacity.terrainLimit() / 32.0));
|
||||
int extent = (int) Math.ceil(capacity.terrainLimit() / (double) step) * step;
|
||||
int solidColumns = 0, outerColumns = 0, emptyColumns = 0;
|
||||
@@ -132,7 +144,7 @@ public final class PopulationDiagnostics {
|
||||
|
||||
static void verifyExterior(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
var capacity = IslandCapacity.forGenerator(level.getChunkSource().getGenerator());
|
||||
var capacity = IslandCapacity.forGenerator(binding(level).generator());
|
||||
int edge = Math.floorDiv(capacity.terrainLimit() + 48, 16) + 1;
|
||||
for (int[] point : new int[][]{{edge,0},{-edge-1,0},{0,edge},{0,-edge-1},{256,0},{-257,0}}) {
|
||||
var chunk = full(helper, level, point[0], point[1]);
|
||||
@@ -168,6 +180,7 @@ public final class PopulationDiagnostics {
|
||||
final GameTestHelper helper;
|
||||
final ServerLevel level;
|
||||
final NoiseBasedChunkGenerator generator;
|
||||
final RandomState random;
|
||||
final IslandCapacity capacity;
|
||||
final List<PopulationHydrologyRuntime.RegionPlan> regions = new ArrayList<>();
|
||||
final Map<Long, LevelChunk> chunks = new TreeMap<>();
|
||||
@@ -190,13 +203,37 @@ public final class PopulationDiagnostics {
|
||||
|
||||
Context(GameTestHelper helper) {
|
||||
this.helper = helper; level = helper.getLevel();
|
||||
generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
|
||||
var binding = binding(level);
|
||||
generator = binding.generator();
|
||||
random = binding.random();
|
||||
capacity = IslandCapacity.forGenerator(generator);
|
||||
}
|
||||
|
||||
void prepare() throws IOException {
|
||||
helper.assertTrue(regionsAtInitialSpawn > 0 && regionsAtInitialSpawn <= 4,
|
||||
"Initial spawn must plan only nearby regions, not materialize the large island: " + regionsAtInitialSpawn);
|
||||
boolean heritage = level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator sanctuary
|
||||
&& (sanctuary.rootGeneration() == 22 || (sanctuary.rootGeneration() == 19 || sanctuary.rootGeneration() == 20 || sanctuary.rootGeneration() == 21) && sanctuary.experimentalStructures());
|
||||
// Generation19 explicitly prospects destinations across the root island before publishing them.
|
||||
// A consulted hydrology region is a bounded immutable plan, not a loaded/generated chunk.
|
||||
// Historical generators retain their original near-spawn contract.
|
||||
int regionLimit = 4;
|
||||
if (heritage) {
|
||||
int extent = capacity.terrainLimit();
|
||||
var first = capacity.regionAt(-extent, -extent); var last = capacity.regionAt(extent, extent);
|
||||
regionLimit = 0;
|
||||
for (int x = first.x(); x <= last.x(); x++) for (int z = first.z(); z <= last.z(); z++) {
|
||||
var region = new IslandCapacity.Region(x, z);
|
||||
double nearestX = Math.max(0, Math.max(region.minX(), -region.maxX()));
|
||||
double nearestZ = Math.max(0, Math.max(region.minZ(), -region.maxZ()));
|
||||
if (Math.hypot(nearestX, nearestZ) < extent) regionLimit++;
|
||||
}
|
||||
helper.assertTrue(regionLimit <= 9, "Heritage starter hydrology must fit within nine local regions");
|
||||
var plan = ((SanctuaryChunkGenerator) level.getChunkSource().getGenerator()).heritagePlan19();
|
||||
helper.assertTrue(plan.columns() <= fr.koka.sanctuary.worldgen.heritage.HeritagePlan19.MAX_COLUMNS
|
||||
&& plan.samples() <= fr.koka.sanctuary.worldgen.heritage.HeritagePlan19.MAX_SAMPLES,
|
||||
"Island-wide heritage prospecting exceeded its independent column/probe budget");
|
||||
}
|
||||
helper.assertTrue(regionsAtInitialSpawn > 0 && regionsAtInitialSpawn <= regionLimit,
|
||||
"Initial hydrology exceeded its geographic planning scope (limit " + regionLimit + "): " + regionsAtInitialSpawn);
|
||||
List<IslandCapacity.Region> targets = new ArrayList<>();
|
||||
targets.add(new IslandCapacity.Region(0,0));
|
||||
if (capacity.players() == 20) targets.add(new IslandCapacity.Region(1,0));
|
||||
@@ -207,7 +244,7 @@ public final class PopulationDiagnostics {
|
||||
Set<Long> requested = new TreeSet<>();
|
||||
for (var target : targets) {
|
||||
SanctuaryMod.LOGGER.info("Capacity {}: inspecting hydrology in region {}", capacity.players(), target);
|
||||
var region = PopulationHydrologyRuntime.regionPlan(generator, level.getChunkSource().randomState(), target);
|
||||
var region = PopulationHydrologyRuntime.regionPlan(generator, random, target);
|
||||
regions.add(region);
|
||||
var plan = region.water();
|
||||
checkOwnership(region);
|
||||
@@ -257,7 +294,7 @@ public final class PopulationDiagnostics {
|
||||
for (int dx = -144; dx <= 144; dx += 48) for (int dz = -144; dz <= 144; dz += 48) {
|
||||
int x = ox+dx, z=oz+dz;
|
||||
int top = generator.getBaseHeight(x,z,net.minecraft.world.level.levelgen.Heightmap.Types.WORLD_SURFACE,
|
||||
level,level.getChunkSource().randomState());
|
||||
level,random);
|
||||
if (top > highest) { highest=top; bestX=x; bestZ=z; }
|
||||
}
|
||||
helper.assertTrue(highest > 48, "Every inspected region must contain real natural terrain");
|
||||
@@ -294,7 +331,7 @@ public final class PopulationDiagnostics {
|
||||
"Hydrology must never be truncated at or written across region ownership: "+cell);
|
||||
helper.assertTrue(ids.contains(cell.featureId()),"Each retained cell needs its complete owning feature");
|
||||
for(int y=cell.bedY()-cell.sedimentDepth()-1;y<=cell.bedY();y++)
|
||||
helper.assertTrue(level.getChunkSource().randomState().sampleBlockValueUncached(
|
||||
helper.assertTrue(random.sampleBlockValueUncached(
|
||||
generator.generatorSettings().value().noiseRouter().finalDensity(),cell.x(),y,cell.z())>0,
|
||||
"Regional translation must retain the real natural support below sediment: "+cell);
|
||||
}
|
||||
@@ -388,7 +425,7 @@ public final class PopulationDiagnostics {
|
||||
}
|
||||
|
||||
float natural(BlockPos p) {
|
||||
return level.getChunkSource().randomState().sampleBlockValueUncached(
|
||||
return random.sampleBlockValueUncached(
|
||||
generator.generatorSettings().value().noiseRouter().finalDensity(), p.getX(), p.getY(), p.getZ());
|
||||
}
|
||||
|
||||
@@ -428,7 +465,7 @@ public final class PopulationDiagnostics {
|
||||
helper.assertTrue(natural(p) > 0 && actual.isCollisionShapeFullBlock(level, p),
|
||||
"Every sediment layer and its two natural supports must remain full after generation and ticks: " + p + "=" + actual);
|
||||
if (d >= cell.sedimentDepth()) continue;
|
||||
BlockState expected = PopulationMaterials.sedimentBlock(cell, level.getSeed(), d);
|
||||
BlockState expected = PopulationMaterials.sedimentBlock(cell, random.seed(), d);
|
||||
boolean ore = actual.is(Blocks.COAL_ORE) || actual.is(Blocks.DEEPSLATE_COAL_ORE)
|
||||
|| actual.is(Blocks.IRON_ORE) || actual.is(Blocks.DEEPSLATE_IRON_ORE)
|
||||
|| actual.is(Blocks.COPPER_ORE) || actual.is(Blocks.DEEPSLATE_COPPER_ORE)
|
||||
@@ -575,10 +612,15 @@ public final class PopulationDiagnostics {
|
||||
Files.createDirectories(output.getParent());
|
||||
var report=new LinkedHashMap<String,Object>();
|
||||
report.put("players",capacity.players());report.put("seed",level.getSeed());
|
||||
report.put("terrain_seed", random.seed());
|
||||
if (level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator unified) {
|
||||
report.put("generation", 13); report.put("diameter", unified.initialDiameter());
|
||||
report.put("rejected_decoration_writes", unified.rejectedDecorationWrites());
|
||||
}
|
||||
report.put("mod_version",FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow().getMetadata().getVersion().getFriendlyString());
|
||||
report.put("settings",generator.generatorSettings().getRegisteredName());
|
||||
report.put("regions_at_initial_spawn",regionsAtInitialSpawn);
|
||||
report.put("regions_cached_after_inspection",PopulationHydrologyRuntime.cachedRegionCount(level.getChunkSource().randomState()));
|
||||
report.put("regions_cached_after_inspection",PopulationHydrologyRuntime.cachedRegionCount(random));
|
||||
report.put("regions",regionReports);report.put("inspected_full_chunks",chunks.size());
|
||||
report.put("retained_water_blocks",expectedWater.size());report.put("tested_springs",springs.size());
|
||||
report.put("tested_spills",spills.size());report.put("fluid_ticks",elapsed);
|
||||
@@ -597,6 +639,10 @@ public final class PopulationDiagnostics {
|
||||
}
|
||||
}
|
||||
|
||||
private static PopulationGenerationContext.Binding binding(ServerLevel level) {
|
||||
return Objects.requireNonNull(PopulationGenerationContext.of(level), "This diagnostic requires Population or unified Sanctuary generation");
|
||||
}
|
||||
|
||||
private static String json(Object value) {return new GsonBuilder().setPrettyPrinting().create().toJson(value)+"\n";}
|
||||
private static BlockPos pos(PopulationHydrology.Position p) {return new BlockPos(p.x(),p.y(),p.z());}
|
||||
private static BlockPos lavaPos(PopulationLavaDeposit.Position p) {return new BlockPos(p.x(),p.y(),p.z());}
|
||||
|
||||
+5
-5
@@ -34,12 +34,12 @@ final class PopulationPersistenceDiagnostics {
|
||||
var presets = access.lookupOrThrow(Registries.WORLD_PRESET);
|
||||
var standard = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET,
|
||||
SanctuaryMod.id("sanctuary"))).value();
|
||||
helper.assertTrue(IslandCapacity.forGenerator(standard.createWorldDimensions().overworld()) == IslandCapacity.TEN,
|
||||
"The public Sanctuary preset defaults to the new large geometry");
|
||||
helper.assertTrue(standard.createWorldDimensions().overworld() instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator unified && unified.initialDiameter() == 724,
|
||||
"The public Sanctuary preset defaults to the unified medium geometry");
|
||||
var visible = presets.getOrThrow(WorldPresetTags.NORMAL).stream().map(holder -> holder.getRegisteredName())
|
||||
.filter(name -> name.startsWith("sanctuary:")).collect(Collectors.toSet());
|
||||
helper.assertTrue(visible.equals(Set.of("sanctuary:sanctuary", "sanctuary:sanctuary_5", "sanctuary:expansion_lab")),
|
||||
"World creation must preserve small and large and add the separate expansion laboratory");
|
||||
helper.assertTrue(visible.equals(Set.of("sanctuary:sanctuary")),
|
||||
"Only Sanctuary is public; saved historical presets remain registered");
|
||||
helper.assertTrue(presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("expansion_lab")))
|
||||
.value().createWorldDimensions().overworld() instanceof fr.koka.sanctuary.expansion.ExpansionChunkGenerator,
|
||||
"The laboratory must use its own codec, without changing the existing presets");
|
||||
@@ -55,7 +55,7 @@ final class PopulationPersistenceDiagnostics {
|
||||
Files.createDirectories(testRoot);
|
||||
LevelStorageSource storage = LevelStorageSource.createDefault(testRoot);
|
||||
for (int players : new int[]{5, 10, 20, 100}) {
|
||||
String presetId = players == 10 ? "sanctuary" : "sanctuary_" + players;
|
||||
String presetId = "sanctuary_" + players;
|
||||
WorldDimensions dimensions = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET,
|
||||
SanctuaryMod.id(presetId))).value().createWorldDimensions();
|
||||
helper.assertTrue(IslandCapacity.forGenerator(dimensions.overworld()) == IslandCapacity.fromPlayers(players),
|
||||
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
|
||||
import fr.koka.sanctuary.worldgen.ruins.RuinsPiece17;
|
||||
import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17;
|
||||
import fr.koka.sanctuary.worldgen.ruins.RuinsRenderer17;
|
||||
import fr.koka.sanctuary.worldgen.ruins.RuinsStructures17;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Predicate;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
|
||||
/** Opt-in native proof confined to the new generation17 starter island in a disposable development world. */
|
||||
public final class Ruins17WorldGameTests {
|
||||
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
|
||||
|
||||
@GameTest(maxTicks = 12000)
|
||||
public void forgottenNetworksPersistNatively(GameTestHelper helper) {
|
||||
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
|
||||
"The forgotten-networks proof requires a Sanctuary generator");
|
||||
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
helper.assertTrue(generator.rootGeneration() == 17, "Use a fresh generation17 test world; do not convert old saves");
|
||||
new Scenario(helper, generator, Boolean.getBoolean("sanctuary.test.expansionReload")).advance();
|
||||
}
|
||||
|
||||
private static final class Scenario {
|
||||
final GameTestHelper helper;
|
||||
final ServerLevel level;
|
||||
final SanctuaryChunkGenerator generator;
|
||||
final RuinsPlan17 plan;
|
||||
final boolean reload;
|
||||
final Map<String, Object> report = new LinkedHashMap<>();
|
||||
final List<ChunkPos> chunks = new ArrayList<>();
|
||||
final List<StructureStart> starts = new ArrayList<>();
|
||||
JsonObject previous;
|
||||
BlockPos gold;
|
||||
int nextChunk;
|
||||
boolean prepared;
|
||||
|
||||
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator, boolean reload) {
|
||||
this.helper = helper; this.level = helper.getLevel(); this.generator = generator; this.reload = reload;
|
||||
this.plan = generator.ruinsPlan17();
|
||||
report.put("seed", level.getSeed()); report.put("size", generator.initialDiameter());
|
||||
report.put("process_id", ProcessHandle.current().pid()); report.put("world_directory", world(level).toString());
|
||||
report.put("experimental_structures", generator.experimentalStructures());
|
||||
report.put("structures_enabled", level.structureManager().shouldGenerateStructures());
|
||||
report.put("scope", "Native starts, references, room blocks, player-sized routes and unopened loot on the starter island only. "
|
||||
+ "No expansion is created and no personal save is opened. A single outer-wall gold edit in this disposable world "
|
||||
+ "is saved and checked in a second JVM. Open hatches are restored after traversal; no free-fluid simulation is claimed.");
|
||||
}
|
||||
void advance() {
|
||||
try {
|
||||
if (!prepared) {
|
||||
prepare(); prepared = true;
|
||||
if (plan.isEmpty()) { finishDisabled(); return; }
|
||||
}
|
||||
if (nextChunk < chunks.size()) {
|
||||
var pos = chunks.get(nextChunk++);
|
||||
var loaded = level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
|
||||
helper.assertTrue(loaded instanceof LevelChunk, "Every room footprint must reach native FULL: " + pos);
|
||||
if (nextChunk % 10 == 0) SanctuaryMod.LOGGER.info("Forgotten networks17: loaded {}/{} footprint chunks", nextChunk, chunks.size());
|
||||
helper.runAtTickTime(helper.getTick() + 1, this::advance); return;
|
||||
}
|
||||
verifyPlaced();
|
||||
report.put("passed", true);
|
||||
if (!reload) {
|
||||
helper.assertTrue(gold != null && level.getBlockState(gold).isCollisionShapeFullBlock(level, gold),
|
||||
"Gameplay witness must replace a solid outer wall away from every doorway");
|
||||
level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3);
|
||||
report.put("gold_witness", coords(gold));
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native structures, deferred loot and the gold edit");
|
||||
report.put("world_flushed_before_shutdown", true);
|
||||
} else {
|
||||
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Reload replaced the existing wall edit");
|
||||
report.put("gold_witness", coords(gold)); report.put("gold_witness_preserved", true);
|
||||
}
|
||||
write(level, report, reload); helper.succeed();
|
||||
} catch (Exception failure) {
|
||||
report.put("passed", false); report.put("failure", failure.toString());
|
||||
try { write(level, report, reload); } catch (IOException ignored) { }
|
||||
throw new IllegalStateException("Forgotten networks17 native " + (reload ? "reload" : "creation") + " failed", failure);
|
||||
}
|
||||
}
|
||||
void prepare() throws IOException {
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
|
||||
var encoded = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow();
|
||||
var decoded = ChunkGenerator.CODEC.parse(ops, encoded).getOrThrow();
|
||||
helper.assertTrue(decoded instanceof SanctuaryChunkGenerator copy && copy.rootGeneration() == 17
|
||||
&& copy.experimentalStructures() == generator.experimentalStructures(), "The generation17 codec must preserve its experimental switch");
|
||||
report.put("generator_codec", encoded); report.put("plan_sha256", planHash(plan)); report.put("planning_samples", plan.samples());
|
||||
helper.assertTrue(plan.samples() <= RuinsPlan17.MAX_SAMPLES, "Forgotten-network planning exceeded its budget");
|
||||
if (reload) {
|
||||
previous = JsonParser.parseString(Files.readString(proof(level))).getAsJsonObject();
|
||||
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(),
|
||||
"Reload requires the successful, flushed creation proof");
|
||||
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must run in a separate JVM");
|
||||
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed()
|
||||
&& previous.get("size").getAsInt() == generator.initialDiameter()
|
||||
&& previous.get("world_directory").getAsString().equals(world(level).toString()), "Reload used another world, seed or size");
|
||||
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("plan_sha256").getAsString().equals(planHash(plan)),
|
||||
"Saved switch or planned generation17 room identities changed on reload");
|
||||
if (previous.has("gold_witness")) gold = readPos(previous.getAsJsonArray("gold_witness"));
|
||||
}
|
||||
boolean enabled = generator.experimentalStructures() && level.structureManager().shouldGenerateStructures();
|
||||
if (!enabled) {
|
||||
helper.assertTrue(plan.isEmpty(), "Experimental=false or generateStructures=false must publish no forgotten network");
|
||||
return;
|
||||
}
|
||||
helper.assertTrue(!plan.isEmpty(), "Enabled reference island has no experimental network");
|
||||
var families = EnumSet.noneOf(RuinsPlan17.Family.class);
|
||||
plan.networks().forEach(n -> families.add(n.family()));
|
||||
report.put("families", families.stream().map(Enum::name).toList()); report.put("networks", plan.networks().size());
|
||||
if (level.getSeed() == 0) helper.assertTrue(families.equals(EnumSet.allOf(RuinsPlan17.Family.class)),
|
||||
"Seed0 reference must demonstrate all three experimental families; found " + families);
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
|
||||
var origins = new HashSet<Long>(); var uniqueChunks = new HashSet<ChunkPos>();
|
||||
for (var network : plan.networks()) {
|
||||
long origin = new ChunkPos(network.startChunkX(), network.startChunkZ()).pack();
|
||||
if (!origins.add(origin)) continue;
|
||||
var chunk = level.getChunk(network.startChunkX(), network.startChunkZ(), ChunkStatus.STRUCTURE_STARTS, true);
|
||||
var start = chunk.getStartForStructure(structure.value());
|
||||
helper.assertTrue(start != null && start.isValid(), "Accepted experimental network has no native start: " + network.id());
|
||||
starts.add(start);
|
||||
for (var piece : start.getPieces()) {
|
||||
helper.assertTrue(piece instanceof RuinsPiece17, "Forgotten network loaded a foreign or historical piece type");
|
||||
piece.getBoundingBox().intersectingChunks().forEach(uniqueChunks::add);
|
||||
}
|
||||
}
|
||||
helper.assertTrue(uniqueChunks.size() <= 225, "The bounded root-only proof unexpectedly covers more than225 chunks");
|
||||
chunks.addAll(uniqueChunks.stream().sorted(java.util.Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList());
|
||||
}
|
||||
void finishDisabled() throws IOException {
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
|
||||
var chunk = level.getChunk(0, 0, ChunkStatus.STRUCTURE_STARTS, true);
|
||||
var start = chunk.getStartForStructure(structure.value());
|
||||
helper.assertTrue(start == null || !start.isValid(), "Disabled experimental generation emitted a native start");
|
||||
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
|
||||
helper.assertTrue(located == null, "Disabled experimental structures must not be located or prepared");
|
||||
report.put("empty_plan_verified", true); report.put("passed", true);
|
||||
if (!reload) {
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save the disabled experiment setting");
|
||||
report.put("world_flushed_before_shutdown", true);
|
||||
}
|
||||
write(level, report, reload); helper.succeed();
|
||||
}
|
||||
void verifyPlaced() {
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
|
||||
var context = StructurePieceSerializationContext.fromLevel(level);
|
||||
var sites = new ArrayList<Map<String, Object>>();
|
||||
int blocks = 0, supports = 0, chests = 0, pieces = 0;
|
||||
for (var start : starts) {
|
||||
var origin = start.getChunkPos(); var saved = start.createTag(context, origin);
|
||||
var copy = StructureStart.loadStaticStart(context, saved, level.getSeed());
|
||||
helper.assertTrue(copy != null && copy.isValid() && copy.createTag(context, origin).equals(saved), "The native start changed on an NBT roundtrip");
|
||||
var expectedRooms = plan.networks().stream().filter(n -> n.startChunkX() == origin.x() && n.startChunkZ() == origin.z())
|
||||
.flatMap(n -> n.rooms().stream()).toList();
|
||||
var actualRooms = start.getPieces().stream().map(RuinsPiece17.class::cast).map(RuinsPiece17::room).toList();
|
||||
helper.assertTrue(actualRooms.size() == expectedRooms.size() && new HashSet<>(actualRooms).equals(new HashSet<>(expectedRooms)),
|
||||
"Native start did not persist every planned room exactly once");
|
||||
String nativeHash = hash(saved.toString());
|
||||
if (reload) {
|
||||
JsonObject prior = null;
|
||||
for (var value : previous.getAsJsonArray("starts")) {
|
||||
var site = value.getAsJsonObject(); var at = site.getAsJsonArray("start_chunk");
|
||||
if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = site;
|
||||
}
|
||||
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(nativeHash),
|
||||
"Reload altered native room parameters, supports or reference count");
|
||||
}
|
||||
var loot = new ArrayList<Map<String, Object>>();
|
||||
for (var piece : start.getPieces()) {
|
||||
var roomPiece = (RuinsPiece17) piece; var room = roomPiece.room(); pieces++;
|
||||
piece.getBoundingBox().intersectingChunks().forEach(at -> {
|
||||
var full = level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true);
|
||||
helper.assertTrue(full.getReferencesForStructure(structure.value()).contains(origin.pack()), "A saved FULL chunk lost the native structure reference");
|
||||
});
|
||||
var roomTag = roomPiece.createTag(context);
|
||||
var roundtrip = new RuinsPiece17(context, roomTag);
|
||||
helper.assertTrue(roundtrip.room().equals(room) && roundtrip.createTag(context).equals(roomTag), "The native room failed its exact saved-field roundtrip");
|
||||
var corrupt = roomTag.copy(); var payload = corrupt.getCompound("SanctuaryRoom").orElseThrow(); payload.putInt("Schema", 99);
|
||||
boolean rejected = false;
|
||||
try { new RuinsPiece17(context, corrupt); } catch (IllegalArgumentException expected) { rejected = true; }
|
||||
helper.assertTrue(rejected, "An unknown generation17 piece schema must be refused");
|
||||
var expected = rendered(room); blocks += expected.blocks().size();
|
||||
for (var entry : expected.blocks().entrySet()) {
|
||||
var at = entry.getKey(); var wanted = entry.getValue();
|
||||
helper.assertTrue(RuinsPiece17.bounds(room).isInside(at), "Renderer escaped its saved room/support bounds");
|
||||
if (reload && at.equals(gold)) continue;
|
||||
helper.assertTrue(level.getBlockState(at).is(wanted.getBlock()),
|
||||
"Native clipping or later decoration changed " + room.kind() + " at " + at + ": expected " + wanted + " got " + level.getBlockState(at));
|
||||
helper.assertTrue(!level.getBlockState(at).is(Blocks.SPAWNER), "Experimental architecture must not create a spawner");
|
||||
}
|
||||
for (var support : room.supports()) {
|
||||
supports++;
|
||||
var below = new BlockPos(support.x(), support.minY() - 1, support.z());
|
||||
helper.assertTrue(level.getBlockState(below).isSolid() && level.getFluidState(below).isEmpty(), "Room footing has lost its native rock anchor");
|
||||
}
|
||||
for (var chest : expected.chests()) {
|
||||
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Deferred native chest block entity is missing");
|
||||
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
|
||||
helper.assertTrue(chest.table().equals(entity.getLootTable()) && entity.getLootTableSeed() == chest.seed(),
|
||||
"Native chest loot must remain deferred with its exact table and seed");
|
||||
loot.add(Map.of("position", coords(chest.position()), "table", chest.table().identifier().toString(), "seed", chest.seed())); chests++;
|
||||
}
|
||||
if (!reload && gold == null && room.kind() != RuinsPlan17.Kind.ACCESS && room.kind() != RuinsPlan17.Kind.CORRIDOR)
|
||||
gold = new BlockPos(room.minX(), room.floorY() + 1, room.minZ() + 1);
|
||||
}
|
||||
sites.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", nativeHash,
|
||||
"pieces", start.getPieces().size(), "unopened_chests", loot));
|
||||
}
|
||||
var traversal = new ArrayList<Map<String, Object>>();
|
||||
for (var network : plan.networks()) traversal.add(verifyAccess(helper, network));
|
||||
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
|
||||
helper.assertTrue(located != null && located.getSecond().is(RuinsStructures17.KEY), "Native locate cannot find a saved forgotten network");
|
||||
helper.assertTrue(plan.networks().stream().flatMap(n -> n.rooms().stream()).filter(r -> r.kind() == RuinsPlan17.Kind.ACCESS)
|
||||
.anyMatch(r -> RuinsPiece17.locator(r).equals(located.getFirst())), "Locate must lead to a surface entrance rather than an underground floor");
|
||||
report.put("starts", sites); report.put("native_pieces", pieces); report.put("native_blocks_checked", blocks);
|
||||
report.put("full_chunks_checked", chunks.size()); report.put("rock_supports_checked", supports);
|
||||
report.put("unopened_deferred_chests", chests); report.put("native_routes", traversal); report.put("locate", coords(located.getFirst()));
|
||||
}
|
||||
}
|
||||
private record Chest(BlockPos position, ResourceKey<LootTable> table, long seed) {}
|
||||
private record Rendered(Map<BlockPos, BlockState> blocks, List<Chest> chests) {}
|
||||
private static Rendered rendered(RuinsPlan17.Room room) {
|
||||
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
|
||||
RuinsRenderer17.render(room, new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(at(room, x, y, z), state); }
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
|
||||
var position = at(room, x, y, z); blocks.put(position, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(position, table, RandomSource.create(seed).nextLong()));
|
||||
}
|
||||
});
|
||||
return new Rendered(blocks, chests);
|
||||
}
|
||||
private static Map<String, Object> verifyAccess(GameTestHelper helper, RuinsPlan17.Network network) {
|
||||
var level = helper.getLevel(); var access = network.rooms().stream().filter(r -> r.kind() == RuinsPlan17.Kind.ACCESS).findFirst().orElseThrow();
|
||||
var hatches = new HashMap<BlockPos, BlockState>();
|
||||
rendered(access).blocks().forEach((at, state) -> { if (state.is(Blocks.DARK_OAK_TRAPDOOR)) hatches.put(at, level.getBlockState(at)); });
|
||||
helper.assertTrue(hatches.size() == 1, "Each network must have one usable wooden surface hatch");
|
||||
hatches.forEach((at, state) -> {
|
||||
helper.assertTrue(state.is(Blocks.DARK_OAK_TRAPDOOR) && state.canSurvive(level, at), "Surface hatch is missing its native support");
|
||||
level.setBlock(at, state.setValue(BlockStateProperties.OPEN, true), 2);
|
||||
});
|
||||
try {
|
||||
Predicate<BlockPos> inside = pos -> network.rooms().stream().anyMatch(r -> r.contains(pos.getX(), pos.getY(), pos.getZ()));
|
||||
var start = RuinsPiece17.locator(access); var reached = walk(helper, start, inside);
|
||||
int targets = 0;
|
||||
for (var room : network.rooms()) for (var target : RuinsRenderer17.targets(room)) {
|
||||
var point = at(room, target.x(), target.y(), target.z());
|
||||
helper.assertTrue(reached.contains(point), "Native network " + network.id() + " cannot reach " + room.kind() + "/" + target.name() + " at " + point);
|
||||
targets++;
|
||||
}
|
||||
for (var hatch : hatches.keySet()) helper.assertTrue(reached.contains(hatch), "Surface approach cannot enter its ladder shaft");
|
||||
return Map.of("id", network.id(), "family", network.family().name(), "rooms", network.rooms().size(),
|
||||
"entrance", coords(start), "walkable_targets", targets, "standing_cells", reached.size());
|
||||
} finally { hatches.forEach((at, state) -> level.setBlock(at, state, 2)); }
|
||||
}
|
||||
private static Set<BlockPos> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside) {
|
||||
var level = helper.getLevel(); helper.assertTrue(inside.test(start) && standable(level, start), "Blocked native surface approach: " + start);
|
||||
var seen = new HashSet<BlockPos>(); var queue = new ArrayDeque<BlockPos>(); seen.add(start); queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
var pos = queue.removeFirst();
|
||||
for (var direction : HORIZONTAL) for (int dy = -1; dy <= 1; dy++) {
|
||||
var next = pos.relative(direction).offset(0, dy, 0);
|
||||
if (!inside.test(next) || dy > 0 && !clearBody(level, pos.above())) continue;
|
||||
if (standable(level, next) && seen.add(next)) queue.add(next);
|
||||
}
|
||||
for (int dy : new int[] {-1, 1}) {
|
||||
var next = pos.offset(0, dy, 0); if (!inside.test(next)) continue;
|
||||
if ((level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(next).is(Blocks.LADDER)
|
||||
|| level.getBlockState(pos.below()).is(Blocks.LADDER)) && standable(level, next) && seen.add(next)) queue.add(next);
|
||||
}
|
||||
helper.assertTrue(seen.size() <= 50_000, "Native traversal exceeded its room bounds");
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
private static boolean standable(ServerLevel level, BlockPos pos) {
|
||||
if (!clearBody(level, pos)) return false;
|
||||
if (level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(pos.below()).is(Blocks.LADDER)) return true;
|
||||
var below = pos.below();
|
||||
return level.getBlockState(below).getCollisionShape(level, below).toAabbs().stream()
|
||||
.anyMatch(b -> b.maxY >= .5 && b.minX <= .5 && b.maxX >= .5 && b.minZ <= .5 && b.maxZ >= .5);
|
||||
}
|
||||
private static boolean clearBody(ServerLevel level, BlockPos pos) {
|
||||
var body = new AABB(pos.getX() + .2, pos.getY(), pos.getZ() + .2, pos.getX() + .8, pos.getY() + 1.8, pos.getZ() + .8);
|
||||
for (var at : new BlockPos[] {pos, pos.above()}) {
|
||||
if (!level.getFluidState(at).isEmpty()) return false;
|
||||
for (var shape : level.getBlockState(at).getCollisionShape(level, at).toAabbs())
|
||||
if (shape.move(at.getX(), at.getY(), at.getZ()).intersects(body)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private static BlockPos at(RuinsPlan17.Room room, int x, int y, int z) { return new BlockPos(room.minX() + x, room.minY() + y, room.minZ() + z); }
|
||||
private static List<Integer> coords(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
|
||||
private static BlockPos readPos(com.google.gson.JsonArray value) { return new BlockPos(value.get(0).getAsInt(), value.get(1).getAsInt(), value.get(2).getAsInt()); }
|
||||
private static Path world(ServerLevel level) { return level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); }
|
||||
private static Path proof(ServerLevel level) { return world(level).resolve("ruins17-create-proof.json"); }
|
||||
private static String planHash(RuinsPlan17 plan) { return hash(plan.networks().toString()); }
|
||||
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
|
||||
var path = Path.of("diagnostics", "ruins17-" + (reload ? "reload" : "create") + ".json"); Files.createDirectories(path.getParent());
|
||||
var value = new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n";
|
||||
Files.writeString(path, value);
|
||||
if (!reload) Files.writeString(proof(level), value);
|
||||
}
|
||||
private static String hash(String value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.expansion.ExpansionIsland;
|
||||
import fr.koka.sanctuary.worldgen.PopulationGenerationContext;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import fr.koka.sanctuary.worldgen.city.*;
|
||||
import fr.koka.sanctuary.worldgen.heritage.*;
|
||||
import fr.koka.sanctuary.worldgen.transit.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
|
||||
/** Read-only admission audit, then native starts and reload in the disposable development world. */
|
||||
public final class Selective22WorldGameTests {
|
||||
@GameTest(maxTicks = 12000)
|
||||
public void naturalSitesAndRefusalsPersist(GameTestHelper helper) throws Exception {
|
||||
var level = helper.getLevel();
|
||||
var generator = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
|
||||
helper.assertTrue(generator.rootGeneration() == 22, "Use a fresh22 development world");
|
||||
var root = ExpansionIsland.origin(generator.initialDiameter(), level.getSeed(), 22);
|
||||
var city = generator.cityPlacement21(root);
|
||||
var heritage = generator.heritagePlan19();
|
||||
var transit = generator.transitPlan21();
|
||||
helper.assertTrue(city != null && city.reservation() == null && city.supports().isEmpty(), "No forced city envelope or rescue piers");
|
||||
var binding = Objects.requireNonNull(PopulationGenerationContext.of(level));
|
||||
var terrain = new HeritageTerrain21(root, binding.generator(), binding.random(), level, p -> false);
|
||||
int columns = 0;
|
||||
for (var district : city.surface().districts()) for (var parcel : district.parcels()) {
|
||||
for (int x = parcel.minX(); x <= parcel.maxX(); x++) for (int z = parcel.minZ(); z <= parcel.maxZ(); z++) {
|
||||
int floor = parcel.surfaceY(x - parcel.minX(), z - parcel.minZ());
|
||||
helper.assertTrue(Math.abs(floor - terrain.surfaceY(x, z)) <= 2, "City must fit natural relief"); columns++;
|
||||
}
|
||||
}
|
||||
// The three samplers have independent bounded caches, just like production planning.
|
||||
terrain = new HeritageTerrain21(root, binding.generator(), binding.random(), level, p -> false);
|
||||
for (var site : heritage.sites()) {
|
||||
helper.assertTrue(site.supports().isEmpty(), "No heritage rescue piers");
|
||||
for (int x = site.minX(); x <= site.maxX(); x++) for (int z = site.minZ(); z <= site.maxZ(); z++) {
|
||||
helper.assertTrue(Math.abs(site.floorY() - terrain.surfaceY(x, z)) <= 2, "Building cut/fill stays within two blocks");
|
||||
helper.assertTrue(terrain.solid(x, site.minY() - 1, z) && terrain.solid(x, site.minY() - 2, z), "Every foundation column rests on original rock"); columns++;
|
||||
}
|
||||
}
|
||||
helper.assertTrue(!transit.isEmpty(), "The reference island retains its core traversal");
|
||||
var hall = generator.undergroundHall22();
|
||||
helper.assertTrue(!hall.isEmpty() || generator.undergroundPlan16(root).networks().stream().flatMap(n -> n.modules().stream())
|
||||
.anyMatch(m -> m.kind() == LostCityUnderground16.Kind.EXPANSION_HALL), "Core hall exists with or without a surface town");
|
||||
for (var module : transit.modules()) for (var support : module.supports())
|
||||
helper.assertTrue(support.maxY() - support.minY() + 1 <= 16, "Core traversal retains bounded engineered supports");
|
||||
if (!generator.experimentalStructures()) helper.assertTrue(heritage.isEmpty() && generator.ruinsPlan17().isEmpty(), "OFF removes optional catalogue, not core hall and traversal");
|
||||
var gson = new GsonBuilder().setPrettyPrinting().create();
|
||||
var encoded = ChunkGenerator.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow();
|
||||
var geometry = gson.toJsonTree(Map.of("city", city.surface().districts(), "heritage_sites", heritage.sites(),
|
||||
"heritage_links", heritage.links(), "heritage_segments", heritage.segments(), "transit", transit.modules(), "independent_hall", hall.networks()));
|
||||
var report = new LinkedHashMap<String, Object>();
|
||||
report.put("seed", level.getSeed()); report.put("size", generator.initialDiameter());
|
||||
report.put("process_id", ProcessHandle.current().pid()); report.put("generator_codec", encoded); report.put("geometry", geometry);
|
||||
report.put("experimental", generator.experimentalStructures()); report.put("city_districts", city.surface().districts().size());
|
||||
report.put("heritage_sites", heritage.sites().size()); report.put("heritage_links", heritage.links().size()); report.put("transit_modules", transit.modules().size());
|
||||
report.put("independent_hall_networks", hall.networks().size());
|
||||
report.put("core_entries", transit.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.ACCESS).map(m -> List.of(m.centerX(), m.surfaceY() + 1, m.minZ() - 2)).toList());
|
||||
report.put("hall_entries", hall.networks().stream().map(n -> { var at = LostCityUndergroundStructures22.locator(n); return List.of(at.getX(), at.getY(), at.getZ()); }).toList());
|
||||
report.put("natural_columns_audited", columns); report.put("city_diagnostics", city.diagnostics());
|
||||
report.put("heritage_diagnostics", heritage.diagnostics()); report.put("transit_diagnostics", transit.diagnostic());
|
||||
var starts = new ArrayList<StructureStart>();
|
||||
var origins = new LinkedHashMap<ResourceKey<Structure>, Set<ChunkPos>>();
|
||||
origins.put(LostCityStructures.KEY, new HashSet<>()); origins.put(HeritageStructures19.KEY, new HashSet<>()); origins.put(TransitStructures21.KEY, new HashSet<>()); origins.put(LostCityUndergroundStructures22.KEY, new HashSet<>());
|
||||
city.surface().districts().forEach(d -> origins.get(LostCityStructures.KEY).add(new ChunkPos(d.startChunkX(), d.startChunkZ())));
|
||||
heritage.sites().forEach(s -> origins.get(HeritageStructures19.KEY).add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
|
||||
heritage.segments().forEach(s -> origins.get(HeritageStructures19.KEY).add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
|
||||
transit.modules().forEach(m -> origins.get(TransitStructures21.KEY).add(new ChunkPos(m.startChunkX(), m.startChunkZ())));
|
||||
hall.networks().forEach(n -> origins.get(LostCityUndergroundStructures22.KEY).add(new ChunkPos(n.startChunkX(), n.startChunkZ())));
|
||||
var chunks = new TreeSet<ChunkPos>(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z));
|
||||
var nativeTags = new TreeMap<String, String>();
|
||||
var context = StructurePieceSerializationContext.fromLevel(level);
|
||||
for (var entry : origins.entrySet()) for (var origin : entry.getValue()) {
|
||||
var type = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(entry.getKey()).value();
|
||||
var start = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(type);
|
||||
helper.assertTrue(start != null && start.isValid(), "Accepted geometry must have a native start");
|
||||
for (var piece : start.getPieces()) {
|
||||
helper.assertTrue(!(piece instanceof LostCityFoundationPiece16), "No artificial city foundation emitted");
|
||||
if (entry.getKey().equals(HeritageStructures19.KEY)) helper.assertTrue(piece instanceof HeritagePiece21, "Use published21 architecture");
|
||||
if (entry.getKey().equals(LostCityUndergroundStructures22.KEY)) helper.assertTrue(piece instanceof LostCityUndergroundPiece20, "Independent hall keeps published20 room geometry");
|
||||
if (entry.getKey().equals(TransitStructures21.KEY)) helper.assertTrue(piece instanceof TransitPiece21 || piece instanceof TransitPiece22, "Published21 tunnel geometry with sealed22 corners");
|
||||
piece.getBoundingBox().intersectingChunks().forEach(chunks::add);
|
||||
}
|
||||
var tag = start.createTag(context, origin);
|
||||
var loaded = StructureStart.loadStaticStart(context, tag, level.getSeed());
|
||||
helper.assertTrue(loaded != null && tag.equals(loaded.createTag(context, origin)), "Native start round trip");
|
||||
nativeTags.put(entry.getKey().identifier() + "/" + origin.x() + "/" + origin.z(), tag.toString()); starts.add(start);
|
||||
}
|
||||
if (chunks.isEmpty()) chunks.add(new ChunkPos(0, 0));
|
||||
report.put("native_starts", nativeTags); report.put("full_chunks", chunks.size());
|
||||
Path world = level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
|
||||
Path proof = world.resolve("selective22-create.json");
|
||||
boolean reload = Boolean.getBoolean("sanctuary.test.expansionReload");
|
||||
JsonObject previous = reload ? JsonParser.parseString(Files.readString(proof)).getAsJsonObject() : null;
|
||||
if (reload) {
|
||||
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload follows a successful separate JVM");
|
||||
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("geometry").equals(geometry), "Generation, options and accepted/refused placements survive reload");
|
||||
helper.assertTrue(previous.get("native_starts").equals(gson.toJsonTree(nativeTags)), "Native saved starts survive reload");
|
||||
}
|
||||
var iterator = chunks.iterator();
|
||||
Runnable finish = () -> {
|
||||
try {
|
||||
// Refresh already-generated transit chunks: the earlier short FULL tickets can expire
|
||||
// while the catalogue loads. The walk checks real native collision shapes, including turns.
|
||||
var transitChunks = new HashSet<ChunkPos>();
|
||||
transit.modules().forEach(m -> TransitPiece21.bounds(m).intersectingChunks().forEach(transitChunks::add));
|
||||
for (var pos : transitChunks) level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
|
||||
report.put("transit_native_walk", Transit21WorldGameTests.verifyAccess(helper, transit));
|
||||
report.put("hall_native_access", Underground22NativeChecks.verify(helper, hall.isEmpty() ? generator.undergroundPlan16(root) : hall));
|
||||
if (!heritage.sites().isEmpty()) {
|
||||
var site = heritage.sites().getFirst();
|
||||
var witness = new BlockPos(site.centerX(), site.floorY() - 1, site.centerZ());
|
||||
if (reload) helper.assertTrue(level.getBlockState(witness).is(Blocks.GOLD_BLOCK), "Personal edit in disposable test survives reload");
|
||||
else {
|
||||
helper.assertTrue(level.getBlockState(witness).isSolid(), "Accepted building foundation generated in real FULL chunks");
|
||||
level.setBlock(witness, Blocks.GOLD_BLOCK.defaultBlockState(), 3);
|
||||
}
|
||||
report.put("saved_edit", List.of(witness.getX(), witness.getY(), witness.getZ()));
|
||||
}
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native world before shutdown");
|
||||
report.put("passed", true);
|
||||
var directory = Path.of("diagnostics", "selective22-" + level.getSeed() + "-" + generator.initialDiameter()); Files.createDirectories(directory);
|
||||
var text = gson.toJson(report) + "\n"; Files.writeString(directory.resolve(reload ? "reload.json" : "create.json"), text);
|
||||
if (!reload) Files.writeString(proof, text);
|
||||
helper.succeed();
|
||||
} catch (Exception failure) { throw new IllegalStateException(failure); }
|
||||
};
|
||||
Runnable[] load = new Runnable[1];
|
||||
load[0] = () -> {
|
||||
if (!iterator.hasNext()) { finish.run(); return; }
|
||||
var pos = iterator.next(); level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
|
||||
helper.runAtTickTime(helper.getTick() + 1, () -> load[0].run());
|
||||
};
|
||||
load[0].run();
|
||||
}
|
||||
}
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
|
||||
import fr.koka.sanctuary.worldgen.transit.TransitPiece21;
|
||||
import fr.koka.sanctuary.worldgen.transit.TransitPlan21;
|
||||
import fr.koka.sanctuary.worldgen.transit.TransitRenderer21;
|
||||
import fr.koka.sanctuary.worldgen.transit.TransitStructures21;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.StairBlock;
|
||||
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
|
||||
/** Opt-in native proof confined to the new generation21 starter island in a disposable development world. */
|
||||
public final class Transit21WorldGameTests {
|
||||
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
|
||||
|
||||
@GameTest(maxTicks = 12000)
|
||||
public void industrialTransitPersistsNatively(GameTestHelper helper) {
|
||||
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
|
||||
"The industrial-transits proof requires a Sanctuary generator");
|
||||
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
helper.assertTrue(generator.rootGeneration() == 21, "Use a fresh generation21 test world; do not convert old saves");
|
||||
new Scenario(helper, generator, Boolean.getBoolean("sanctuary.test.expansionReload")).advance();
|
||||
}
|
||||
|
||||
private static final class Scenario {
|
||||
final GameTestHelper helper;
|
||||
final ServerLevel level;
|
||||
final SanctuaryChunkGenerator generator;
|
||||
final TransitPlan21 plan;
|
||||
final boolean reload;
|
||||
final Map<String, Object> report = new LinkedHashMap<>();
|
||||
final List<ChunkPos> chunks = new ArrayList<>();
|
||||
final List<StructureStart> starts = new ArrayList<>();
|
||||
final Map<BlockPos, BlockState> allGeometry = new HashMap<>();
|
||||
JsonObject previous;
|
||||
BlockPos gold;
|
||||
int nextChunk;
|
||||
boolean prepared;
|
||||
|
||||
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator, boolean reload) {
|
||||
this.helper = helper; this.level = helper.getLevel(); this.generator = generator; this.reload = reload;
|
||||
this.plan = generator.transitPlan21();
|
||||
report.put("seed", level.getSeed()); report.put("size", generator.initialDiameter());
|
||||
report.put("process_id", ProcessHandle.current().pid()); report.put("world_directory", world(level).toString());
|
||||
report.put("experimental_structures", generator.experimentalStructures());
|
||||
report.put("structures_enabled", level.structureManager().shouldGenerateStructures());
|
||||
report.put("planning_diagnostic", plan.diagnostic());
|
||||
report.put("scope", "Native starts, references, geometry, rock bearings, unopened loot and a player-sized stair walk between two surface gates. "
|
||||
+ "No expansion is created and no personal save is opened. A single outer-wall gold edit in this disposable world "
|
||||
+ "is saved and checked in a second JVM. Traversal remains inside the modules and certified portico approaches; underground one-block rises need real correctly oriented stairs. Only the certified natural portico approaches permit an ordinary one-block jump. No flight, ladder or external terrain shortcut is allowed. No fluid simulation is claimed.");
|
||||
}
|
||||
void advance() {
|
||||
try {
|
||||
if (!prepared) {
|
||||
prepare(); prepared = true;
|
||||
if (plan.isEmpty()) { finishDisabled(); return; }
|
||||
}
|
||||
if (nextChunk < chunks.size()) {
|
||||
var pos = chunks.get(nextChunk++);
|
||||
var loaded = level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
|
||||
helper.assertTrue(loaded instanceof LevelChunk, "Every room footprint must reach native FULL: " + pos);
|
||||
if (nextChunk % 10 == 0) SanctuaryMod.LOGGER.info("Industrial transit21: loaded {}/{} footprint chunks", nextChunk, chunks.size());
|
||||
helper.runAtTickTime(helper.getTick() + 1, this::advance); return;
|
||||
}
|
||||
verifyPlaced();
|
||||
report.put("passed", true);
|
||||
if (!reload) {
|
||||
helper.assertTrue(gold != null && level.getBlockState(gold).isCollisionShapeFullBlock(level, gold),
|
||||
"Gameplay witness must replace a solid outer wall away from every doorway");
|
||||
level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3);
|
||||
report.put("gold_witness", coords(gold));
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native structures, deferred loot and the gold edit");
|
||||
report.put("world_flushed_before_shutdown", true);
|
||||
} else {
|
||||
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Reload replaced the existing wall edit");
|
||||
report.put("gold_witness", coords(gold)); report.put("gold_witness_preserved", true);
|
||||
}
|
||||
write(level, report, reload); helper.succeed();
|
||||
} catch (Exception failure) {
|
||||
report.put("passed", false); report.put("failure", failure.toString());
|
||||
try { write(level, report, reload); } catch (IOException ignored) { }
|
||||
throw new IllegalStateException("Industrial transit21 native " + (reload ? "reload" : "creation") + " failed", failure);
|
||||
}
|
||||
}
|
||||
void prepare() throws IOException {
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
|
||||
var encoded = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow();
|
||||
var decoded = ChunkGenerator.CODEC.parse(ops, encoded).getOrThrow();
|
||||
helper.assertTrue(decoded instanceof SanctuaryChunkGenerator copy && copy.rootGeneration() == 21
|
||||
&& copy.experimentalStructures() == generator.experimentalStructures(), "The generation21 codec must preserve its experimental switch");
|
||||
report.put("generator_codec", encoded); report.put("plan_sha256", planHash(plan)); report.put("planning_samples", plan.samples());
|
||||
helper.assertTrue(plan.samples() <= TransitPlan21.MAX_SAMPLES, "Transit planning exceeded its budget");
|
||||
if (reload) {
|
||||
previous = JsonParser.parseString(Files.readString(proof(level))).getAsJsonObject();
|
||||
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(),
|
||||
"Reload requires the successful, flushed creation proof");
|
||||
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must run in a separate JVM");
|
||||
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed()
|
||||
&& previous.get("size").getAsInt() == generator.initialDiameter()
|
||||
&& previous.get("world_directory").getAsString().equals(world(level).toString()), "Reload used another world, seed or size");
|
||||
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("plan_sha256").getAsString().equals(planHash(plan)),
|
||||
"Saved switch or planned generation21 room identities changed on reload");
|
||||
if (previous.has("gold_witness")) gold = readPos(previous.getAsJsonArray("gold_witness"));
|
||||
}
|
||||
boolean enabled = generator.experimentalStructures() && level.structureManager().shouldGenerateStructures();
|
||||
if (!enabled) {
|
||||
helper.assertTrue(plan.isEmpty(), "Experimental=false or generateStructures=false must publish no industrial transit");
|
||||
return;
|
||||
}
|
||||
helper.assertTrue(!plan.isEmpty(), "Enabled reference island has no experimental network");
|
||||
helper.assertTrue(plan.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.ACCESS).count() == 2,
|
||||
"A through-route needs two independent saved surface stairs");
|
||||
int spanX = plan.modules().stream().mapToInt(TransitPlan21.Module::maxX).max().orElseThrow()
|
||||
- plan.modules().stream().mapToInt(TransitPlan21.Module::minX).min().orElseThrow() + 1;
|
||||
int spanZ = plan.modules().stream().mapToInt(TransitPlan21.Module::maxZ).max().orElseThrow()
|
||||
- plan.modules().stream().mapToInt(TransitPlan21.Module::minZ).min().orElseThrow() + 1;
|
||||
report.put("span_x", spanX); report.put("span_z", spanZ);
|
||||
helper.assertTrue(Math.max(spanX, spanZ) >= generator.initialDiameter() * .4,
|
||||
"The native transit reference must cross at least40% of the island's nominal diameter");
|
||||
report.put("modules", plan.modules().size());
|
||||
report.put("halls", plan.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.HALL).count());
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(TransitStructures21.KEY);
|
||||
var origins = new HashSet<Long>(); var uniqueChunks = new HashSet<ChunkPos>();
|
||||
for (var network : plan.modules()) {
|
||||
long origin = new ChunkPos(network.startChunkX(), network.startChunkZ()).pack();
|
||||
if (!origins.add(origin)) continue;
|
||||
var chunk = level.getChunk(network.startChunkX(), network.startChunkZ(), ChunkStatus.STRUCTURE_STARTS, true);
|
||||
var start = chunk.getStartForStructure(structure.value());
|
||||
helper.assertTrue(start != null && start.isValid(), "Accepted experimental network has no native start: " + network.kind() + " at " + network.centerX() + "," + network.centerZ());
|
||||
starts.add(start);
|
||||
for (var piece : start.getPieces()) {
|
||||
helper.assertTrue(piece instanceof TransitPiece21, "Industrial transit loaded a foreign or historical piece type");
|
||||
piece.getBoundingBox().intersectingChunks().forEach(at -> {
|
||||
helper.assertTrue(Math.abs(at.x() - network.startChunkX()) <= 7 && Math.abs(at.z() - network.startChunkZ()) <= 7,
|
||||
"Transit native start exceeds its local reference range");
|
||||
uniqueChunks.add(at);
|
||||
});
|
||||
}
|
||||
}
|
||||
helper.assertTrue(uniqueChunks.size() <= 256, "The bounded root-only proof unexpectedly covers more than256 chunks");
|
||||
chunks.addAll(uniqueChunks.stream().sorted(java.util.Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList());
|
||||
}
|
||||
void finishDisabled() throws IOException {
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(TransitStructures21.KEY);
|
||||
var chunk = level.getChunk(0, 0, ChunkStatus.STRUCTURE_STARTS, true);
|
||||
var start = chunk.getStartForStructure(structure.value());
|
||||
helper.assertTrue(start == null || !start.isValid(), "Disabled experimental generation emitted a native start");
|
||||
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
|
||||
helper.assertTrue(located == null, "Disabled experimental structures must not be located or prepared");
|
||||
report.put("empty_plan_verified", true); report.put("passed", true);
|
||||
if (!reload) {
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save the disabled experiment setting");
|
||||
report.put("world_flushed_before_shutdown", true);
|
||||
}
|
||||
write(level, report, reload); helper.succeed();
|
||||
}
|
||||
void verifyPlaced() {
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(TransitStructures21.KEY);
|
||||
var context = StructurePieceSerializationContext.fromLevel(level);
|
||||
var sites = new ArrayList<Map<String, Object>>();
|
||||
var modules = new ArrayList<Map<String, Object>>();
|
||||
int blocks = 0, supports = 0, chests = 0, pieces = 0;
|
||||
var bearingMasks = new HashMap<TransitPlan21.Module, Integer>();
|
||||
for (var start : starts) {
|
||||
var origin = start.getChunkPos(); var saved = start.createTag(context, origin);
|
||||
var copy = StructureStart.loadStaticStart(context, saved, level.getSeed());
|
||||
helper.assertTrue(copy != null && copy.isValid() && copy.createTag(context, origin).equals(saved), "The native start changed on an NBT roundtrip");
|
||||
var expectedRooms = plan.modulesForChunk(origin.x(), origin.z());
|
||||
var actualRooms = start.getPieces().stream().map(TransitPiece21.class::cast).map(TransitPiece21::module).toList();
|
||||
helper.assertTrue(actualRooms.size() == expectedRooms.size() && new HashSet<>(actualRooms).equals(new HashSet<>(expectedRooms)),
|
||||
"Native start did not persist every planned room exactly once");
|
||||
String nativeHash = hash(saved.toString());
|
||||
if (reload) {
|
||||
JsonObject prior = null;
|
||||
for (var value : previous.getAsJsonArray("starts")) {
|
||||
var site = value.getAsJsonObject(); var at = site.getAsJsonArray("start_chunk");
|
||||
if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = site;
|
||||
}
|
||||
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(nativeHash),
|
||||
"Reload altered native room parameters, supports or reference count");
|
||||
}
|
||||
var loot = new ArrayList<Map<String, Object>>();
|
||||
for (var piece : start.getPieces()) {
|
||||
var roomPiece = (TransitPiece21) piece; var room = roomPiece.module(); pieces++;
|
||||
piece.getBoundingBox().intersectingChunks().forEach(at -> {
|
||||
var full = level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true);
|
||||
helper.assertTrue(full.getReferencesForStructure(structure.value()).contains(origin.pack()), "A saved FULL chunk lost the native structure reference");
|
||||
});
|
||||
var roomTag = roomPiece.createTag(context);
|
||||
var roundtrip = new TransitPiece21(context, roomTag);
|
||||
helper.assertTrue(roundtrip.module().equals(room) && roundtrip.createTag(context).equals(roomTag), "The native room failed its exact saved-field roundtrip");
|
||||
var corrupt = roomTag.copy(); var payload = corrupt.getCompound("SanctuaryModule").orElseThrow(); payload.putInt("Schema", 99);
|
||||
boolean rejected = false;
|
||||
try { new TransitPiece21(context, corrupt); } catch (IllegalArgumentException expected) { rejected = true; }
|
||||
helper.assertTrue(rejected, "An unknown generation21 piece schema must be refused");
|
||||
var expected = rendered(room); blocks += expected.blocks().size();
|
||||
expected.blocks().forEach((at, state) -> helper.assertTrue(allGeometry.putIfAbsent(at, state) == null,
|
||||
"Two saved transit modules paint the same position: " + at));
|
||||
modules.add(Map.of("kind", room.kind().name(), "min", List.of(room.minX(), room.minY(), room.minZ()),
|
||||
"max", List.of(room.maxX(), room.maxY(), room.maxZ()), "floor_y", room.floorY(),
|
||||
"surface_y", room.surfaceY(), "openings", room.openings(), "supports", room.supports().size(),
|
||||
"start_chunk", List.of(room.startChunkX(), room.startChunkZ())));
|
||||
for (var entry : expected.blocks().entrySet()) {
|
||||
var at = entry.getKey(); var wanted = entry.getValue();
|
||||
helper.assertTrue(TransitPiece21.bounds(room).isInside(at), "Renderer escaped its saved room/support bounds");
|
||||
helper.assertTrue(plan.protects(at.getX(), at.getY(), at.getZ()) && generator.protectsCityDecoration(at),
|
||||
"Transit construction/cleared air is missing its published decoration protection at " + at);
|
||||
if (reload && at.equals(gold)) continue;
|
||||
helper.assertTrue(level.getBlockState(at).is(wanted.getBlock()),
|
||||
"Native clipping or later decoration changed " + room.kind() + " at " + at + ": expected " + wanted + " got " + level.getBlockState(at));
|
||||
helper.assertTrue(!level.getBlockState(at).is(Blocks.SPAWNER), "Experimental architecture must not create a spawner");
|
||||
}
|
||||
supports += verifyBearings(helper, generator, room, bearingMasks);
|
||||
for (var entry : expected.blocks().entrySet()) if (entry.getValue().is(Blocks.LANTERN)) {
|
||||
var at = entry.getKey(); var state = level.getBlockState(at);
|
||||
helper.assertTrue(state.canSurvive(level, at) && level.getBlockState(at.above()).isFaceSturdy(level, at.above(), Direction.DOWN),
|
||||
"Transit lamp lost its complete ceiling bearing at " + at);
|
||||
}
|
||||
for (var chest : expected.chests()) {
|
||||
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Deferred native chest block entity is missing");
|
||||
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
|
||||
helper.assertTrue(chest.table().equals(entity.getLootTable()) && entity.getLootTableSeed() == chest.seed(),
|
||||
"Native chest loot must remain deferred with its exact table and seed");
|
||||
loot.add(Map.of("position", coords(chest.position()), "table", chest.table().identifier().toString(), "seed", chest.seed())); chests++;
|
||||
}
|
||||
if (!reload && gold == null && room.kind() != TransitPlan21.Kind.ACCESS && room.kind() != TransitPlan21.Kind.JUNCTION)
|
||||
gold = new BlockPos(room.minX(), room.floorY() + 1, room.minZ() + 1);
|
||||
}
|
||||
sites.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", nativeHash,
|
||||
"pieces", start.getPieces().size(), "unopened_chests", loot));
|
||||
}
|
||||
report.put("module_coordinates", modules);
|
||||
report.put("starts", sites);
|
||||
int weakSpan = 0, longestSpan = 0;
|
||||
for (var room : plan.modules()) {
|
||||
weakSpan = bearingMasks.get(room) == 15 ? 0 : weakSpan + 1;
|
||||
longestSpan = Math.max(longestSpan, weakSpan);
|
||||
helper.assertTrue(weakSpan <= 2, "Native deck spans more than 34 blocks without distributed rock bearings");
|
||||
}
|
||||
report.put("longest_span_without_full_bearings", longestSpan * 17);
|
||||
var ores = new java.util.TreeMap<String, Integer>();
|
||||
allGeometry.values().forEach(state -> {
|
||||
var id = net.minecraft.core.registries.BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString();
|
||||
if (id.endsWith("_ore")) ores.merge(id, 1, Integer::sum);
|
||||
});
|
||||
report.put("finite_exposed_ores", ores);
|
||||
var traversal = verifyAccess(helper, plan);
|
||||
report.put("native_connections", NativeConnections20.verify(helper, allGeometry,
|
||||
reload && gold != null ? Set.of(gold) : Set.of()));
|
||||
report.put("module_coordinates", modules);
|
||||
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
|
||||
helper.assertTrue(located != null && located.getSecond().is(TransitStructures21.KEY), "Native locate cannot find a saved industrial transit");
|
||||
helper.assertTrue(plan.modules().stream().filter(r -> r.kind() == TransitPlan21.Kind.ACCESS)
|
||||
.anyMatch(r -> TransitPiece21.locator(r).equals(located.getFirst())), "Locate must lead to a surface entrance rather than an underground floor");
|
||||
report.put("starts", sites); report.put("native_pieces", pieces); report.put("native_blocks_checked", blocks);
|
||||
report.put("full_chunks_checked", chunks.size()); report.put("rock_supports_checked", supports);
|
||||
report.put("unopened_deferred_chests", chests); report.put("native_routes", traversal); report.put("locate", coords(located.getFirst()));
|
||||
}
|
||||
}
|
||||
private record Chest(BlockPos position, ResourceKey<LootTable> table, long seed) {}
|
||||
private record Rendered(Map<BlockPos, BlockState> blocks, List<Chest> chests) {}
|
||||
private static Rendered rendered(TransitPlan21.Module room) {
|
||||
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
|
||||
TransitRenderer21.render(room, new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(at(room, x, y, z), state); }
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
|
||||
var position = at(room, x, y, z); blocks.put(position, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(position, table, RandomSource.create(seed).nextLong()));
|
||||
}
|
||||
});
|
||||
return new Rendered(blocks, chests);
|
||||
}
|
||||
private static int verifyBearings(GameTestHelper helper, SanctuaryChunkGenerator generator, TransitPlan21.Module room, Map<TransitPlan21.Module, Integer> masks) {
|
||||
var level = helper.getLevel(); int checked = 0, quarters = 0;
|
||||
for (int z = room.minZ();; z = Math.min(z + 4, room.maxZ())) {
|
||||
for (int x = room.minX();; x = Math.min(x + 4, room.maxX())) {
|
||||
int anchor = room.minY() - 1;
|
||||
for (var support : room.supports()) if (support.x() == x && support.z() == z) anchor = support.minY() - 1;
|
||||
var bearing = new BlockPos(x, anchor, z);
|
||||
boolean supported = level.getBlockState(bearing).isSolid() && level.getFluidState(bearing).isEmpty()
|
||||
&& level.getBlockState(bearing.below()).isSolid() && level.getFluidState(bearing.below()).isEmpty();
|
||||
if (supported) {
|
||||
quarters |= 1 << ((x < room.centerX() ? 0 : 1) + (z < room.centerZ() ? 0 : 2));
|
||||
for (int dy = 0; dy >= -1; dy--) {
|
||||
var at = new BlockPos(x, anchor + dy, z);
|
||||
helper.assertTrue(level.getBlockState(at).isSolid() && level.getFluidState(at).isEmpty(),
|
||||
"Transit bearing grid lost one of its two untouched rock layers at " + at);
|
||||
helper.assertTrue(generator.protectsCityDecoration(at), "A certified transit rock dependency is unprotected at " + at);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
if (x == room.maxX()) break;
|
||||
}
|
||||
if (z == room.maxZ()) break;
|
||||
}
|
||||
helper.assertTrue(room.kind() == TransitPlan21.Kind.GALLERY || room.kind() == TransitPlan21.Kind.JUNCTION || quarters == 15, "The continuous deck lost its distributed rock bearings");
|
||||
masks.put(room, quarters);
|
||||
return checked;
|
||||
}
|
||||
static Map<String, Object> verifyAccess(GameTestHelper helper, TransitPlan21 plan) {
|
||||
var level = helper.getLevel();
|
||||
var accesses = plan.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.ACCESS).toList();
|
||||
helper.assertTrue(accesses.size() == 2, "Native transit requires exactly two surface gates");
|
||||
Predicate<BlockPos> inside = pos -> plan.contains(pos.getX(), pos.getY(), pos.getZ())
|
||||
|| accesses.stream().anyMatch(m -> pos.getX() >= m.centerX() - 2 && pos.getX() <= m.centerX() + 2
|
||||
&& pos.getZ() >= m.minZ() - 3 && pos.getZ() < m.minZ()
|
||||
&& pos.getY() >= m.surfaceY() && pos.getY() <= m.surfaceY() + 2);
|
||||
var start = TransitPiece21.locator(accesses.getFirst());
|
||||
Predicate<BlockPos> naturalApproach = pos -> accesses.stream().anyMatch(m ->
|
||||
pos.getX() >= m.centerX() - 2 && pos.getX() <= m.centerX() + 2
|
||||
&& pos.getZ() >= m.minZ() - 3 && pos.getZ() < m.minZ()
|
||||
&& pos.getY() >= m.surfaceY() && pos.getY() <= m.surfaceY() + 2);
|
||||
var reached = walk(helper, start, inside, naturalApproach); int targets = 0;
|
||||
var arrivals = new ArrayList<Map<String, Object>>();
|
||||
for (var module : plan.modules()) for (var target : TransitRenderer21.targets(module)) {
|
||||
var point = at(module, target.x(), target.y(), target.z());
|
||||
helper.assertTrue(reached.containsKey(Foot.at(point)), "Native transit cannot walk from its first portico to "
|
||||
+ module.kind() + "/" + target.name() + " at " + point);
|
||||
targets++;
|
||||
}
|
||||
for (var access : accesses) {
|
||||
var gate = TransitPiece21.locator(access);
|
||||
helper.assertTrue(reached.containsKey(Foot.at(gate)), "The second independent surface gate is not connected by real stairs");
|
||||
int approaches = 0;
|
||||
for (int x = access.centerX() - 2; x <= access.centerX() + 2; x++) for (int z = access.minZ() - 3; z < access.minZ(); z++) {
|
||||
var high = new BlockPos(x, access.surfaceY() + 1, z);
|
||||
var low = high.below(); var position = standable(level, high) ? high : low;
|
||||
helper.assertTrue(standable(level, position) && reached.containsKey(Foot.at(position)),
|
||||
"The certified natural approach no longer joins its portico at " + position);
|
||||
helper.assertTrue(level.getBlockState(position.below()).isSolid()
|
||||
&& level.getFluidState(position.below()).isEmpty(), "A surface arrival is suspended above air or water");
|
||||
approaches++;
|
||||
}
|
||||
arrivals.add(Map.of("gate", coords(gate), "roadway", coords(at(access, 8, 2, 8)),
|
||||
"rise", access.surfaceY() - access.floorY(), "steps_from_first_gate", reached.get(Foot.at(gate)),
|
||||
"natural_approach_cells", approaches));
|
||||
}
|
||||
return Map.of("surface_accesses", arrivals, "walkable_targets", targets, "standing_cells", reached.size(),
|
||||
"surface_to_surface_steps", reached.get(Foot.at(TransitPiece21.locator(accesses.getLast()))),
|
||||
"traversal_policy", "0.6 by1.8 native collision volume, half-block sidewalk steps; underground rises require a real forward-facing stair; natural portico approaches alone permit a normal one-block jump; no vertical moves");
|
||||
}
|
||||
/** Half-block standing elevations retain real sidewalk slabs without pretending an integer cell can hover over them. */
|
||||
private record Foot(int x, int halfY, int z) {
|
||||
static Foot at(BlockPos pos) { return new Foot(pos.getX(), pos.getY() * 2, pos.getZ()); }
|
||||
double y() { return halfY / 2.0; }
|
||||
BlockPos block() { return new BlockPos(x, Math.floorDiv(halfY, 2), z); }
|
||||
BlockPos bearing() { return new BlockPos(x, Math.floorDiv(halfY - 1, 2), z); }
|
||||
Foot rise(int halves) { return new Foot(x, halfY + halves, z); }
|
||||
Foot move(Direction direction, int halves) { return new Foot(x + direction.getStepX(), halfY + halves, z + direction.getStepZ()); }
|
||||
}
|
||||
private static Map<Foot, Integer> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside, Predicate<BlockPos> naturalApproach) {
|
||||
var level = helper.getLevel(); var first = Foot.at(start);
|
||||
helper.assertTrue(inside.test(start) && standable(level, first), "Blocked native surface portico: " + start);
|
||||
var seen = new HashMap<Foot, Integer>(); var queue = new ArrayDeque<Foot>(); seen.put(first, 0); queue.add(first);
|
||||
while (!queue.isEmpty()) {
|
||||
var pos = queue.removeFirst();
|
||||
for (var direction : HORIZONTAL) for (int dy = -2; dy <= 2; dy++) {
|
||||
var next = pos.move(direction, dy);
|
||||
if (!inside.test(next.block()) || seen.containsKey(next)) continue;
|
||||
boolean ordinaryThreshold = naturalApproach.test(pos.block()) || naturalApproach.test(next.block());
|
||||
// Native step height is enough for a half slab; a full block needs the two treads of a stair.
|
||||
if (dy > 0 && (!clearBody(level, pos.rise(dy))
|
||||
|| dy == 2 && !ordinaryThreshold && !ascendingStair(level, next.bearing(), direction))) continue;
|
||||
if (dy < 0 && (!clearBody(level, next.rise(-dy))
|
||||
|| dy == -2 && !ordinaryThreshold && !ascendingStair(level, pos.bearing(), direction.getOpposite()))) continue;
|
||||
if (standable(level, next)) { seen.put(next, seen.get(pos) + 1); queue.add(next); }
|
||||
}
|
||||
helper.assertTrue(seen.size() <= 120_000, "Native transit traversal exceeded its bounded room volumes");
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
private static boolean ascendingStair(ServerLevel level, BlockPos pos, Direction direction) {
|
||||
var state = level.getBlockState(pos);
|
||||
return state.getBlock() instanceof StairBlock
|
||||
&& state.getValue(BlockStateProperties.HORIZONTAL_FACING) == direction
|
||||
&& state.getValue(BlockStateProperties.HALF) == net.minecraft.world.level.block.state.properties.Half.BOTTOM;
|
||||
}
|
||||
private static boolean standable(ServerLevel level, BlockPos pos) { return standable(level, Foot.at(pos)); }
|
||||
private static boolean standable(ServerLevel level, Foot pos) {
|
||||
if (!clearBody(level, pos)) return false;
|
||||
var below = pos.bearing(); double top = pos.y() - below.getY();
|
||||
return level.getBlockState(below).getCollisionShape(level, below).toAabbs().stream()
|
||||
.anyMatch(b -> Math.abs(b.maxY - top) < .0001 && b.minX <= .5 && b.maxX >= .5 && b.minZ <= .5 && b.maxZ >= .5);
|
||||
}
|
||||
private static boolean clearBody(ServerLevel level, Foot pos) {
|
||||
var body = new AABB(pos.x() + .2, pos.y(), pos.z() + .2, pos.x() + .8, pos.y() + 1.8, pos.z() + .8);
|
||||
for (int y = (int) Math.floor(pos.y()); y <= (int) Math.floor(pos.y() + 1.8 - .0001); y++) {
|
||||
var at = new BlockPos(pos.x(), y, pos.z());
|
||||
if (!level.getFluidState(at).isEmpty()) return false;
|
||||
for (var shape : level.getBlockState(at).getCollisionShape(level, at).toAabbs())
|
||||
if (shape.move(at.getX(), at.getY(), at.getZ()).intersects(body)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private static BlockPos at(TransitPlan21.Module room, int x, int y, int z) { return new BlockPos(room.minX() + x, room.minY() + y, room.minZ() + z); }
|
||||
private static List<Integer> coords(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
|
||||
private static BlockPos readPos(com.google.gson.JsonArray value) { return new BlockPos(value.get(0).getAsInt(), value.get(1).getAsInt(), value.get(2).getAsInt()); }
|
||||
private static Path world(ServerLevel level) { return level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); }
|
||||
private static Path proof(ServerLevel level) { return world(level).resolve("transit21-create-proof.json"); }
|
||||
private static String planHash(TransitPlan21 plan) { return hash(plan.modules().toString()); }
|
||||
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
|
||||
var path = Path.of("diagnostics", "transit21-" + (reload ? "reload" : "create") + ".json"); Files.createDirectories(path.getParent());
|
||||
var value = new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n";
|
||||
Files.writeString(path, value);
|
||||
if (!reload) Files.writeString(proof(level), value);
|
||||
}
|
||||
private static String hash(String value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityUnderground16;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Kind;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Module;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.StairBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
|
||||
/** Native circulation proof on the already generated piece footprint. */
|
||||
public final class Underground22NativeChecks {
|
||||
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
|
||||
private Underground22NativeChecks() {}
|
||||
public static Map<String, Integer> verify(GameTestHelper helper, LostCityUnderground16.Plan plan) {
|
||||
var level = helper.getLevel(); var chunks = new HashSet<Long>();
|
||||
int halls = 0, ladders = 0, apertures = 0, walked = 0;
|
||||
helper.assertTrue(!plan.isEmpty(), "Independent underground hall plan must be present");
|
||||
for (var network : plan.networks()) {
|
||||
for (var m : network.modules()) {
|
||||
for (int cx = m.minX() >> 4; cx <= m.maxX() >> 4; cx++) for (int cz = m.minZ() >> 4; cz <= m.maxZ() >> 4; cz++) {
|
||||
long key = ((long) cx << 32) ^ (cz & 0xffffffffL);
|
||||
// Short-lived read tickets can expire during the caller’s asynchronous footprint load.
|
||||
// Refresh only these already generated chunks before inspecting their blocks.
|
||||
if (chunks.add(key)) helper.assertTrue(level.getChunk(cx, cz, ChunkStatus.FULL, true) instanceof LevelChunk,
|
||||
"Underground22 piece has no loaded FULL chunk at " + cx + "," + cz);
|
||||
}
|
||||
for (int d = 0; d < 4; d++) if ((m.openings() & 1 << d) != 0) {
|
||||
for (int side = -1; side <= 1; side++) for (int dy = 1; dy <= 3; dy++) {
|
||||
var at = mouth(m, d, side, dy);
|
||||
helper.assertTrue(passable(level.getBlockState(at)), "Native utility doorway blocked at " + at + " in " + m.kind());
|
||||
}
|
||||
apertures++;
|
||||
}
|
||||
}
|
||||
var access = network.modules().stream().filter(m -> m.kind() == Kind.ACCESS).findFirst().orElseThrow();
|
||||
BlockPos hatch = null;
|
||||
for (int x = access.minX(); x <= access.maxX(); x++) for (int z = access.minZ(); z <= access.maxZ(); z++) {
|
||||
var at = new BlockPos(x, access.maxY() - 1, z);
|
||||
if (level.getBlockState(at).is(Blocks.DARK_OAK_TRAPDOOR)) {
|
||||
helper.assertTrue(hatch == null, "The access has more than one surface hatch"); hatch = at;
|
||||
}
|
||||
}
|
||||
helper.assertTrue(hatch != null, "Independent access has no real wooden surface hatch");
|
||||
final BlockPos entrance = hatch;
|
||||
var closed = level.getBlockState(entrance);
|
||||
for (int y = access.floorY() + 1; y < entrance.getY(); y++) {
|
||||
var at = new BlockPos(entrance.getX(), y, entrance.getZ()); var ladder = level.getBlockState(at);
|
||||
helper.assertTrue(ladder.is(Blocks.LADDER), "Access ladder is interrupted at " + at);
|
||||
var wall = at.relative(ladder.getValue(BlockStateProperties.HORIZONTAL_FACING).getOpposite());
|
||||
helper.assertTrue(level.getBlockState(wall).isCollisionShapeFullBlock(level, wall), "Ladder has no real backing wall at " + at);
|
||||
ladders++;
|
||||
}
|
||||
level.setBlock(entrance, closed.setValue(BlockStateProperties.OPEN, true), 2);
|
||||
try {
|
||||
Predicate<BlockPos> inside = pos -> network.modules().stream().anyMatch(m -> m.contains(pos.getX(), pos.getY(), pos.getZ()));
|
||||
BlockPos approach = java.util.Arrays.stream(HORIZONTAL).map(entrance::relative)
|
||||
.filter(inside).filter(pos -> standable(level, pos)).findFirst().orElseThrow();
|
||||
var reached = walk(helper, approach, inside); walked += reached.size();
|
||||
helper.assertTrue(reached.contains(entrance), "Public deck cannot reach its hatch");
|
||||
for (var m : network.modules()) {
|
||||
helper.assertTrue(reached.stream().anyMatch(pos -> pos.getY() == m.floorY() + 1 && pos.getX() > m.minX()
|
||||
&& pos.getX() < m.maxX() && pos.getZ() > m.minZ() && pos.getZ() < m.maxZ()),
|
||||
"Surface access cannot reach emitted utility module " + m.kind() + " at " + m.centerX() + "," + m.centerZ());
|
||||
if (m.kind() != Kind.EXPANSION_HALL) continue;
|
||||
halls++;
|
||||
var plinth = new BlockPos(m.centerX(), m.minY() + 2, m.centerZ());
|
||||
helper.assertTrue(level.getBlockState(plinth).is(Blocks.SMOOTH_STONE) && reached.contains(plinth.above()),
|
||||
"Reserved expansion plinth must be emitted and reachable");
|
||||
for (int dx : new int[]{-6, 6}) for (int dz : new int[]{-6, 6})
|
||||
helper.assertTrue(reached.contains(new BlockPos(m.centerX() + dx, m.floorY() + 1, m.centerZ() + dz)),
|
||||
"Expansion hall has a blocked quarter");
|
||||
}
|
||||
} finally { level.setBlock(entrance, closed, 2); }
|
||||
}
|
||||
helper.assertTrue(halls > 0, "Native networks have no usable expansion hall");
|
||||
var report = new LinkedHashMap<String, Integer>(); report.put("full_chunks", chunks.size()); report.put("usable_halls", halls);
|
||||
report.put("ladder_blocks", ladders); report.put("clear_openings", apertures); report.put("reached_positions", walked);
|
||||
return Map.copyOf(report);
|
||||
}
|
||||
private static BlockPos mouth(Module m, int direction, int across, int dy) {
|
||||
return new BlockPos(direction == 1 ? m.maxX() : direction == 3 ? m.minX() : m.centerX() + across,
|
||||
m.floorY() + dy, direction == 0 ? m.minZ() : direction == 2 ? m.maxZ() : m.centerZ() + across);
|
||||
}
|
||||
/** Same finite walking/ladder rule used by the native city16/20/21 access proofs. */
|
||||
private static Set<BlockPos> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside) {
|
||||
var level = helper.getLevel(); var reached = new HashSet<BlockPos>(); var queue = new ArrayDeque<BlockPos>();
|
||||
helper.assertTrue(inside.test(start) && standable(level, start), "Blocked utility approach"); reached.add(start); queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
var pos = queue.remove();
|
||||
for (var direction : HORIZONTAL) for (int dy = -1; dy <= 1; dy++) {
|
||||
var next = pos.relative(direction).offset(0, dy, 0);
|
||||
if (!inside.test(next) || dy > 0 && !passable(level.getBlockState(pos.above(2)))) continue;
|
||||
if (standable(level, next) && reached.add(next)) queue.add(next);
|
||||
}
|
||||
for (int dy : new int[]{-1, 1}) {
|
||||
var next = pos.offset(0, dy, 0);
|
||||
if (inside.test(next) && (level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(next).is(Blocks.LADDER)
|
||||
|| level.getBlockState(pos.below()).is(Blocks.LADDER)) && standable(level, next) && reached.add(next)) queue.add(next);
|
||||
}
|
||||
helper.assertTrue(reached.size() < 100_000, "Utility traversal exceeded its bounded footprint");
|
||||
}
|
||||
return reached;
|
||||
}
|
||||
private static boolean standable(ServerLevel level, BlockPos pos) {
|
||||
var below = level.getBlockState(pos.below()); var at = level.getBlockState(pos);
|
||||
return passable(at) && passable(level.getBlockState(pos.above()))
|
||||
&& (below.isSolid() || below.getBlock() instanceof StairBlock || at.is(Blocks.LADDER) || below.is(Blocks.LADDER));
|
||||
}
|
||||
private static boolean passable(BlockState state) {
|
||||
return state.isAir() || state.is(Blocks.LADDER) || state.is(Blocks.REDSTONE_WALL_TORCH)
|
||||
|| state.is(Blocks.DARK_OAK_TRAPDOOR) && state.getValue(BlockStateProperties.OPEN);
|
||||
}
|
||||
}
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.*;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.server.level.GenerationChunkHolder;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.WorldGenRegion;
|
||||
import net.minecraft.util.StaticCache2D;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.ImposterProtoChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkPyramid;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
|
||||
|
||||
/** Alpha.13 engine checks use the real starter binding and never reconfigure saved worlds. */
|
||||
public final class UnifiedWorldGameTests {
|
||||
private static final List<String> CAVES = List.of("minecraft:lush_caves", "minecraft:dripstone_caves", "minecraft:sulfur_caves");
|
||||
private static final Direction[] SIDES = {Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST};
|
||||
|
||||
@GameTest(maxTicks = 3000)
|
||||
public void retainedHydrologySurvivesNativeDecoration(GameTestHelper helper) throws IOException {
|
||||
PopulationDiagnostics.start(helper);
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void sizeAndPublicChoiceSurviveDiskStorage(GameTestHelper helper) throws IOException {
|
||||
UnifiedWorldSettingsDiagnostics.verify(helper);
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void legacyPipelinesRemainIsolated(GameTestHelper helper) {
|
||||
PopulationDiagnostics.verifyLegacyIsolation(helper);
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void populationTerrainAndNaturalSpawnRemainPresent(GameTestHelper helper) throws IOException {
|
||||
PopulationDiagnostics.verifyDensity(helper);
|
||||
var level = helper.getLevel();
|
||||
var binding = binding(level);
|
||||
helper.assertTrue(level.getMinY() == 0 && level.getHeight() == 384, "Unification preserves the complete vertical world extent");
|
||||
var spawn = SanctuaryWorldGameTests.initialSpawn;
|
||||
helper.assertTrue(spawn != null, "Capture the original natural spawn before the GameTest fixtures move it");
|
||||
var at = spawn.pos();
|
||||
helper.assertTrue(level.getBlockState(at.below()).isCollisionShapeFullBlock(level, at.below())
|
||||
&& level.getBlockState(at).getCollisionShape(level, at).isEmpty()
|
||||
&& level.getBlockState(at.above()).getCollisionShape(level, at.above()).isEmpty(),
|
||||
"The unmodified starter spawn must stand on real usable terrain");
|
||||
helper.assertTrue(natural(binding, at.below()) > 0, "The starter spawn never requires an added platform");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void exteriorAndLowerMarginRemainVoid(GameTestHelper helper) {
|
||||
PopulationDiagnostics.verifyExterior(helper);
|
||||
PopulationDiagnostics.verifyLowerMargin(helper);
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void nativeCaveFeaturesExistOnNaturalLayers(GameTestHelper helper) throws IOException {
|
||||
ServerLevel level = helper.getLevel();
|
||||
var binding = binding(level);
|
||||
var generator = unified(level);
|
||||
List<Site> sites = caveSites(binding);
|
||||
Map<Long, LevelChunk> chunks = new TreeMap<>();
|
||||
for (Site site : sites) {
|
||||
int cx = site.x() >> 4, cz = site.z() >> 4;
|
||||
for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++) {
|
||||
long key = ChunkPos.pack(cx + dx, cz + dz);
|
||||
if (!chunks.containsKey(key)) chunks.put(key, full(helper, level, cx + dx, cz + dz));
|
||||
}
|
||||
}
|
||||
helper.assertTrue(chunks.size() <= 27, "Native cave inspection is at most three independent 3x3 FULL patches");
|
||||
Map<String, Map<String, Long>> counts = new TreeMap<>();
|
||||
CAVES.forEach(name -> counts.put(name, new TreeMap<>()));
|
||||
Map<String, Long> allBlocks = new TreeMap<>();
|
||||
Map<String, Long> surfaceBiomes = new TreeMap<>();
|
||||
int largestDripstoneColumn = 0;
|
||||
var density = binding.generator().generatorSettings().value().noiseRouter().finalDensity();
|
||||
for (LevelChunk chunk : chunks.values()) {
|
||||
for (var section : chunk.getSections()) section.getStates().count((state, count) -> {
|
||||
if (!state.isAir()) allBlocks.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), (long) count, Long::sum);
|
||||
});
|
||||
for (int dx = 0; dx < 16; dx++) for (int dz = 0; dz < 16; dz++) {
|
||||
int x = chunk.getPos().getMinBlockX() + dx, z = chunk.getPos().getMinBlockZ() + dz;
|
||||
int run = 0;
|
||||
for (int y = 0; y < 256; y++) {
|
||||
var pos = new BlockPos(x, y, z);
|
||||
var state = chunk.getBlockState(pos);
|
||||
String biome = chunk.getNoiseBiome(x >> 2, y >> 2, z >> 2).getRegisteredName();
|
||||
var bucket = counts.get(biome);
|
||||
if (bucket != null && !state.isAir()) bucket.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), 1L, Long::sum);
|
||||
// Large vanilla pillars add thick dripstone into original cave air. A painted
|
||||
// floor or the old hand-placed support block cannot satisfy this witness.
|
||||
if (state.is(Blocks.DRIPSTONE_BLOCK) && biome.equals("minecraft:dripstone_caves")
|
||||
&& binding.random().sampleBlockValueUncached(density, x, y, z) <= 0) run++;
|
||||
else run = 0;
|
||||
largestDripstoneColumn = Math.max(largestDripstoneColumn, run);
|
||||
}
|
||||
int top = chunk.getHeight(net.minecraft.world.level.levelgen.Heightmap.Types.WORLD_SURFACE, dx, dz);
|
||||
if (top > 190) surfaceBiomes.merge(chunk.getNoiseBiome(x >> 2, top >> 2, z >> 2).getRegisteredName(), 1L, Long::sum);
|
||||
}
|
||||
}
|
||||
var report = report(level);
|
||||
report.put("sites", sites); report.put("inspected_full_chunks", chunks.size());
|
||||
report.put("native_cave_blocks", counts); report.put("all_observed_blocks", allBlocks);
|
||||
report.put("surface_biomes", surfaceBiomes); report.put("largest_natural_air_dripstone_column", largestDripstoneColumn);
|
||||
report.put("rejected_decoration_writes", generator.rejectedDecorationWrites());
|
||||
Map<String, Object> atmospheres = new TreeMap<>();
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
|
||||
for (String name : CAVES) {
|
||||
var biome = level.registryAccess().lookupOrThrow(Registries.BIOME)
|
||||
.getValue(net.minecraft.resources.Identifier.parse(name));
|
||||
var encoded = Biome.DIRECT_CODEC.encodeStart(ops, biome).getOrThrow().getAsJsonObject();
|
||||
atmospheres.put(name, encoded.get("attributes"));
|
||||
helper.assertTrue(encoded.get("attributes").toString().contains("music.overworld." + name.substring("minecraft:".length())),
|
||||
"The native cave's music must remain present in its loaded biome attributes: " + name);
|
||||
if (name.equals("minecraft:sulfur_caves")) helper.assertTrue(encoded.get("attributes").toString().contains("minecraft:visual/fog_color")
|
||||
&& encoded.get("attributes").toString().contains("minecraft:sulfur_cube"),
|
||||
"The sulfur cave retains its distinct fog and native creature table");
|
||||
}
|
||||
report.put("native_attributes", atmospheres);
|
||||
report.put("scope", "Up to 27 FULL chunks selected from real cave density; counts are observed blocks, not island-wide resource estimates or client screenshots");
|
||||
boolean passed = false;
|
||||
Set<String> encountered = new TreeSet<>(); sites.forEach(site -> encountered.add(site.biome()));
|
||||
Set<String> verified = new TreeSet<>();
|
||||
try {
|
||||
helper.assertTrue(!sites.isEmpty(), "The demonstration world must provide at least one real native cave site");
|
||||
if (encountered.contains("minecraft:lush_caves")) {
|
||||
helper.assertTrue(count(counts, "minecraft:lush_caves", "minecraft:moss_block") > 0
|
||||
&& count(counts, "minecraft:lush_caves", "minecraft:cave_vines")
|
||||
+ count(counts, "minecraft:lush_caves", "minecraft:cave_vines_plant") > 0,
|
||||
"Actual native lush caverns must have moss and harvestable hanging vines");
|
||||
verified.add("minecraft:lush_caves");
|
||||
}
|
||||
if (encountered.contains("minecraft:dripstone_caves")) {
|
||||
helper.assertTrue(count(counts, "minecraft:dripstone_caves", "minecraft:pointed_dripstone") > 0 && largestDripstoneColumn >= 5,
|
||||
"Actual native dripstone caverns must include points and a substantial pillar in natural cave air");
|
||||
verified.add("minecraft:dripstone_caves");
|
||||
}
|
||||
if (encountered.contains("minecraft:sulfur_caves")) {
|
||||
helper.assertTrue(count(counts, "minecraft:sulfur_caves", "minecraft:sulfur") > 0
|
||||
&& count(counts, "minecraft:sulfur_caves", "minecraft:sulfur_spike") > 0,
|
||||
"Actual native sulfur caverns must include native rock and spikes");
|
||||
verified.add("minecraft:sulfur_caves");
|
||||
}
|
||||
helper.assertTrue(surfaceBiomes.keySet().stream().anyMatch(name -> name.startsWith("sanctuary:population_")),
|
||||
"The starter surface must retain its Population biome family above these lower cave patches");
|
||||
passed = true; helper.succeed();
|
||||
} finally {
|
||||
report.put("encountered_native_types", encountered); report.put("verified_native_types", verified);
|
||||
report.put("unverified_native_types", CAVES.stream().filter(name -> !verified.contains(name)).toList());
|
||||
report.put("all_three_types_demonstrated_here", verified.containsAll(CAVES));
|
||||
report.put("validation_contract", "Every encountered type must pass its full block evidence; the release also requires cumulative evidence for all three types across its fresh demonstration worlds");
|
||||
report.put("passed", passed); write(level, "native-caves", report);
|
||||
}
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void decorationGuardRejectsChangesToHydrology(GameTestHelper helper) throws IOException {
|
||||
var level = helper.getLevel(); var binding = binding(level); var generator = unified(level);
|
||||
var plan = PopulationHydrologyRuntime.regionPlan(binding.generator(), binding.random(), new IslandCapacity.Region(0, 0)).water();
|
||||
LinkedHashSet<BlockPos> witnesses = new LinkedHashSet<>();
|
||||
int shells = 0;
|
||||
for (var cell : plan.cells()) {
|
||||
if (!cell.hasWater()) continue;
|
||||
witnesses.add(new BlockPos(cell.x(), cell.waterY(), cell.z()));
|
||||
witnesses.add(new BlockPos(cell.x(), cell.bedY() - cell.sedimentDepth() - 1, cell.z()));
|
||||
for (Direction side : SIDES) {
|
||||
var wall = new BlockPos(cell.x(), cell.waterY(), cell.z()).relative(side);
|
||||
var next = plan.cellAt(wall.getX(), wall.getY(), wall.getZ());
|
||||
if ((next == null || !next.hasWater()) && natural(binding, wall) > 0) { witnesses.add(wall); shells++; }
|
||||
}
|
||||
if (shells >= 2 && witnesses.size() >= 12) break;
|
||||
}
|
||||
int boundaryWitnesses = 0;
|
||||
for (var cell : plan.cells()) {
|
||||
if (!cell.hasWater()) continue;
|
||||
for (Direction side : SIDES) {
|
||||
var water = new BlockPos(cell.x(), cell.waterY(), cell.z());
|
||||
var wall = water.relative(side);
|
||||
var next = plan.cellAt(wall.getX(), wall.getY(), wall.getZ());
|
||||
if (ChunkPos.pack(water) != ChunkPos.pack(wall) && (next == null || !next.hasWater()) && natural(binding, wall) > 0) {
|
||||
witnesses.add(wall); boundaryWitnesses++;
|
||||
}
|
||||
}
|
||||
if (boundaryWitnesses >= 4) break;
|
||||
}
|
||||
for (var spring : plan.springs()) { witnesses.add(position(spring.source())); witnesses.add(position(spring.outlet())); }
|
||||
for (var spill : plan.spills()) { witnesses.add(position(spill.source())); witnesses.add(position(spill.outlet())); }
|
||||
helper.assertTrue(!witnesses.isEmpty() && shells > 0, "Inspect real source, bed and natural wall witnesses");
|
||||
Map<Long, SanctuaryDecorationProtection> masks = new HashMap<>();
|
||||
int compared = 0;
|
||||
for (var witness : witnesses) {
|
||||
var mask = masks.computeIfAbsent(ChunkPos.pack(witness), ignored -> SanctuaryDecorationProtection.create(binding, witness.getX() >> 4, witness.getZ() >> 4));
|
||||
helper.assertTrue(mask.protects(witness) && generator.protectsDecoration(witness), "The published decoration mask protects the complete water geometry: " + witness);
|
||||
helper.assertTrue(PopulationHydrologyRuntime.protects(binding.generator(), binding.random(), witness, 1, 1, 1),
|
||||
"The mask must agree with the hydrology protection oracle including one-block clearance at natural walls");
|
||||
compared++;
|
||||
}
|
||||
var witness = witnesses.getFirst();
|
||||
LevelChunk chunk = full(helper, level, witness.getX() >> 4, witness.getZ() >> 4);
|
||||
// FULL holders deliberately expose a read-only ImposterProtoChunk to generation.
|
||||
// Use a test-local writable view; leave the world's real holder and saved status alone.
|
||||
var writable = new ImposterProtoChunk(chunk, true);
|
||||
GenerationChunkHolder testHolder = new GenerationChunkHolder(chunk.getPos()) {
|
||||
@Override protected void addSaveDependency(java.util.concurrent.CompletableFuture<?> dependency) {}
|
||||
@Override public int getTicketLevel() { return 0; }
|
||||
@Override public int getQueueLevel() { return 0; }
|
||||
@Override public ChunkAccess getChunkIfPresentUnchecked(ChunkStatus status) { return writable; }
|
||||
};
|
||||
var holders = StaticCache2D.<GenerationChunkHolder>create(chunk.getPos().x(), chunk.getPos().z(), 0,
|
||||
(x, z) -> testHolder);
|
||||
var worldgen = new WorldGenRegion(level, holders, ChunkPyramid.GENERATION_PYRAMID.getStepTo(ChunkStatus.FEATURES), chunk);
|
||||
var before = chunk.getBlockState(witness);
|
||||
long rejected = generator.rejectedDecorationWrites();
|
||||
try {
|
||||
helper.assertFalse(worldgen.setBlock(witness, Blocks.DIAMOND_BLOCK.defaultBlockState(), 2, 0),
|
||||
"A real WorldGenRegion write must be denied on protected retained water");
|
||||
helper.assertTrue(chunk.getBlockState(witness).equals(before) && generator.rejectedDecorationWrites() == rejected + 1,
|
||||
"The native write path must preserve the original block and record the rejection");
|
||||
} finally { if (!chunk.getBlockState(witness).equals(before)) chunk.setBlockState(witness, before, 0); }
|
||||
BlockPos free = new BlockPos(chunk.getPos().getMinBlockX() + 8, 383, chunk.getPos().getMinBlockZ() + 8);
|
||||
var freeBefore = chunk.getBlockState(free);
|
||||
helper.assertFalse(generator.protectsDecoration(free), "A distant air witness must be outside the hydrology mask");
|
||||
try {
|
||||
helper.assertTrue(worldgen.setBlock(free, Blocks.DIAMOND_BLOCK.defaultBlockState(), 2, 0)
|
||||
&& chunk.getBlockState(free).is(Blocks.DIAMOND_BLOCK),
|
||||
"Decoration outside protected volumes must remain writable");
|
||||
} finally { chunk.setBlockState(free, freeBefore, 0); }
|
||||
write(level, "decoration-guard", Map.of("generation", 13, "seed", level.getSeed(),
|
||||
"diameter", generator.initialDiameter(), "compared_plan_points", compared,
|
||||
"natural_wall_witnesses", shells, "walls_owned_by_adjacent_chunk", boundaryWitnesses, "actual_write_rejected", true, "unprotected_write_allowed", true, "passed", true));
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private record Site(String biome, int x, int floorY, int roofY, int z, int score) {}
|
||||
|
||||
private static List<Site> caveSites(PopulationGenerationContext.Binding binding) {
|
||||
var capacity = IslandCapacity.forGenerator(binding.generator());
|
||||
var resolver = binding.generator().getBiomeSource().createUncachedResolver(binding.random());
|
||||
var density = binding.generator().generatorSettings().value().noiseRouter().finalDensity();
|
||||
Map<String, List<Site>> candidates = new TreeMap<>(); CAVES.forEach(name -> candidates.put(name, new ArrayList<>()));
|
||||
int radius = (capacity.terrainLimit() + 15) / 16;
|
||||
for (int cx = -radius; cx <= radius; cx++) for (int cz = -radius; cz <= radius; cz++) {
|
||||
int x = cx * 16 + 8, z = cz * 16 + 8;
|
||||
if (Math.hypot(x, z) >= capacity.radius() - 24) continue;
|
||||
for (int y = 164; y >= 40; y--) {
|
||||
String biome = resolver.getNoiseBiome(x >> 2, (y + 1) >> 2, z >> 2).getRegisteredName();
|
||||
var bucket = candidates.get(biome); if (bucket == null) continue;
|
||||
if (sample(binding, density, x, y, z) <= 0 || sample(binding, density, x, y + 1, z) > 0
|
||||
|| sample(binding, density, x, y + 2, z) > 0 || sample(binding, density, x, y - 1, z) <= 0) continue;
|
||||
int roof = y + 3;
|
||||
while (roof < Math.min(256, y + 96) && sample(binding, density, x, roof, z) <= 0) roof++;
|
||||
if (roof >= Math.min(256, y + 96) || roof - y < 10 || sample(binding, density, x, roof + 1, z) <= 0) continue;
|
||||
int score = Math.min(48, roof - y);
|
||||
for (int dx : new int[]{-4, 0, 4}) for (int dz : new int[]{-4, 0, 4})
|
||||
if (sample(binding, density, x + dx, y, z + dz) > 0 && sample(binding, density, x + dx, roof, z + dz) > 0) score += 8;
|
||||
bucket.add(new Site(biome, x, y, roof, z, score));
|
||||
break;
|
||||
}
|
||||
}
|
||||
List<Site> sites = new ArrayList<>();
|
||||
for (String cave : CAVES) {
|
||||
var ordered = candidates.get(cave).stream().sorted(Comparator.comparingInt(Site::score).reversed()
|
||||
.thenComparingDouble(s -> Math.hypot(s.x(), s.z())).thenComparingInt(Site::x).thenComparingInt(Site::z)).toList();
|
||||
for (Site candidate : ordered.stream().limit(32).toList()) {
|
||||
var base = new BlockPos(candidate.x(), candidate.floorY() + 1, candidate.z());
|
||||
if (!PopulationHydrologyRuntime.protects(binding.generator(), binding.random(), base, 8,
|
||||
candidate.roofY() - candidate.floorY(), 3)) { sites.add(candidate); break; }
|
||||
}
|
||||
}
|
||||
return List.copyOf(sites);
|
||||
}
|
||||
|
||||
private static float sample(PopulationGenerationContext.Binding binding, DensityFunction density, int x, int y, int z) {
|
||||
return binding.random().sampleBlockValueUncached(density, x, y, z);
|
||||
}
|
||||
private static float natural(PopulationGenerationContext.Binding binding, BlockPos pos) {
|
||||
return sample(binding, binding.generator().generatorSettings().value().noiseRouter().finalDensity(), pos.getX(), pos.getY(), pos.getZ());
|
||||
}
|
||||
private static long count(Map<String, Map<String, Long>> counts, String biome, String block) { return counts.get(biome).getOrDefault(block, 0L); }
|
||||
private static BlockPos position(PopulationHydrology.Position p) { return new BlockPos(p.x(), p.y(), p.z()); }
|
||||
private static PopulationGenerationContext.Binding binding(ServerLevel level) { return Objects.requireNonNull(PopulationGenerationContext.of(level)); }
|
||||
private static SanctuaryChunkGenerator unified(ServerLevel level) { return (SanctuaryChunkGenerator) level.getChunkSource().getGenerator(); }
|
||||
private static LevelChunk full(GameTestHelper helper, ServerLevel level, int x, int z) {
|
||||
var chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, true);
|
||||
helper.assertTrue(chunk instanceof LevelChunk, "Inspect only real fully generated chunks"); return (LevelChunk) chunk;
|
||||
}
|
||||
private static Map<String, Object> report(ServerLevel level) {
|
||||
var report = new LinkedHashMap<String, Object>();
|
||||
report.put("generation", 13); report.put("seed", level.getSeed()); report.put("diameter", unified(level).initialDiameter());
|
||||
report.put("mod_version", FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow().getMetadata().getVersion().getFriendlyString());
|
||||
return report;
|
||||
}
|
||||
private static void write(ServerLevel level, String kind, Object report) throws IOException {
|
||||
Path target = Path.of("diagnostics", "unified-" + unified(level).initialDiameter() + "-seed-" + level.getSeed() + "-" + kind + ".json");
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.writeString(target, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
|
||||
SanctuaryMod.LOGGER.info("Unified {} seed {} wrote {}", unified(level).initialDiameter(), level.getSeed(), target);
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
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.tags.WorldPresetTags;
|
||||
import net.minecraft.world.level.dimension.LevelStem;
|
||||
import net.minecraft.world.level.levelgen.WorldGenSettings;
|
||||
import net.minecraft.world.level.levelgen.WorldOptions;
|
||||
import net.minecraft.world.level.storage.LevelStorageSource;
|
||||
|
||||
/** Storage contract shared by the public preset and its three client-selected sizes. */
|
||||
final class UnifiedWorldSettingsDiagnostics {
|
||||
static void verify(GameTestHelper helper) throws IOException {
|
||||
var access = helper.getLevel().registryAccess();
|
||||
var presets = access.lookupOrThrow(Registries.WORLD_PRESET);
|
||||
var publicPreset = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET,
|
||||
SanctuaryMod.id("sanctuary"))).value();
|
||||
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 historical14 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v14"))).value().createWorldDimensions();
|
||||
helper.assertTrue(historical14.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 14 && old.source().generation() == 14,
|
||||
"Historical alpha.14 keeps its own biome codec and city generator");
|
||||
var historical15 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v15"))).value().createWorldDimensions();
|
||||
helper.assertTrue(historical15.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 15 && old.source().generation() == 15,
|
||||
"Historical alpha.15 keeps its own biome codec and city generator");
|
||||
var historical16 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v16"))).value().createWorldDimensions();
|
||||
helper.assertTrue(historical16.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 16
|
||||
&& old.source().generation() == 16 && !old.experimentalStructures(), "Historical16 never gains experimental structures");
|
||||
var historical17 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v17"))).value().createWorldDimensions();
|
||||
helper.assertTrue(historical17.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 17
|
||||
&& old.source().generation() == 17 && old.experimentalStructures(), "Historical17 keeps its original experimental generator");
|
||||
var historical19 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v19"))).value().createWorldDimensions();
|
||||
helper.assertTrue(historical19.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 19
|
||||
&& old.source().generation() == 19 && old.experimentalStructures(), "Historical19 keeps its original renderer and world settings");
|
||||
var generator = (SanctuaryChunkGenerator) standard.overworld();
|
||||
var historical20 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v20")));
|
||||
helper.assertTrue(historical20.value().createWorldDimensions().overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 20
|
||||
&& old.source().generation() == 20 && old.experimentalStructures(), "Historical20 keeps its original architecture");
|
||||
var historical21 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v21")));
|
||||
helper.assertTrue(historical21.value().createWorldDimensions().overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 21
|
||||
&& old.source().generation() == 21 && old.experimentalStructures(), "Historical21 keeps its original placement rules");
|
||||
helper.assertTrue(generator.rootGeneration() == 22 && generator.source().generation() == 22 && generator.experimentalStructures(), "The public preset uses generation22 with its new-world option");
|
||||
helper.assertTrue(generator.initialDiameter() == 724, "The public preset defaults to Medium / 724 blocks");
|
||||
verifyConstructionMasks(helper, (SanctuaryChunkGenerator) historical21.value().createWorldDimensions().overworld(), generator);
|
||||
for (var tag : List.of(WorldPresetTags.NORMAL, WorldPresetTags.EXTENDED)) {
|
||||
var visible = presets.getOrThrow(tag).stream().map(holder -> holder.getRegisteredName())
|
||||
.filter(name -> name.startsWith("sanctuary:")).collect(Collectors.toSet());
|
||||
helper.assertTrue(visible.equals(Set.of("sanctuary:sanctuary")),
|
||||
"Only one Sanctuary choice is present in the normal and extended world-type lists");
|
||||
}
|
||||
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, access);
|
||||
var activeCodec = generator.rootGeneration() == 22 ? SanctuaryChunkGenerator.SELECTIVE22_CODEC : generator.rootGeneration() == 21 ? SanctuaryChunkGenerator.NETWORK21_CODEC : generator.rootGeneration() == 20 ? SanctuaryChunkGenerator.ARCHITECTURE20_CODEC : generator.rootGeneration() == 19 ? SanctuaryChunkGenerator.HERITAGE19_CODEC : generator.rootGeneration() == 17 ? SanctuaryChunkGenerator.RUINS17_CODEC : generator.rootGeneration() == 16 ? SanctuaryChunkGenerator.CITY16_CODEC : generator.rootGeneration() == 15 ? SanctuaryChunkGenerator.CITY15_CODEC : generator.rootGeneration() == 14 ? SanctuaryChunkGenerator.CITY_CODEC : SanctuaryChunkGenerator.CODEC;
|
||||
var serialized = activeCodec.codec().encodeStart(ops, generator).getOrThrow().getAsJsonObject();
|
||||
serialized.remove("experimental_structures");
|
||||
helper.assertTrue(activeCodec.codec().parse(ops, serialized).getOrThrow().experimentalStructures(),
|
||||
"Generation22 omitted experimental option defaults to enabled");
|
||||
helper.assertTrue(!((SanctuaryChunkGenerator) historical16.overworld()).withExperimentalStructures(true).experimentalStructures(),
|
||||
"The new-world switch cannot activate experimental generation in a historical16 generator");
|
||||
serialized.remove("initial_diameter");
|
||||
helper.assertTrue(activeCodec.codec().parse(ops, serialized).getOrThrow().initialDiameter() == 724,
|
||||
"An omitted new-generation size defaults to Medium");
|
||||
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(),
|
||||
"Only the three supported initial diameters are accepted");
|
||||
}
|
||||
|
||||
Path directory = Path.of("diagnostics", "unified-world-settings-seed-" + helper.getLevel().getSeed()).toAbsolutePath();
|
||||
Files.createDirectories(directory);
|
||||
var storage = LevelStorageSource.createDefault(directory);
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
for (int diameter : new int[]{512, 724, 1024}) for (boolean experimental : new boolean[]{false, true}) {
|
||||
var chosen = generator.withInitialDiameter(diameter).withExperimentalStructures(experimental);
|
||||
helper.assertTrue(generator.initialDiameter() == 724 && chosen.source() != generator.source(),
|
||||
"Customizing a new world must copy the generator and its mutable biome source");
|
||||
var dimensions = standard.replaceOverworldGenerator(access, chosen);
|
||||
for (var dimension : List.of(LevelStem.NETHER, LevelStem.END)) {
|
||||
helper.assertTrue(dimensions.get(dimension).equals(standard.get(dimension)),
|
||||
"Customizing the island preserves the other dimensions");
|
||||
}
|
||||
var original = new WorldGenSettings(new WorldOptions(helper.getLevel().getSeed(), false, true), dimensions);
|
||||
String name = "diameter-" + diameter + "-experimental-" + experimental;
|
||||
try (var save = storage.createAccess(name)) {
|
||||
LevelStorageSource.writeWorldGenSettings(access, storage.getLevelPath(name), original);
|
||||
var restored = LevelStorageSource.readExistingSavedData(save, access, WorldGenSettings.TYPE).getOrThrow();
|
||||
helper.assertTrue(restored.dimensions().overworld() instanceof SanctuaryChunkGenerator reloaded
|
||||
&& reloaded.initialDiameter() == diameter && reloaded.rootGeneration() == generator.rootGeneration() && reloaded.experimentalStructures() == experimental,
|
||||
"The selected diameter must survive real world-settings storage");
|
||||
helper.assertTrue(WorldGenSettings.CODEC.encodeStart(ops, restored).getOrThrow()
|
||||
.equals(WorldGenSettings.CODEC.encodeStart(ops, original).getOrThrow()),
|
||||
"Seed, generation options and all dimensions survive the disk round trip");
|
||||
results.add(Map.of("diameter", diameter, "experimental_structures", experimental, "disk_round_trip", true));
|
||||
}
|
||||
}
|
||||
Files.writeString(directory.resolve("round-trip.json"), new GsonBuilder().setPrettyPrinting().create().toJson(Map.of(
|
||||
"seed", helper.getLevel().getSeed(), "default_diameter", 724,
|
||||
"public_presets", List.of("sanctuary:sanctuary"), "sizes", results,
|
||||
"invalid_diameters_rejected", true, "copied_biome_source", true, "passed", true)) + "\n");
|
||||
}
|
||||
|
||||
private static void verifyConstructionMasks(GameTestHelper helper, SanctuaryChunkGenerator historical21,
|
||||
SanctuaryChunkGenerator generation22) {
|
||||
var outside = new net.minecraft.core.BlockPos(200_000, 192, 200_000);
|
||||
// Decoding a generation must not reserve the world before any structures are planned.
|
||||
// These checks also cover the old OFF contract without initializing another island.
|
||||
for (var generator : List.of(historical21, historical21.withExperimentalStructures(false),
|
||||
generation22, generation22.withExperimentalStructures(false))) {
|
||||
helper.assertTrue(!generator.protectsCityDecoration(outside),
|
||||
"Unplanned generation" + generator.rootGeneration() + " does not protect arbitrary blocks");
|
||||
helper.assertTrue(!generator.intersectsExperimentalStructures(outside.getX(), outside.getY(), outside.getZ(),
|
||||
outside.getX() + 1, outside.getY() + 1, outside.getZ() + 1),
|
||||
"Unplanned generation" + generator.rootGeneration() + " does not reject arbitrary structure bounds");
|
||||
}
|
||||
// Reuse the attached world's real plans: no extra native-noise planning or chunk loads.
|
||||
if (!(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator active)
|
||||
|| active.rootGeneration() != 21 && active.rootGeneration() != 22) return;
|
||||
helper.assertTrue(!active.protectsCityDecoration(outside)
|
||||
&& !active.intersectsExperimentalStructures(outside.getX(), outside.getY(), outside.getZ(),
|
||||
outside.getX() + 1, outside.getY() + 1, outside.getZ() + 1),
|
||||
"Prepared construction masks remain local to their real structures");
|
||||
var interior = new ArrayList<net.minecraft.core.BlockPos>();
|
||||
for (var module : active.transitPlan21().modules())
|
||||
interior.add(new net.minecraft.core.BlockPos(module.centerX(), module.floorY(), module.centerZ()));
|
||||
for (var network : active.undergroundHall22().networks()) for (var module : network.modules())
|
||||
interior.add(new net.minecraft.core.BlockPos(module.centerX(), module.floorY(), module.centerZ()));
|
||||
if (active.rootGeneration() == 22 && helper.getLevel().structureManager().shouldGenerateStructures())
|
||||
helper.assertTrue(!interior.isEmpty(), "The attached generation22 world has core construction to protect");
|
||||
for (var position : interior) helper.assertTrue(active.protectsCityDecoration(position)
|
||||
&& active.intersectsExperimentalStructures(position.getX(), position.getY(), position.getZ(),
|
||||
position.getX(), position.getY(), position.getZ()),
|
||||
"Real underground construction remains protected from decoration and overlapping native structures");
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -23,13 +23,20 @@ public final class WorldgenPerformanceGameTests {
|
||||
@GameTest(maxTicks = 3000)
|
||||
public void spawnAreaAndExploration(GameTestHelper helper) throws Exception {
|
||||
ServerLevel level = helper.getLevel();
|
||||
var capacity = IslandCapacity.forGenerator(level.getChunkSource().getGenerator());
|
||||
var binding = fr.koka.sanctuary.worldgen.PopulationGenerationContext.of(level);
|
||||
var capacity = IslandCapacity.forGenerator(binding.generator());
|
||||
var spawn = SanctuaryWorldGameTests.initialSpawn.pos();
|
||||
int cx = spawn.getX() >> 4, cz = spawn.getZ() >> 4;
|
||||
var report = new LinkedHashMap<String, Object>();
|
||||
report.put("version", FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow()
|
||||
.getMetadata().getVersion().getFriendlyString());
|
||||
report.put("players", capacity.players()); report.put("seed", level.getSeed());
|
||||
var host = level.getChunkSource().getGenerator();
|
||||
String profile = "players-" + capacity.players();
|
||||
if (host instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator unified) {
|
||||
report.put("generation", 13); report.put("initial_diameter", unified.initialDiameter());
|
||||
profile = "unified-" + unified.initialDiameter();
|
||||
}
|
||||
report.put("spawn", List.of(spawn.getX(), spawn.getY(), spawn.getZ()));
|
||||
report.put("java", System.getProperty("java.version"));
|
||||
report.put("processors", Runtime.getRuntime().availableProcessors());
|
||||
@@ -78,13 +85,13 @@ public final class WorldgenPerformanceGameTests {
|
||||
report.put("exploration_ms", millis(System.nanoTime() - exploreStart));
|
||||
report.put("exploration_full_chunks", 80); report.put("exploration_steps", steps);
|
||||
report.put("exploration_chunk_latency_ms", latency(chunkTimes));
|
||||
report.put("regions_cached", PopulationHydrologyRuntime.cachedRegionCount(level.getChunkSource().randomState()));
|
||||
report.put("regions_cached", PopulationHydrologyRuntime.cachedRegionCount(binding.random()));
|
||||
report.put("scope", "Fresh development world; 289 FULL chunks around natural spawn, then 80 eastward FULL chunks. "
|
||||
+ "Synchronous server requests include dependent generation up to FULL, not guaranteed ticking readiness, client GPU, network, FPS or player load. "
|
||||
+ "World creation before the test is timed separately by Minecraft's startup log.");
|
||||
helper.assertTrue(count == 289 && land > 100, "The benchmark must generate actual playable terrain");
|
||||
report.put("passed", true);
|
||||
Path output = Path.of("diagnostics", "generation-performance-players-" + capacity.players() + "-seed-" + level.getSeed() + ".json");
|
||||
Path output = Path.of("diagnostics", "generation-performance-" + profile + "-seed-" + level.getSeed() + ".json");
|
||||
Files.createDirectories(output.getParent());
|
||||
Files.writeString(output, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
|
||||
helper.succeed();
|
||||
|
||||
+26
-4
@@ -21,7 +21,9 @@ 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.selective22") || (Boolean.getBoolean("sanctuary.test.heritage20") || Boolean.getBoolean("sanctuary.test.heritage21") || Boolean.getBoolean("sanctuary.test.lostCity21") || Boolean.getBoolean("sanctuary.test.transit21")) || Boolean.getBoolean("sanctuary.test.lostCity20")
|
||||
|| Boolean.getBoolean("sanctuary.test.heritage19") || Boolean.getBoolean("sanctuary.test.ruins17") || Boolean.getBoolean("sanctuary.test.expansion") || Boolean.getBoolean("sanctuary.test.lostCity")
|
||||
|| Boolean.getBoolean("sanctuary.test.lostCity16") || Boolean.getBoolean("sanctuary.test.lostCity15") || Boolean.getBoolean("sanctuary.test.voidReuse")) {
|
||||
long until = System.nanoTime() + 20_000_000L;
|
||||
long remaining;
|
||||
// Worker completion unparks the server; keep the pacing deadline despite those signals.
|
||||
@@ -44,11 +46,16 @@ public abstract class SanctuaryGameTestServerMixin {
|
||||
@Redirect(method = "lambda$create$1", at = @At(value = "FIELD", target =
|
||||
"Lnet/minecraft/world/level/levelgen/presets/WorldPresets;FLAT_ALL_DIMENSIONS:Lnet/minecraft/resources/ResourceKey;"))
|
||||
private static ResourceKey<WorldPreset> sanctuary$selectActualPreset() {
|
||||
if (Boolean.getBoolean("sanctuary.test.unified")) {
|
||||
int generation = Integer.getInteger("sanctuary.test.generation", 22);
|
||||
if (generation != 13 && generation != 14 && generation != 15 && generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22) throw new IllegalArgumentException("Unsupported test generation");
|
||||
return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(generation == 22 ? "sanctuary" : "sanctuary_v" + generation));
|
||||
}
|
||||
if (Boolean.getBoolean("sanctuary.test.expansion")) return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("expansion_lab"));
|
||||
int players = Integer.getInteger("sanctuary.test.players", 10);
|
||||
String preset = switch (players) {
|
||||
case 5 -> "sanctuary_5";
|
||||
case 10 -> "sanctuary";
|
||||
case 10 -> "sanctuary_10";
|
||||
case 20 -> "sanctuary_20";
|
||||
case 100 -> "sanctuary_100";
|
||||
default -> throw new IllegalArgumentException("Test geometry must be 5, 10, 20 or 100");
|
||||
@@ -56,12 +63,27 @@ public abstract class SanctuaryGameTestServerMixin {
|
||||
return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(preset));
|
||||
}
|
||||
|
||||
@Redirect(method = "lambda$create$1", at = @At(value = "INVOKE", target =
|
||||
"Lnet/minecraft/world/level/levelgen/presets/WorldPreset;createWorldDimensions()Lnet/minecraft/world/level/levelgen/WorldDimensions;"))
|
||||
private static net.minecraft.world.level.levelgen.WorldDimensions sanctuary$customizeSize(WorldPreset preset,
|
||||
net.minecraft.world.level.LevelSettings settings, net.minecraft.server.WorldLoader.DataLoadContext context) {
|
||||
var dimensions = preset.createWorldDimensions();
|
||||
if (Boolean.getBoolean("sanctuary.test.unified")) {
|
||||
var generator = (fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator) dimensions.overworld();
|
||||
return dimensions.replaceOverworldGenerator(context.datapackWorldRegistries(),
|
||||
generator.withInitialDiameter(Integer.getInteger("sanctuary.test.diameter", 724))
|
||||
.withExperimentalStructures(Boolean.parseBoolean(System.getProperty("sanctuary.test.experimentalStructures", "true"))));
|
||||
}
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
@Inject(method = "startTests", at = @At("HEAD"))
|
||||
private void sanctuary$captureInitialSpawn(ServerLevel level, CallbackInfo ci) {
|
||||
// The framework moves its own spawn to a random test grid after this point.
|
||||
SanctuaryWorldGameTests.initialSpawn = level.getRespawnData();
|
||||
if (Boolean.getBoolean("sanctuary.test.expansion")) return;
|
||||
fr.koka.sanctuary.gametest.PopulationDiagnostics.regionsAtInitialSpawn =
|
||||
fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime.cachedRegionCount(level.getChunkSource().randomState());
|
||||
var binding = fr.koka.sanctuary.worldgen.PopulationGenerationContext.of(level);
|
||||
fr.koka.sanctuary.gametest.PopulationDiagnostics.regionsAtInitialSpawn = binding == null ? 0 :
|
||||
fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime.cachedRegionCount(binding.random());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package fr.koka.sanctuary;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryBiomeSource;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.CavernIslandBiomeSource;
|
||||
import fr.koka.sanctuary.worldgen.PopulationIslandDensity;
|
||||
import fr.koka.sanctuary.worldgen.PopulationIslandBiomeSource;
|
||||
@@ -45,7 +48,31 @@ public final class SanctuaryMod implements ModInitializer {
|
||||
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("expansion_island"), fr.koka.sanctuary.expansion.ExpansionDensity.CODEC);
|
||||
fr.koka.sanctuary.expansion.ExpansionRuntime.register();
|
||||
fr.koka.sanctuary.expansion.ExpansionCommands.register();
|
||||
fr.koka.sanctuary.worldgen.heritage.HeritageCommands19.register();
|
||||
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("main_island"), MainIslandDensity.CODEC);
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v13"), SanctuaryChunkGenerator.CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v13"), SanctuaryBiomeSource.CODEC);
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v14"), SanctuaryChunkGenerator.CITY_CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v14"), SanctuaryBiomeSource.CITY_CODEC);
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v15"), SanctuaryChunkGenerator.CITY15_CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v15"), SanctuaryBiomeSource.CITY15_CODEC);
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v16"), SanctuaryChunkGenerator.CITY16_CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v16"), SanctuaryBiomeSource.CITY16_CODEC);
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v17"), SanctuaryChunkGenerator.RUINS17_CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v17"), SanctuaryBiomeSource.RUINS17_CODEC);
|
||||
fr.koka.sanctuary.worldgen.city.LostCityStructures.register();
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v19"), SanctuaryChunkGenerator.HERITAGE19_CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v19"), SanctuaryBiomeSource.HERITAGE19_CODEC);
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v20"), SanctuaryChunkGenerator.ARCHITECTURE20_CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v20"), SanctuaryBiomeSource.ARCHITECTURE20_CODEC);
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v21"), SanctuaryChunkGenerator.NETWORK21_CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v21"), SanctuaryBiomeSource.NETWORK21_CODEC);
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v22"), SanctuaryChunkGenerator.SELECTIVE22_CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v22"), SanctuaryBiomeSource.SELECTIVE22_CODEC);
|
||||
fr.koka.sanctuary.worldgen.transit.TransitStructures21.register();
|
||||
fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.register();
|
||||
fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.register();
|
||||
fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.register();
|
||||
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("population_island"), PopulationIslandDensity.CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("population_island"), PopulationIslandBiomeSource.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("population_groves"), PopulationGrovesFeature.CODEC);
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.CycleButton;
|
||||
import net.minecraft.client.gui.components.MultiLineTextWidget;
|
||||
import net.minecraft.client.gui.layouts.HeaderAndFooterLayout;
|
||||
import net.minecraft.client.gui.layouts.LinearLayout;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen;
|
||||
import net.minecraft.client.gui.screens.worldselection.WorldCreationContext;
|
||||
import net.minecraft.network.chat.CommonComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
/** Edits size and the saved experiment switch of a pending new world. Cancel applies neither. */
|
||||
public final class SanctuaryWorldOptionsScreen extends Screen {
|
||||
private final CreateWorldScreen parent;
|
||||
private final SanctuaryChunkGenerator generator;
|
||||
private final HeaderAndFooterLayout layout = new HeaderAndFooterLayout(this);
|
||||
private IslandSize selectedSize;
|
||||
private boolean experimental;
|
||||
private MultiLineTextWidget diameterHint;
|
||||
|
||||
public SanctuaryWorldOptionsScreen(CreateWorldScreen parent, WorldCreationContext context) {
|
||||
super(Component.translatable("sanctuary.world_options.title"));
|
||||
this.parent = parent;
|
||||
if (!(context.selectedDimensions().overworld() instanceof SanctuaryChunkGenerator sanctuary)) {
|
||||
throw new IllegalArgumentException("Sanctuary options require the unified generator");
|
||||
}
|
||||
generator = sanctuary;
|
||||
selectedSize = IslandSize.fromDiameter(generator.initialDiameter());
|
||||
experimental = generator.experimentalStructures();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
layout.removeChildren();
|
||||
layout.addTitleHeader(title, font);
|
||||
|
||||
LinearLayout contents = LinearLayout.vertical().spacing(12);
|
||||
contents.defaultCellSetting().alignHorizontallyCenter();
|
||||
contents.addChild(new MultiLineTextWidget(Component.translatable("sanctuary.world_options.description"), font)
|
||||
.setMaxWidth(310).setCentered(true));
|
||||
CycleButton<IslandSize> sizeButton = CycleButton.builder(IslandSize::label, selectedSize)
|
||||
.withValues(IslandSize.values())
|
||||
.create(0, 0, 240, 20, Component.translatable("sanctuary.world_options.size"), (button, size) -> {
|
||||
selectedSize = size;
|
||||
diameterHint.setMessage(diameterHint());
|
||||
layout.arrangeElements();
|
||||
});
|
||||
contents.addChild(sizeButton);
|
||||
diameterHint = contents.addChild(new MultiLineTextWidget(diameterHint(), font)
|
||||
.setMaxWidth(310).setCentered(true));
|
||||
if (generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22) {
|
||||
contents.addChild(CycleButton.builder((Boolean enabled) -> enabled ? CommonComponents.OPTION_ON : CommonComponents.OPTION_OFF, experimental)
|
||||
.withValues(true, false).create(0, 0, 280, 20, Component.translatable("sanctuary.world_options.experimental"),
|
||||
(button, value) -> experimental = value));
|
||||
contents.addChild(new MultiLineTextWidget(Component.translatable(generator.rootGeneration() == 22 ? "sanctuary.world_options.heritage22_hint" : (generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22) ? "sanctuary.world_options.heritage_hint" : "sanctuary.world_options.experimental_hint"), font)
|
||||
.setMaxWidth(310).setCentered(true));
|
||||
}
|
||||
layout.addToContents(contents);
|
||||
|
||||
LinearLayout actions = LinearLayout.horizontal().spacing(8);
|
||||
actions.addChild(Button.builder(CommonComponents.GUI_DONE, button -> applyAndClose()).width(150).build());
|
||||
actions.addChild(Button.builder(CommonComponents.GUI_CANCEL, button -> onClose()).width(150).build());
|
||||
layout.addToFooter(actions);
|
||||
layout.visitWidgets(this::addRenderableWidget);
|
||||
repositionElements();
|
||||
}
|
||||
|
||||
private Component diameterHint() {
|
||||
return Component.translatable("sanctuary.world_options.diameter", selectedSize.diameter);
|
||||
}
|
||||
|
||||
private void applyAndClose() {
|
||||
parent.getUiState().updateDimensions((registries, dimensions) -> dimensions.replaceOverworldGenerator(
|
||||
registries, generator.withInitialDiameter(selectedSize.diameter).withExperimentalStructures(experimental)));
|
||||
onClose();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void repositionElements() {
|
||||
layout.arrangeElements();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose() {
|
||||
minecraft.gui.setScreen(parent);
|
||||
}
|
||||
|
||||
private enum IslandSize {
|
||||
SMALL("small", 512), MEDIUM("medium", 724), LARGE("large", 1024);
|
||||
|
||||
private final String id;
|
||||
private final int diameter;
|
||||
|
||||
IslandSize(String id, int diameter) {
|
||||
this.id = id;
|
||||
this.diameter = diameter;
|
||||
}
|
||||
|
||||
Component label() {
|
||||
return Component.translatable("sanctuary.world_options.size." + id);
|
||||
}
|
||||
|
||||
static IslandSize fromDiameter(int diameter) {
|
||||
for (IslandSize size : values()) {
|
||||
if (size.diameter == diameter) return size;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported Sanctuary island diameter: " + diameter);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,45 +23,94 @@ public final class ExpansionAdmission {
|
||||
private ExpansionAdmission() { }
|
||||
|
||||
public static void verify(ServerLevel level, ExpansionIsland island) throws IOException {
|
||||
if (!level.getServer().isSameThread()) throw new IOException("Expansion admission must run on the server thread");
|
||||
if (island.origin()) throw new IOException("The existing Sanctuary region cannot be admitted again");
|
||||
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");
|
||||
verify(level, island, searchDeadline());
|
||||
}
|
||||
|
||||
/** A caller searching several positions must share this deadline across all admission attempts. */
|
||||
public static long searchDeadline() {
|
||||
return System.nanoTime() + DEADLINE_NANOS;
|
||||
}
|
||||
|
||||
public static void verify(ServerLevel level, ExpansionIsland island, long deadlineNanos) throws IOException {
|
||||
checkDeadline(deadlineNanos);
|
||||
List<ChunkPos> positions = positions(level, island);
|
||||
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 (ChunkPos position : positions) rejectHolder(map, position);
|
||||
long deadline = System.nanoTime() + DEADLINE_NANOS;
|
||||
verifyHolders(map, positions, deadlineNanos);
|
||||
for (int start = 0; start < positions.size(); start += READ_BATCH) {
|
||||
checkDeadline(deadlineNanos);
|
||||
int end = Math.min(positions.size(), start + READ_BATCH);
|
||||
List<CompletableFuture<Optional<CompoundTag>>> reads = new ArrayList<>();
|
||||
for (int i = start; i < end; i++) reads.add(map.read(positions.get(i)));
|
||||
ChunkPos occupied = null;
|
||||
try {
|
||||
for (int i = 0; i < reads.size(); i++) {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
long remaining = deadlineNanos - System.nanoTime();
|
||||
if (remaining <= 0) throw new TimeoutException("Admission deadline expired");
|
||||
if (reads.get(i).get(remaining, TimeUnit.NANOSECONDS).isPresent()) {
|
||||
throw new IOException("Expansion intersects an existing saved chunk: " + positions.get(start + i));
|
||||
occupied = positions.get(start + i);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Expansion admission interrupted", e);
|
||||
} catch (ExecutionException | TimeoutException e) {
|
||||
} catch (TimeoutException e) {
|
||||
throw new IOException("search_timeout", e);
|
||||
} catch (ExecutionException e) {
|
||||
throw new IOException("Expansion could not prove that every reserved chunk is absent", e);
|
||||
}
|
||||
// Drain every submitted read before skipping this occupied region, so a failed read
|
||||
// is never swallowed as an ordinary collision and no next attempt overlaps the batch.
|
||||
checkDeadline(deadlineNanos);
|
||||
if (occupied != null) throw new OccupiedException("Expansion intersects an existing saved chunk: " + occupied);
|
||||
}
|
||||
for (ChunkPos position : positions) rejectHolder(map, position);
|
||||
verifyHolders(map, positions, deadlineNanos);
|
||||
// Caller must commit and publish on this same server turn, without yielding or processing ticks.
|
||||
}
|
||||
|
||||
/** Recheck active requests immediately before commit, after read-only terrain prospecting. */
|
||||
public static void verifyHolders(ServerLevel level, ExpansionIsland island, long deadlineNanos) throws IOException {
|
||||
checkDeadline(deadlineNanos);
|
||||
verifyHolders(level.getChunkSource().chunkMap, positions(level, island), deadlineNanos);
|
||||
}
|
||||
|
||||
private static List<ChunkPos> positions(ServerLevel level, ExpansionIsland island) throws IOException {
|
||||
if (!level.getServer().isSameThread()) throw new IOException("Expansion admission must run on the server thread");
|
||||
if (island.origin()) throw new IOException("The existing Sanctuary region cannot be admitted again");
|
||||
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);
|
||||
int limit = ExpansionIsland.spatialGeneration(island.generation()) ? 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++)
|
||||
if (!ExpansionIsland.spatialGeneration(island.generation()) || island.ownsChunk(x, z)) positions.add(new ChunkPos(x, z));
|
||||
return positions;
|
||||
}
|
||||
|
||||
private static void verifyHolders(ChunkMap map, List<ChunkPos> positions, long deadlineNanos) throws IOException {
|
||||
for (ChunkPos position : positions) {
|
||||
checkDeadline(deadlineNanos);
|
||||
rejectHolder(map, position);
|
||||
}
|
||||
}
|
||||
|
||||
public static void checkDeadline(long deadlineNanos) throws IOException {
|
||||
if (deadlineNanos - System.nanoTime() <= 0) throw new IOException("search_timeout");
|
||||
}
|
||||
|
||||
private static void rejectHolder(ChunkMap map, ChunkPos position) throws IOException {
|
||||
long key = position.pack();
|
||||
if (map.getUpdatingChunkIfPresent(key) != null || ((ExpansionChunkMapAccessor) map).sanctuary$pendingUnloads().containsKey(key)) {
|
||||
throw new IOException("Expansion intersects a loaded or unloading chunk: " + position);
|
||||
throw new OccupiedException("Expansion intersects a loaded or unloading chunk: " + position);
|
||||
}
|
||||
}
|
||||
|
||||
/** Only proven occupancy may move the search farther along the requested direction. */
|
||||
public static final class OccupiedException extends IOException {
|
||||
public OccupiedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import net.minecraft.world.level.biome.Climate;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/** All future holders are known up front; only the immutable region snapshot changes on activation. */
|
||||
public final class ExpansionBiomeSource extends BiomeSource {
|
||||
public class ExpansionBiomeSource extends BiomeSource {
|
||||
public static final List<String> REQUIRED_BIOMES = Stream.of(
|
||||
"the_void", "forest", "birch_forest", "flower_forest", "plains", "meadow",
|
||||
"taiga", "snowy_taiga", "snowy_plains", "grove", "ice_spikes", "frozen_peaks",
|
||||
@@ -59,7 +59,7 @@ public final class ExpansionBiomeSource extends BiomeSource {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override protected MapCodec<ExpansionBiomeSource> codec() { return CODEC; }
|
||||
@Override protected MapCodec<? extends ExpansionBiomeSource> codec() { return CODEC; }
|
||||
@Override protected Stream<Holder<Biome>> collectPossibleBiomes() { return biomes.stream(); }
|
||||
|
||||
@Override
|
||||
@@ -75,7 +75,7 @@ public final class ExpansionBiomeSource extends BiomeSource {
|
||||
return (quartX, quartY, quartZ) -> resolve(snapshot, quartX * 4, quartY * 4, quartZ * 4);
|
||||
}
|
||||
|
||||
private Holder<Biome> resolve(List<ExpansionIsland> snapshot, int x, int y, int z) {
|
||||
protected Holder<Biome> resolve(List<ExpansionIsland> snapshot, int x, int y, int z) {
|
||||
ExpansionIsland island = islandAt(snapshot, x, z);
|
||||
if (island == null || y < 0 || y >= 384) return biome("the_void");
|
||||
double scale = Math.clamp(island.diameter() / 3.0, 24.0, 144.0);
|
||||
@@ -93,7 +93,7 @@ public final 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";
|
||||
|
||||
+22
-8
@@ -39,26 +39,30 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemp
|
||||
|
||||
/** Laboratory only. Each immutable island has its own vanilla noise generator and seeded RandomState.
|
||||
* The host ChunkMap's empty fallback RandomState is deliberately never used for terrain. */
|
||||
public final class ExpansionChunkGenerator extends ChunkGenerator {
|
||||
public class ExpansionChunkGenerator extends ChunkGenerator {
|
||||
public static final MapCodec<ExpansionChunkGenerator> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
ExpansionBiomeSource.CODEC.fieldOf("biome_source").forGetter(ExpansionChunkGenerator::source),
|
||||
Codec.intRange(512, 724).optionalFieldOf("initial_diameter", 512).forGetter(ExpansionChunkGenerator::initialDiameter)
|
||||
).apply(i, ExpansionChunkGenerator::new));
|
||||
private final ExpansionBiomeSource source;
|
||||
private final int initialDiameter;
|
||||
private volatile RegistryAccess registries;
|
||||
protected volatile RegistryAccess registries;
|
||||
private final Map<ExpansionIsland, Context> contexts = new ConcurrentHashMap<>();
|
||||
private record Context(NoiseBasedChunkGenerator generator, RandomState random) {}
|
||||
protected record Context(NoiseBasedChunkGenerator generator, RandomState random) { public Context {} }
|
||||
|
||||
public ExpansionChunkGenerator(ExpansionBiomeSource source, int initialDiameter) {
|
||||
this(source, initialDiameter, false);
|
||||
}
|
||||
protected ExpansionChunkGenerator(ExpansionBiomeSource source, int initialDiameter, boolean unified) {
|
||||
super(source);
|
||||
if (initialDiameter != 512 && initialDiameter != 724) throw new IllegalArgumentException("Laboratory diameter must be 512 or 724");
|
||||
if (initialDiameter != 512 && initialDiameter != 724 && !(unified && initialDiameter == 1024))
|
||||
throw new IllegalArgumentException("Unsupported Sanctuary diameter");
|
||||
this.source = source;
|
||||
this.initialDiameter = initialDiameter;
|
||||
}
|
||||
public ExpansionBiomeSource source() { return source; }
|
||||
public int initialDiameter() { return initialDiameter; }
|
||||
@Override protected MapCodec<ExpansionChunkGenerator> codec() { return CODEC; }
|
||||
@Override protected MapCodec<? extends ExpansionChunkGenerator> codec() { return CODEC; }
|
||||
public void initialize(ServerLevel level, List<ExpansionIsland> islands) {
|
||||
registries = level.registryAccess();
|
||||
source.setIslands(islands);
|
||||
@@ -68,12 +72,13 @@ public final class ExpansionChunkGenerator extends ChunkGenerator {
|
||||
var key = island.ready();
|
||||
if (source.islands().stream().noneMatch(saved -> saved.ready().equals(key))) contexts.remove(key);
|
||||
}
|
||||
public Holder<Biome> structureBiome(Holder<Biome> biome) { return biome; }
|
||||
public ExpansionIsland islandAt(int x, int z) { return source.islandAt(x, z); }
|
||||
public ExpansionIsland islandForChunk(int x, int z) {
|
||||
for (ExpansionIsland island : source.islands()) if (island.ownsChunk(x, z)) return island;
|
||||
return null;
|
||||
}
|
||||
private Context context(ExpansionIsland island) {
|
||||
protected Context context(ExpansionIsland island) {
|
||||
if (registries == null) throw new IllegalStateException("Expansion generator used before world attachment");
|
||||
return contexts.computeIfAbsent(island.ready(), ignored -> {
|
||||
String baseKey = island.origin() && island.diameter() == 724 ? "population_10" : "population_5";
|
||||
@@ -92,6 +97,9 @@ public final class ExpansionChunkGenerator extends ChunkGenerator {
|
||||
return new Context(delegate, random);
|
||||
});
|
||||
}
|
||||
protected void afterTerrain(ExpansionIsland island, ChunkAccess chunk) { ExpansionTerrain.apply(island, chunk); }
|
||||
public String journalDirectory() { return "sanctuary-expansion-v1"; }
|
||||
public int rootGeneration() { return ExpansionIsland.GENERATION; }
|
||||
public RandomState randomState(ExpansionIsland island) { return context(island).random(); }
|
||||
public NoiseBasedChunkGenerator delegate(ExpansionIsland island) { return context(island).generator(); }
|
||||
@Override public CompletableFuture<ChunkAccess> createBiomes(RandomState ignored, Blender blender, StructureManager structures, ChunkAccess chunk) {
|
||||
@@ -106,12 +114,17 @@ public final class ExpansionChunkGenerator extends ChunkGenerator {
|
||||
if (island == null) return CompletableFuture.completedFuture(chunk);
|
||||
var context = context(island);
|
||||
return context.generator().buildTerrain(chunk, blender, context.random(), structures, manager, region, biomes).thenApply(generated -> {
|
||||
ExpansionTerrain.apply(island, generated);
|
||||
afterTerrain(island, generated);
|
||||
return generated;
|
||||
});
|
||||
}
|
||||
@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);
|
||||
fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.generate(this, registries, state, structures, chunk, templates, dimension);
|
||||
fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.generate(this, registries, state, structures, chunk, templates, dimension);
|
||||
fr.koka.sanctuary.worldgen.transit.TransitStructures21.generate(this, registries, state, structures, chunk, templates, dimension);
|
||||
fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.generate(this, registries, state, structures, chunk, templates, dimension);
|
||||
ExpansionStructures.generate(this, registries, state, structures, chunk, templates, dimension);
|
||||
}
|
||||
@Override public void spawnOriginalMobs(WorldGenRegion region) {
|
||||
@@ -141,6 +154,7 @@ public final class ExpansionChunkGenerator extends ChunkGenerator {
|
||||
}
|
||||
@Override public void addDebugScreenInfo(List<String> text, RandomState ignored, BlockPos pos, SamplerContext context) {
|
||||
var island = islandAt(pos.getX(), pos.getZ());
|
||||
text.add(island == null ? "Sanctuary alpha.12: void" : "Sanctuary alpha.12: " + island.id() + " / " + island.climate() + " / " + island.relief());
|
||||
String prefix = "Sanctuary gen " + rootGeneration() + ": ";
|
||||
text.add(island == null ? prefix + "void" : prefix + island.id() + " / " + island.climate() + " / " + island.relief());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,16 +48,18 @@ 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))
|
||||
.executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief"))))))));
|
||||
.executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief")))
|
||||
.then(distanceArgument().executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"),
|
||||
word(c, "relief"), IntegerArgumentType.getInteger(c, "distance")))))))));
|
||||
dispatcher.register(Commands.literal("sanctuary").requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)).then(expansion));
|
||||
});
|
||||
}
|
||||
@@ -76,20 +78,31 @@ 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 -> run(c, () -> {
|
||||
var session = ExpansionRuntime.session(c.getSource().getLevel());
|
||||
var island = preview ? session.candidate(word(c, "id"), word(c, "parent"), word(c, "direction"), word(c, "climate"),
|
||||
IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief"))
|
||||
: session.create(word(c, "id"), word(c, "parent"), word(c, "direction"), word(c, "climate"),
|
||||
IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief"));
|
||||
describe(c, island, preview);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
}))))))));
|
||||
.executes(c -> create(c, preview, 0))
|
||||
.then(distanceArgument().executes(c -> create(c, preview, IntegerArgumentType.getInteger(c, "distance"))))))))));
|
||||
}
|
||||
private static com.mojang.brigadier.builder.RequiredArgumentBuilder<CommandSourceStack, Integer> distanceArgument() {
|
||||
return Commands.argument("distance", IntegerArgumentType.integer(0, 100_000))
|
||||
.suggests((c, b) -> SharedSuggestionProvider.suggest(List.of("1024", "2048", "4096"), b));
|
||||
}
|
||||
private static int create(CommandContext<CommandSourceStack> c, boolean preview, int minimumDistance) {
|
||||
return run(c, () -> {
|
||||
var session = ExpansionRuntime.session(c.getSource().getLevel());
|
||||
var island = preview ? session.candidate(word(c, "id"), word(c, "parent"), word(c, "direction"), word(c, "climate"),
|
||||
IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief"), minimumDistance)
|
||||
: session.create(word(c, "id"), word(c, "parent"), word(c, "direction"), word(c, "climate"),
|
||||
IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief"), minimumDistance);
|
||||
describe(c, island, preview);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
});
|
||||
}
|
||||
private static int quick(CommandContext<CommandSourceStack> c, String climate, int diameter, String relief) {
|
||||
return quick(c, climate, diameter, relief, 0);
|
||||
}
|
||||
private static int quick(CommandContext<CommandSourceStack> c, String climate, int diameter, String relief, int minimumDistance) {
|
||||
return run(c, () -> {
|
||||
var session = ExpansionRuntime.session(c.getSource().getLevel());
|
||||
int next = session.islands().size();
|
||||
@@ -100,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);
|
||||
String id = "island_" + next;
|
||||
int chosenDiameter = diameter != 0 ? diameter : ExpansionIsland.spatialGeneration(session.islands().getFirst().generation())
|
||||
? ExpansionRandomSize.choose(c.getSource().getLevel().getSeed(), id) : 128;
|
||||
var island = session.create(id, "sanctuary", direction,
|
||||
climate == null ? ExpansionIsland.directionalClimate(direction) : climate, chosenDiameter, relief, minimumDistance);
|
||||
describe(c, island, false);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
});
|
||||
@@ -109,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,27 +26,40 @@ 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))) throw new IllegalArgumentException("Unsupported nominal diameter");
|
||||
if (!((spatialGeneration(generation) ? DIAMETERS : LEGACY_DIAMETERS).contains(diameter) || (id.equals("sanctuary") && (diameter == 724 || (generation == 13 || spatialGeneration(generation)) && diameter == 1024)))) throw new IllegalArgumentException("Unsupported nominal diameter");
|
||||
if (Math.abs((long) centerX) > 100_000 || Math.abs((long) centerZ) > 100_000 || (centerX & 15) != 0 || (centerZ & 15) != 0) throw new IllegalArgumentException("Invalid island centre");
|
||||
if (!List.of("reserved", "ready").contains(status) || generation != GENERATION) throw new IllegalArgumentException("Unsupported island state or generation");
|
||||
if (!List.of("reserved", "ready").contains(status) || (generation != GENERATION && !spatialGeneration(generation) && !(generation == 13 && id.equals("sanctuary")))) throw new IllegalArgumentException("Unsupported island state or generation");
|
||||
if (climate.equals("lost_city") && (!spatialGeneration(generation) || id.equals("sanctuary"))) throw new IllegalArgumentException("Lost City requires a generation 14, 15, 16, 17, 19, 20, 21 or 22 expansion");
|
||||
if (reuseToken == null || !reuseToken.isEmpty() && (!spatialGeneration(generation) || id.equals("sanctuary") || !reuseToken.matches("[0-9a-f]{64}")))
|
||||
throw new IllegalArgumentException("Invalid void reuse certificate token");
|
||||
}
|
||||
|
||||
public static ExpansionIsland origin(int diameter, long seed) {
|
||||
return new ExpansionIsland("sanctuary", "sanctuary", "north", "temperate", diameter, 0, 0, seed, "natural", "ready", GENERATION);
|
||||
return origin(diameter, seed, GENERATION);
|
||||
}
|
||||
public static ExpansionIsland origin(int diameter, long seed, int generation) {
|
||||
return new ExpansionIsland("sanctuary", "sanctuary", "north", "temperate", diameter, 0, 0, seed, "natural", "ready", generation);
|
||||
}
|
||||
|
||||
public static boolean spatialGeneration(int generation) { return generation == 14 || generation == 15 || generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22; }
|
||||
public boolean origin() { return id.equals("sanctuary"); }
|
||||
public double referenceDiameter() { return origin() && diameter == 724 ? Math.sqrt(2) * 512 : 512; }
|
||||
public double horizontalScale() { return origin() ? 1.0 : diameter / referenceDiameter(); }
|
||||
public double radius() { return diameter / 2.0; }
|
||||
public int terrainRadius() { 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 || spatialGeneration(generation)) && 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 + (spatialGeneration(generation) ? 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);
|
||||
@@ -53,10 +67,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 (spatialGeneration(generation) && generation == other.generation()) 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";
|
||||
|
||||
@@ -39,11 +39,25 @@ public final class ExpansionJournal {
|
||||
private final Path file;
|
||||
private final long seed;
|
||||
private final int rootDiameter;
|
||||
private final int rootGeneration;
|
||||
private final DirectorySync directorySync;
|
||||
private List<ExpansionIsland> islands;
|
||||
private boolean failed;
|
||||
|
||||
public ExpansionJournal(Path directory, long seed, int rootDiameter) throws IOException {
|
||||
if (rootDiameter != 512 && rootDiameter != 724) throw new IOException("Unsupported expansion root diameter");
|
||||
this(directory, seed, rootDiameter, ExpansionIsland.GENERATION);
|
||||
}
|
||||
public ExpansionJournal(Path directory, long seed, int rootDiameter, int rootGeneration) throws IOException {
|
||||
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 && !ExpansionIsland.spatialGeneration(rootGeneration)) throw new IOException("Unsupported root generation");
|
||||
if (rootDiameter != 512 && rootDiameter != 724 && !((rootGeneration == 13 || ExpansionIsland.spatialGeneration(rootGeneration)) && rootDiameter == 1024))
|
||||
throw new IOException("Unsupported expansion root diameter");
|
||||
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;
|
||||
@@ -55,7 +69,7 @@ public final class ExpansionJournal {
|
||||
// Creating the directory is the bootstrap marker. An interrupted bootstrap requires repair.
|
||||
Files.createDirectories(this.directory.getParent());
|
||||
Files.createDirectory(this.directory);
|
||||
this.islands = List.of(ExpansionIsland.origin(rootDiameter, seed));
|
||||
this.islands = List.of(ExpansionIsland.origin(rootDiameter, seed, rootGeneration));
|
||||
persist(this.islands);
|
||||
}
|
||||
}
|
||||
@@ -103,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;
|
||||
@@ -116,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");
|
||||
@@ -131,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 (ExpansionIsland.spatialGeneration(rootGeneration) && 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) {
|
||||
@@ -146,17 +176,18 @@ public final class ExpansionJournal {
|
||||
if (proposed == null || proposed.isEmpty() || proposed.size() > MAX_ISLANDS) throw new IOException("Expansion journal must contain 1 to 64 regions");
|
||||
List<ExpansionIsland> checked;
|
||||
try { checked = List.copyOf(proposed); } catch (RuntimeException e) { throw new IOException("Invalid expansion entries", e); }
|
||||
if (!checked.getFirst().equals(ExpansionIsland.origin(rootDiameter, seed))) throw new IOException("Sanctuary root region is immutable");
|
||||
if (!checked.getFirst().equals(ExpansionIsland.origin(rootDiameter, seed, rootGeneration))) throw new IOException("Sanctuary root region is immutable");
|
||||
Map<String, ExpansionIsland> previous = new HashMap<>();
|
||||
previous.put("sanctuary", checked.getFirst());
|
||||
for (int i = 1; i < checked.size(); i++) {
|
||||
ExpansionIsland island = checked.get(i);
|
||||
if (island.generation() != (ExpansionIsland.spatialGeneration(rootGeneration) ? rootGeneration : 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 (!(ExpansionIsland.spatialGeneration(island.generation()) ? ExpansionSpatialPlacement14.followsDirection(island.direction(), dx, dz) : followsDirection(island.direction(), dx, dz))) throw new IOException("Expansion position does not follow its direction: " + island.id());
|
||||
for (ExpansionIsland other : previous.values()) if (island.conflicts(other)) throw new IOException("Expansion reservations overlap: " + island.id() + " / " + other.id());
|
||||
previous.put(island.id(), island);
|
||||
}
|
||||
@@ -187,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;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Pure placement along a chosen ray; no chunk is inspected or generated here. */
|
||||
public final class ExpansionPlacement {
|
||||
public static final int MAX_ATTEMPTS = 32;
|
||||
public static final int MAX_DISTANCE = 100_000;
|
||||
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 ExpansionPlacement() { }
|
||||
|
||||
public static List<ExpansionIsland> candidates(long worldSeed, List<ExpansionIsland> islands,
|
||||
String id, String parentId, String direction, String climate, int diameter, String relief,
|
||||
int minimumDistance) {
|
||||
if (id == null || parentId == null || !id.matches("[a-z][a-z0-9_]{0,31}") || !parentId.matches("[a-z][a-z0-9_]{0,31}"))
|
||||
throw new IllegalArgumentException("invalid_id");
|
||||
if (id.equals("sanctuary")) throw new IllegalArgumentException("reserved_id");
|
||||
if (direction == null || !ExpansionIsland.DIRECTIONS.contains(direction)) throw new IllegalArgumentException("invalid_direction");
|
||||
if (climate == null || !ExpansionIsland.CLIMATES.contains(climate)) throw new IllegalArgumentException("invalid_climate");
|
||||
if (!ExpansionIsland.DIAMETERS.contains(diameter)) throw new IllegalArgumentException("invalid_diameter");
|
||||
if (relief == null || !ExpansionIsland.RELIEFS.contains(relief)) throw new IllegalArgumentException("invalid_relief");
|
||||
validateDistance(minimumDistance);
|
||||
int rootGeneration = islands.getFirst().generation();
|
||||
int generation = ExpansionIsland.spatialGeneration(rootGeneration) ? rootGeneration : ExpansionIsland.GENERATION;
|
||||
if ((climate.equals("lost_city") || diameter == 1024) && !ExpansionIsland.spatialGeneration(generation)) 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", generation);
|
||||
if (ExpansionIsland.spatialGeneration(generation)) 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;
|
||||
int start = Math.max(step, requestedComponent);
|
||||
List<ExpansionIsland> candidates = new ArrayList<>();
|
||||
for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
long component = (long) start + (long) step * attempt;
|
||||
long x = parent.centerX() + DX[ordinal] * component;
|
||||
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", generation);
|
||||
if (islands.stream().noneMatch(candidate::conflicts)) candidates.add(candidate);
|
||||
}
|
||||
return List.copyOf(candidates);
|
||||
}
|
||||
|
||||
public static void validateDistance(int minimumDistance) {
|
||||
if (minimumDistance < 0 || minimumDistance > MAX_DISTANCE) throw new IllegalArgumentException("invalid_distance");
|
||||
}
|
||||
|
||||
public static double distanceFromParent(ExpansionIsland island, ExpansionIsland parent) {
|
||||
return Math.hypot((double) island.centerX() - parent.centerX(), (double) island.centerZ() - parent.centerZ());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -33,11 +33,12 @@ public final class ExpansionRuntime {
|
||||
if (!(level.getChunkSource().getGenerator() instanceof ExpansionChunkGenerator generator)) return;
|
||||
if (level.dimension() != Level.OVERWORLD) throw new IllegalStateException("The expansion laboratory is Overworld only");
|
||||
try {
|
||||
var directory = server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-expansion-v1");
|
||||
var directory = server.getWorldPath(LevelResource.ROOT).resolve("data/" + generator.journalDirectory());
|
||||
if (server.getWorldData().overworldData().isInitialized()
|
||||
&& !java.nio.file.Files.isDirectory(directory, java.nio.file.LinkOption.NOFOLLOW_LINKS))
|
||||
throw new IOException("Initialized laboratory world is missing its expansion journal directory");
|
||||
var journal = new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter());
|
||||
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());
|
||||
}
|
||||
@@ -80,68 +82,75 @@ public final class ExpansionRuntime {
|
||||
return job == null ? (failure.isEmpty() ? "idle" : failure) : job.island.id() + ": " + job.index + "/" + job.chunks.size();
|
||||
}
|
||||
public ExpansionIsland candidate(String id, String parentId, String direction, String climate, int diameter, String relief) {
|
||||
if (id == null || parentId == null || !id.matches("[a-z][a-z0-9_]{0,31}") || !parentId.matches("[a-z][a-z0-9_]{0,31}"))
|
||||
throw new IllegalArgumentException("invalid_id");
|
||||
if (id.equals("sanctuary")) throw new IllegalArgumentException("reserved_id");
|
||||
if (direction == null || !ExpansionIsland.DIRECTIONS.contains(direction)) throw new IllegalArgumentException("invalid_direction");
|
||||
if (climate == null || !ExpansionIsland.CLIMATES.contains(climate)) throw new IllegalArgumentException("invalid_climate");
|
||||
if (!ExpansionIsland.DIAMETERS.contains(diameter)) throw new IllegalArgumentException("invalid_diameter");
|
||||
if (relief == null || !ExpansionIsland.RELIEFS.contains(relief)) throw new IllegalArgumentException("invalid_relief");
|
||||
var parent = island(parentId);
|
||||
if (!parent.status().equals("ready")) throw new IllegalArgumentException("parent_not_ready");
|
||||
int ordinal = ExpansionIsland.DIRECTIONS.indexOf(direction);
|
||||
int[] dx = {0, 1, 1, 1, 0, -1, -1, -1};
|
||||
int[] dz = {-1, -1, 0, 1, 1, 1, 0, -1};
|
||||
var sample = new ExpansionIsland(id, parentId, direction, climate, diameter, 0, 0,
|
||||
ExpansionIsland.deriveSeed(level.getSeed(), id), relief, "reserved", ExpansionIsland.GENERATION);
|
||||
int step = ((parent.reservedRadius() + sample.reservedRadius() + 63) / 16) * 16;
|
||||
// Eight short deterministic searches along the requested ray, without generating void.
|
||||
for (int ring = 1; ring <= 8; ring++) {
|
||||
int x = Math.addExact(parent.centerX(), dx[ordinal] * step * ring);
|
||||
int z = Math.addExact(parent.centerZ(), dz[ordinal] * step * ring);
|
||||
if (Math.abs((long) x) > 100_000 || Math.abs((long) z) > 100_000) break;
|
||||
var candidate = new ExpansionIsland(id, parentId, direction, climate, diameter, x, z,
|
||||
sample.seed(), relief, "reserved", ExpansionIsland.GENERATION);
|
||||
if (islands().stream().noneMatch(candidate::conflicts)) return candidate;
|
||||
}
|
||||
throw new IllegalArgumentException("no_space");
|
||||
return candidate(id, parentId, direction, climate, diameter, relief, 0);
|
||||
}
|
||||
public ExpansionIsland candidate(String id, String parentId, String direction, String climate, int diameter, String relief,
|
||||
int minimumDistance) {
|
||||
return ExpansionPlacement.candidates(level.getSeed(), islands(), id, parentId, direction, climate, diameter, relief, minimumDistance)
|
||||
.stream().findFirst().orElseThrow(() -> new IllegalArgumentException("no_free_space"));
|
||||
}
|
||||
public ExpansionIsland create(String id, String parent, String direction, String climate, int diameter, String relief) throws IOException {
|
||||
return create(id, parent, direction, climate, diameter, relief, 0);
|
||||
}
|
||||
public ExpansionIsland create(String id, String parent, String direction, String climate, int diameter, String relief,
|
||||
int minimumDistance) throws IOException {
|
||||
long deadline = ExpansionAdmission.searchDeadline();
|
||||
requireHealthyJournal();
|
||||
ExpansionPlacement.validateDistance(minimumDistance);
|
||||
var existing = islands().stream().filter(i -> i.id().equals(id)).findFirst();
|
||||
if (existing.isPresent()) {
|
||||
var value = existing.get();
|
||||
if (!value.parent().equals(parent) || !value.direction().equals(direction) || !value.climate().equals(climate)
|
||||
|| value.diameter() != diameter || !value.relief().equals(relief)) throw new IllegalArgumentException("id_conflict");
|
||||
|| value.diameter() != diameter || !value.relief().equals(relief)
|
||||
|| (minimumDistance > 0 && ExpansionPlacement.distanceFromParent(value, island(parent)) < minimumDistance))
|
||||
throw new IllegalArgumentException("id_conflict");
|
||||
return value;
|
||||
}
|
||||
if (islands().size() >= 64) throw new IllegalArgumentException("island_limit");
|
||||
var value = candidate(id, parent, direction, climate, diameter, relief);
|
||||
// Explicit refusal if a proposed location was already explored, even in the void.
|
||||
ExpansionAdmission.verify(level, value);
|
||||
try {
|
||||
if (probe(value) == null) throw new IllegalArgumentException("no_ground");
|
||||
var next = new ArrayList<>(islands());
|
||||
next.add(value);
|
||||
commitJournal(next); // Durability and re-read come before worldgen sees the island.
|
||||
generator.setIslands(journal.islands());
|
||||
failure = "";
|
||||
enqueue(value);
|
||||
return value;
|
||||
} finally {
|
||||
generator.discardUnpublishedContext(value);
|
||||
var candidates = ExpansionPlacement.candidates(level.getSeed(), islands(), id, parent, direction, climate, diameter, relief, minimumDistance);
|
||||
for (var value : candidates) {
|
||||
ExpansionAdmission.checkDeadline(deadline);
|
||||
try {
|
||||
var inspection = ExpansionIsland.spatialGeneration(value.generation()) ? ExpansionVoidReuse.inspect(level, value, deadline) : null;
|
||||
if (inspection == null) ExpansionAdmission.verify(level, value, deadline);
|
||||
if (probe(value) == null) throw new IllegalArgumentException("no_ground");
|
||||
var admitted = inspection == null ? value : value.withReuseToken(ExpansionVoidReuse.persist(inspection));
|
||||
var next = new ArrayList<>(islands());
|
||||
next.add(admitted);
|
||||
if (inspection == null) ExpansionAdmission.verifyHolders(level, value, deadline);
|
||||
commitJournal(next); // Durability and re-read come before worldgen sees the island.
|
||||
if (!admitted.reuseToken().isEmpty()) ExpansionVoidReuse.deferred(level, admitted);
|
||||
publishActiveIslands();
|
||||
failure = "";
|
||||
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 {
|
||||
generator.discardUnpublishedContext(value);
|
||||
}
|
||||
}
|
||||
ExpansionAdmission.checkDeadline(deadline);
|
||||
throw new IllegalArgumentException("no_free_space");
|
||||
}
|
||||
public void resume(String id) {
|
||||
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);
|
||||
@@ -216,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();
|
||||
@@ -225,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))
|
||||
@@ -258,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 (!ExpansionIsland.spatialGeneration(island.generation()) || 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 (!ExpansionIsland.spatialGeneration(parent.generation()) || prototype.generation() != parent.generation() || prototype.origin())
|
||||
throw new IllegalArgumentException("Spatial placement requires matching generation 14 or 15 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", prototype.generation());
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
@@ -57,7 +58,8 @@ public final class ExpansionStructures {
|
||||
return;
|
||||
}
|
||||
var sampler = randomState.createClimateSampler(SamplerContext.builder().enableCaches().build());
|
||||
Holder<Biome> biome = generator.getBiomeSource().createResolver(sampler).getNoiseBiome(blockX >> 2, surfaceY >> 2, blockZ >> 2);
|
||||
Holder<Biome> biome = generator.structureBiome(generator.getBiomeSource().createResolver(sampler)
|
||||
.getNoiseBiome(blockX >> 2, surfaceY >> 2, blockZ >> 2));
|
||||
var registry = registries.lookupOrThrow(Registries.STRUCTURE);
|
||||
List<Holder<Structure>> villages = new ArrayList<>();
|
||||
// Villages remain optional and are never squeezed into the small formats.
|
||||
@@ -85,7 +87,7 @@ public final class ExpansionStructures {
|
||||
String name = selected.unwrapKey().orElseThrow().identifier().toString();
|
||||
StructureStart start = structure.generate(selected, level, registries, generator, generator.getBiomeSource(),
|
||||
sampler, randomState, templates, island.seed(), chunk.getPos(), previous == null ? 0 : previous.getReferences(),
|
||||
chunk, structure.biomes()::contains);
|
||||
chunk, candidateBiome -> structure.biomes().contains(generator.structureBiome(candidateBiome)));
|
||||
if (!start.isValid()) {
|
||||
diagnostic(island, chunk, "native-rejected", name + " biome=" + biome.unwrapKey().orElseThrow().identifier() + " height=" + surfaceY);
|
||||
continue;
|
||||
@@ -121,6 +123,25 @@ 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 (generator instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator city
|
||||
&& (city.intersectsCityConstruction15(total.minX() - 1, total.minY() - (temple ? 24 : 12), total.minZ() - 1,
|
||||
total.maxX() + 1, total.maxY(), total.maxZ() + 1)
|
||||
|| city.intersectsCityConstruction16(total.minX() - 1, total.minY() - (temple ? 24 : 12), total.minZ() - 1,
|
||||
total.maxX() + 1, total.maxY(), total.maxZ() + 1)))
|
||||
return "occupied-city-underground-or-support";
|
||||
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;
|
||||
int radius = Math.max(total.getXSpan(), total.getZSpan()) / 2 + 3;
|
||||
if (fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime.protects(binding.generator(), binding.random(),
|
||||
new BlockPos(centreX, total.minY(), centreZ), radius, total.getYSpan() + 16, 26)) return "protected-hydrology";
|
||||
}
|
||||
if (generator instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator city
|
||||
&& city.intersectsExperimentalStructures(total.minX() - 1, total.minY() - (temple ? 24 : 12), total.minZ() - 1,
|
||||
total.maxX() + 1, total.maxY() + 4, total.maxZ() + 1)) return "experimental-structure";
|
||||
if (!inside(island, total.minX(), total.minZ(), 4) || !inside(island, total.maxX(), total.minZ(), 4)
|
||||
|| !inside(island, total.minX(), total.maxZ(), 4) || !inside(island, total.maxX(), total.maxZ(), 4)) return "outside-island";
|
||||
if (total.minY() < chunk.getMinY() || total.maxY() > chunk.getMaxY() - 4) return "outside-height";
|
||||
|
||||
+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)
|
||||
|| !ExpansionIsland.spatialGeneration(generator.rootGeneration()) || island.generation() != generator.rootGeneration() || island.origin())
|
||||
throw new IOException("Void reuse is restricted to a matching generation-14/15 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 (!ExpansionIsland.spatialGeneration(generator.rootGeneration())) 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/" + ((ExpansionChunkGenerator) level.getChunkSource().getGenerator()).journalDirectory() + "/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();
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.WorldGenRegion;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
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.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)
|
||||
|| !fr.koka.sanctuary.expansion.ExpansionIsland.spatialGeneration(generator.rootGeneration())) 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()
|
||||
&& !fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.isPlacing()
|
||||
&& !fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.isPlacing()
|
||||
&& !fr.koka.sanctuary.worldgen.transit.TransitStructures21.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) {
|
||||
var region = (WorldGenRegion) (Object) this;
|
||||
if (!(region.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator)) return;
|
||||
// Vanilla rejects writes outside its region before reading the destination chunk.
|
||||
if (!region.isWithinWriteZone(pos)) return;
|
||||
// The dedicated shoreline feature still grows cane on its naturally valid dry banks.
|
||||
if (state.is(Blocks.SUGAR_CANE)) return;
|
||||
// Generation16 reserves its architectural volume before decoration. Only its own
|
||||
// native pieces may replace water there; published generations retain the old rule.
|
||||
if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22)
|
||||
&& fr.koka.sanctuary.worldgen.city.LostCityStructures.isPlacing()
|
||||
&& generator.protectsCityDecoration(pos)) return;
|
||||
if (generator.protectsDecoration(pos) && !region.getBlockState(pos).equals(state)) {
|
||||
generator.rejectedDecorationWrite();
|
||||
callback.setReturnValue(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
import fr.koka.sanctuary.client.SanctuaryWorldOptionsScreen;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import net.minecraft.client.gui.screens.worldselection.PresetEditor;
|
||||
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.levelgen.presets.WorldPreset;
|
||||
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.Redirect;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
/** Supplies the existing vanilla Customize button without replacing its editor registry. */
|
||||
@Mixin(WorldCreationUiState.class)
|
||||
public abstract class SanctuaryWorldOptionsMixin {
|
||||
@Unique
|
||||
private static final ResourceKey<WorldPreset> SANCTUARY_PRESET = ResourceKey.create(
|
||||
Registries.WORLD_PRESET, Identifier.fromNamespaceAndPath("sanctuary", "sanctuary"));
|
||||
@Unique
|
||||
private static final PresetEditor SANCTUARY_EDITOR = SanctuaryWorldOptionsScreen::new;
|
||||
|
||||
@Inject(method = "getPresetEditor", at = @At("HEAD"), cancellable = true)
|
||||
private void sanctuary$worldOptions(CallbackInfoReturnable<PresetEditor> callback) {
|
||||
if (sanctuary$hasEditableGenerator()) callback.setReturnValue(SANCTUARY_EDITOR);
|
||||
}
|
||||
|
||||
// Vanilla preserves custom dimensions on registry refresh only when this direct lookup finds an editor.
|
||||
@Redirect(method = "updatePresetLists", at = @At(value = "INVOKE",
|
||||
target = "Ljava/util/Map;get(Ljava/lang/Object;)Ljava/lang/Object;"))
|
||||
private Object sanctuary$preserveCustomSize(Map<?, ?> editors, Object key) {
|
||||
if (Optional.of(SANCTUARY_PRESET).equals(key) && sanctuary$hasEditableGenerator()) return SANCTUARY_EDITOR;
|
||||
return editors.get(key);
|
||||
}
|
||||
|
||||
@Unique
|
||||
private boolean sanctuary$hasEditableGenerator() {
|
||||
WorldCreationUiState state = (WorldCreationUiState) (Object) this;
|
||||
var preset = state.getWorldType().preset();
|
||||
// Recreating an older save may retain the same public preset key with a legacy generator.
|
||||
return preset != null && preset.is(SANCTUARY_PRESET)
|
||||
&& state.getSettings().selectedDimensions().overworld() instanceof SanctuaryChunkGenerator;
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,9 @@ public enum IslandCapacity {
|
||||
public static IslandCapacity forGenerator(ChunkGenerator generator) {
|
||||
if (!(generator instanceof NoiseBasedChunkGenerator noise)) return null;
|
||||
var settings = noise.generatorSettings();
|
||||
if (settings.is(SanctuarySpawn.POPULATION_5_SETTINGS)) return FIVE;
|
||||
if (settings.is(SanctuarySpawn.POPULATION_10_SETTINGS)) return TEN;
|
||||
if (settings.is(SanctuarySpawn.POPULATION_20_SETTINGS)) return TWENTY;
|
||||
if (settings.is(SanctuarySpawn.POPULATION_5_SETTINGS) || settings.is(fr.koka.sanctuary.SanctuaryMod.id("unified_5"))) return FIVE;
|
||||
if (settings.is(SanctuarySpawn.POPULATION_10_SETTINGS) || settings.is(fr.koka.sanctuary.SanctuaryMod.id("unified_10"))) return TEN;
|
||||
if (settings.is(SanctuarySpawn.POPULATION_20_SETTINGS) || settings.is(fr.koka.sanctuary.SanctuaryMod.id("unified_20"))) return TWENTY;
|
||||
if (settings.is(SanctuarySpawn.POPULATION_100_SETTINGS)) return HUNDRED;
|
||||
return null;
|
||||
}
|
||||
|
||||
+4
-2
@@ -76,8 +76,10 @@ public final class PopulationDecorationsFeature implements Feature {
|
||||
}
|
||||
|
||||
@Override public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource ignored, BlockPos origin) {
|
||||
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !PopulationHydrologyRuntime.enabled(noise)) return false;
|
||||
var state = level.getLevel().getChunkSource().randomState();
|
||||
var binding = PopulationGenerationContext.of(generator, level.getLevel());
|
||||
if (binding == null) return false;
|
||||
var noise = binding.generator();
|
||||
var state = binding.random();
|
||||
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
|
||||
WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z);
|
||||
int cx = origin.getX() >> 4, cz = origin.getZ() >> 4;
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/** Resolves the actual starter noise state; an expansion host's fallback state has no terrain. */
|
||||
public final class PopulationGenerationContext {
|
||||
public record Binding(NoiseBasedChunkGenerator generator, RandomState random) {}
|
||||
private PopulationGenerationContext() {}
|
||||
public static @Nullable Binding of(ServerLevel level) {
|
||||
return of(level.getChunkSource().getGenerator(), level);
|
||||
}
|
||||
public static @Nullable Binding of(ChunkGenerator generator, ServerLevel level) {
|
||||
if (generator instanceof SanctuaryChunkGenerator unified) return unified.starterContext();
|
||||
if (generator instanceof NoiseBasedChunkGenerator noise && PopulationHydrologyRuntime.enabled(noise))
|
||||
return new Binding(noise, level.getChunkSource().randomState());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -53,8 +53,8 @@ public final class PopulationGrovesFeature implements Feature {
|
||||
@Override public MapCodec<PopulationGrovesFeature> codec() { return CODEC; }
|
||||
|
||||
public static Optional<RemarkableTree> remarkablePlan(ServerLevel level) {
|
||||
if (!(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)) return Optional.empty();
|
||||
return remarkablePlan(noise, level.getChunkSource().randomState());
|
||||
var binding = PopulationGenerationContext.of(level);
|
||||
return binding == null ? Optional.empty() : remarkablePlan(binding.generator(), binding.random());
|
||||
}
|
||||
|
||||
public static Optional<RemarkableTree> remarkablePlan(NoiseBasedChunkGenerator noise, RandomState state) {
|
||||
@@ -87,10 +87,12 @@ public final class PopulationGrovesFeature implements Feature {
|
||||
|
||||
@Override
|
||||
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource ignored, BlockPos origin) {
|
||||
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !PopulationHydrologyRuntime.enabled(noise)) return false;
|
||||
var binding = PopulationGenerationContext.of(generator, level.getLevel());
|
||||
if (binding == null) return false;
|
||||
var noise = binding.generator();
|
||||
int minX = (origin.getX() >> 4) * 16, minZ = (origin.getZ() >> 4) * 16;
|
||||
if (!PopulationHydrologyRuntime.capacity(noise).areaExists(minX >> 4, minZ >> 4)) return false;
|
||||
var state = level.getLevel().getChunkSource().randomState();
|
||||
var state = binding.random();
|
||||
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
|
||||
WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z);
|
||||
Protection protection = (base, radius, height, rootDepth) ->
|
||||
|
||||
+2
-1
@@ -57,7 +57,8 @@ public final class PopulationHydrologyRuntime {
|
||||
});
|
||||
}
|
||||
public static RegionPlan regionPlan(ServerLevel level, IslandCapacity.Region region) {
|
||||
return regionPlan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(), level.getChunkSource().randomState(), region);
|
||||
var binding = Objects.requireNonNull(PopulationGenerationContext.of(level), "Population world required");
|
||||
return regionPlan(binding.generator(), binding.random(), region);
|
||||
}
|
||||
public static List<RegionPlan> plansIntersecting(NoiseBasedChunkGenerator generator, RandomState state,
|
||||
int minX, int minZ, int maxX, int maxZ) {
|
||||
|
||||
+4
-2
@@ -20,8 +20,10 @@ public final class PopulationShoreSugarCaneFeature implements Feature {
|
||||
|
||||
@Override
|
||||
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) {
|
||||
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !PopulationHydrologyRuntime.enabled(noise)) return false;
|
||||
var state = level.getLevel().getChunkSource().randomState();
|
||||
var binding = PopulationGenerationContext.of(generator, level.getLevel());
|
||||
if (binding == null) return false;
|
||||
var noise = binding.generator();
|
||||
var state = binding.random();
|
||||
var cells = PopulationHydrologyRuntime.cellsInChunk(noise, state, origin.getX() >> 4, origin.getZ() >> 4);
|
||||
var cane = Blocks.SUGAR_CANE.defaultBlockState();
|
||||
boolean placed = false;
|
||||
|
||||
+9
-6
@@ -26,8 +26,9 @@ public final class PopulationSpringOutlets {
|
||||
|
||||
/** Called before vanilla consumes the queue; a fully processed/reloaded chunk has no work. */
|
||||
public static Pending capturePending(ServerLevel level, LevelChunk chunk) {
|
||||
if (!(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)
|
||||
|| !PopulationHydrologyRuntime.enabled(noise)) return Pending.EMPTY;
|
||||
var binding = PopulationGenerationContext.of(level);
|
||||
if (binding == null) return Pending.EMPTY;
|
||||
var noise = binding.generator();
|
||||
if (!PopulationHydrologyRuntime.capacity(noise).areaExists(chunk.getPos().x(), chunk.getPos().z())) return Pending.EMPTY;
|
||||
boolean queued = false;
|
||||
for (var section : chunk.getPostProcessing()) if (section != null && !section.isEmpty()) {
|
||||
@@ -35,7 +36,7 @@ public final class PopulationSpringOutlets {
|
||||
break;
|
||||
}
|
||||
if (!queued) return Pending.EMPTY;
|
||||
var regional = PopulationHydrologyRuntime.regionPlan(noise, level.getChunkSource().randomState(),
|
||||
var regional = PopulationHydrologyRuntime.regionPlan(noise, binding.random(),
|
||||
PopulationHydrologyRuntime.capacity(noise).regionAt(chunk.getPos().getMinBlockX(), chunk.getPos().getMinBlockZ()));
|
||||
var plan = regional.water();
|
||||
List<PopulationHydrology.Position> sources = new ArrayList<>(), outlets = new ArrayList<>();
|
||||
@@ -58,9 +59,11 @@ public final class PopulationSpringOutlets {
|
||||
|
||||
/** All neighbouring FEATURES have finished before a chunk reaches this stage. */
|
||||
public static void finish(ServerLevel level, LevelChunk chunk, Pending pending) {
|
||||
if (pending.isEmpty() || !(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)
|
||||
|| !PopulationHydrologyRuntime.enabled(noise)) return;
|
||||
var random = level.getChunkSource().randomState();
|
||||
if (pending.isEmpty()) return;
|
||||
var binding = PopulationGenerationContext.of(level);
|
||||
if (binding == null) return;
|
||||
var noise = binding.generator();
|
||||
var random = binding.random();
|
||||
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
|
||||
for (var outlet : pending.outlets()) {
|
||||
if (!owns(chunk, outlet)) throw new IllegalArgumentException("An outlet belongs to another chunk: " + outlet);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import fr.koka.sanctuary.expansion.ExpansionBiomeSource;
|
||||
import fr.koka.sanctuary.expansion.ExpansionIsland;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeResolver;
|
||||
import net.minecraft.world.level.biome.Biomes;
|
||||
import net.minecraft.world.level.biome.Climate;
|
||||
|
||||
/** Alpha.13 keeps Population's origin woodlands and the laboratory's native cave pockets.
|
||||
* Child islands retain the laboratory's complete climate palette. */
|
||||
public final class SanctuaryBiomeSource extends ExpansionBiomeSource {
|
||||
public static final List<String> REQUIRED_BIOMES = Stream.concat(
|
||||
ExpansionBiomeSource.REQUIRED_BIOMES.stream(), Stream.of(
|
||||
PopulationIslandBiomeSource.OAK_FOREST, PopulationIslandBiomeSource.BIRCH_FOREST,
|
||||
PopulationIslandBiomeSource.CLEARING, PopulationIslandBiomeSource.DRY_WOODLAND,
|
||||
PopulationIslandBiomeSource.ROCKY_HEATH, PopulationIslandBiomeSource.DARK_GROVE,
|
||||
PopulationIslandBiomeSource.BAMBOO_GROVE, PopulationIslandBiomeSource.SULFUR_DEPTHS,
|
||||
PopulationIslandBiomeSource.LUSH_CAVES, PopulationIslandBiomeSource.DRIPSTONE_CAVES
|
||||
).map(key -> key.identifier().toString())).toList();
|
||||
|
||||
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, 14)));
|
||||
public static final MapCodec<SanctuaryBiomeSource> CITY15_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
|
||||
).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 15)));
|
||||
public static final MapCodec<SanctuaryBiomeSource> CITY16_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
|
||||
).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 16)));
|
||||
|
||||
public static final MapCodec<SanctuaryBiomeSource> RUINS17_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
|
||||
).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 17)));
|
||||
|
||||
public static final MapCodec<SanctuaryBiomeSource> HERITAGE19_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
|
||||
).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 19)));
|
||||
|
||||
public static final MapCodec<SanctuaryBiomeSource> ARCHITECTURE20_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
|
||||
).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 20)));
|
||||
|
||||
public static final MapCodec<SanctuaryBiomeSource> NETWORK21_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
|
||||
).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 21)));
|
||||
|
||||
public static final MapCodec<SanctuaryBiomeSource> SELECTIVE22_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
|
||||
).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 22)));
|
||||
|
||||
private final List<Holder<Biome>> allBiomes;
|
||||
private final PopulationIslandBiomeSource population;
|
||||
private final boolean cities;
|
||||
private final int generation;
|
||||
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, 13);
|
||||
}
|
||||
private SanctuaryBiomeSource(List<Holder<Biome>> biomes, int generation) {
|
||||
super(biomes);
|
||||
this.generation = generation;
|
||||
this.cities = ExpansionIsland.spatialGeneration(generation);
|
||||
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),
|
||||
required(PopulationIslandBiomeSource.ROCKY_HEATH), required(PopulationIslandBiomeSource.DARK_GROVE),
|
||||
required(PopulationIslandBiomeSource.BAMBOO_GROVE), required(PopulationIslandBiomeSource.SULFUR_DEPTHS),
|
||||
required(PopulationIslandBiomeSource.LUSH_CAVES), required(PopulationIslandBiomeSource.DRIPSTONE_CAVES));
|
||||
}
|
||||
|
||||
/** A world or delegate gets its own activation snapshot; registered holders remain immutable. */
|
||||
public SanctuaryBiomeSource copy() { return new SanctuaryBiomeSource(allBiomes, generation); }
|
||||
public boolean citiesEnabled() { return cities; }
|
||||
public int generation() { return generation; }
|
||||
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 generation == 22 ? SELECTIVE22_CODEC : generation == 21 ? NETWORK21_CODEC : generation == 20 ? ARCHITECTURE20_CODEC : generation == 19 ? HERITAGE19_CODEC : generation == 17 ? RUINS17_CODEC : generation == 16 ? CITY16_CODEC : generation == 15 ? CITY15_CODEC : cities ? CITY_CODEC : CODEC; }
|
||||
|
||||
@Override public BiomeResolver createResolver(Climate.Sampler sampler) {
|
||||
BiomeResolver woodlands = population.createResolver(sampler);
|
||||
return (quartX, quartY, quartZ) -> resolveCombined(islands(), woodlands, quartX, quartY, quartZ);
|
||||
}
|
||||
|
||||
@Override public BiomeResolver createResolverForChunk(Climate.Sampler sampler, int minQuartX, int minQuartY,
|
||||
int minQuartZ, int quartSizeX, int quartSizeY, int quartSizeZ) {
|
||||
List<ExpansionIsland> snapshot = islands();
|
||||
BiomeResolver woodlands = population.createResolver(sampler);
|
||||
return (quartX, quartY, quartZ) -> resolveCombined(snapshot, woodlands, quartX, quartY, quartZ);
|
||||
}
|
||||
|
||||
private Holder<Biome> resolveCombined(List<ExpansionIsland> snapshot, BiomeResolver woodlands,
|
||||
int quartX, int quartY, int quartZ) {
|
||||
int x = quartX * 4, y = quartY * 4, z = quartZ * 4;
|
||||
// Reuse the historical selector itself: its noise, thresholds, order and child
|
||||
// climate choices stay exact rather than being approximated by another climate field.
|
||||
Holder<Biome> laboratory = super.resolve(snapshot, x, y, z);
|
||||
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() == generation && 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;
|
||||
}
|
||||
|
||||
private Holder<Biome> required(ResourceKey<Biome> key) {
|
||||
return allBiomes.stream().filter(holder -> holder.is(key)).findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Missing Sanctuary biome: " + key.identifier()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import com.mojang.serialization.DataResult;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.expansion.ExpansionChunkGenerator;
|
||||
import fr.koka.sanctuary.expansion.ExpansionIsland;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlanner15;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlanner16;
|
||||
import java.util.List;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
|
||||
/** Alpha.13 combines the complete starter island with the versioned expansion runtime.
|
||||
* The historical laboratory codec keeps its original algorithm and journal. */
|
||||
public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
|
||||
public static final Codec<Integer> DIAMETER_CODEC = Codec.INT.validate(value ->
|
||||
List.of(512, 724, 1024).contains(value) ? DataResult.success(value)
|
||||
: DataResult.error(() -> "Sanctuary diameter must be 512, 724 or 1024"));
|
||||
public static final MapCodec<SanctuaryChunkGenerator> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
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)));
|
||||
public static final MapCodec<SanctuaryChunkGenerator> CITY15_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
SanctuaryBiomeSource.CITY15_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
|
||||
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter)
|
||||
).apply(i, (source, diameter) -> new SanctuaryChunkGenerator(source, diameter, 15)));
|
||||
public static final MapCodec<SanctuaryChunkGenerator> CITY16_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
SanctuaryBiomeSource.CITY16_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
|
||||
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter)
|
||||
).apply(i, (source, diameter) -> new SanctuaryChunkGenerator(source, diameter, 16)));
|
||||
|
||||
public static final MapCodec<SanctuaryChunkGenerator> RUINS17_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
SanctuaryBiomeSource.RUINS17_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
|
||||
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter),
|
||||
Codec.BOOL.optionalFieldOf("experimental_structures", true).forGetter(SanctuaryChunkGenerator::experimentalStructures)
|
||||
).apply(i, (source, diameter, experimental) -> new SanctuaryChunkGenerator(source, diameter, 17, experimental)));
|
||||
|
||||
public static final MapCodec<SanctuaryChunkGenerator> HERITAGE19_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
SanctuaryBiomeSource.HERITAGE19_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
|
||||
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter),
|
||||
Codec.BOOL.optionalFieldOf("experimental_structures", true).forGetter(SanctuaryChunkGenerator::experimentalStructures)
|
||||
).apply(i, (source, diameter, experimental) -> new SanctuaryChunkGenerator(source, diameter, 19, experimental)));
|
||||
|
||||
public static final MapCodec<SanctuaryChunkGenerator> ARCHITECTURE20_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
SanctuaryBiomeSource.ARCHITECTURE20_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
|
||||
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter),
|
||||
Codec.BOOL.optionalFieldOf("experimental_structures", true).forGetter(SanctuaryChunkGenerator::experimentalStructures)
|
||||
).apply(i, (source, diameter, experimental) -> new SanctuaryChunkGenerator(source, diameter, 20, experimental)));
|
||||
|
||||
public static final MapCodec<SanctuaryChunkGenerator> NETWORK21_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
SanctuaryBiomeSource.NETWORK21_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
|
||||
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter),
|
||||
Codec.BOOL.optionalFieldOf("experimental_structures", true).forGetter(SanctuaryChunkGenerator::experimentalStructures)
|
||||
).apply(i, (source, diameter, experimental) -> new SanctuaryChunkGenerator(source, diameter, 21, experimental)));
|
||||
|
||||
public static final MapCodec<SanctuaryChunkGenerator> SELECTIVE22_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
SanctuaryBiomeSource.SELECTIVE22_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
|
||||
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter),
|
||||
Codec.BOOL.optionalFieldOf("experimental_structures", true).forGetter(SanctuaryChunkGenerator::experimentalStructures)
|
||||
).apply(i, (source, diameter, experimental) -> new SanctuaryChunkGenerator(source, diameter, 22, experimental)));
|
||||
|
||||
private final boolean experimentalStructures;
|
||||
private volatile fr.koka.sanctuary.worldgen.ruins.RuinsPlan17 ruinsPlan17 = fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.EMPTY;
|
||||
private boolean ruinsPrepared;
|
||||
private volatile fr.koka.sanctuary.worldgen.heritage.HeritagePlan19 heritagePlan19 = fr.koka.sanctuary.worldgen.heritage.HeritagePlan19.EMPTY;
|
||||
private boolean heritagePrepared;
|
||||
private final java.util.Map<ExpansionIsland, fr.koka.sanctuary.worldgen.city.LostCityPlanner21.Result> cityPlacements21 = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private volatile fr.koka.sanctuary.worldgen.transit.TransitPlan21 transitPlan21 = fr.koka.sanctuary.worldgen.transit.TransitPlan21.EMPTY;
|
||||
private boolean transitPrepared;
|
||||
private volatile fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan undergroundHall22 = new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan(List.of(), 0);
|
||||
private volatile fr.koka.sanctuary.worldgen.city.LostCityProtection16 undergroundHallProtection22 = fr.koka.sanctuary.worldgen.city.LostCityProtection16.EMPTY;
|
||||
private boolean undergroundHallPrepared22;
|
||||
public fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan undergroundHall22() { return undergroundHall22; }
|
||||
public fr.koka.sanctuary.worldgen.transit.TransitPlan21 transitPlan21() { return transitPlan21; }
|
||||
public fr.koka.sanctuary.worldgen.city.LostCityPlanner21.Result cityPlacement21(ExpansionIsland island) {
|
||||
lostCityPlan(island);
|
||||
return cityPlacements21.get(cityIdentity(island));
|
||||
}
|
||||
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();
|
||||
private final java.util.Map<ExpansionIsland, fr.koka.sanctuary.worldgen.city.LostCityUnderground15.Plan> undergroundPlans = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private final java.util.Map<ExpansionIsland, List<LostCityPlanner15.Support>> citySupports = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private volatile fr.koka.sanctuary.worldgen.city.LostCityProtection15 cityProtection15 = fr.koka.sanctuary.worldgen.city.LostCityProtection15.EMPTY;
|
||||
private final java.util.Map<ExpansionIsland, fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan> undergroundPlans16 = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private final java.util.Map<ExpansionIsland, List<LostCityPlanner16.Support>> citySupports16 = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private volatile fr.koka.sanctuary.worldgen.city.LostCityProtection16 cityProtection16 = fr.koka.sanctuary.worldgen.city.LostCityProtection16.EMPTY;
|
||||
private final java.util.Map<ExpansionIsland, LostCityPlanner16.Fallback> cityReservations16 = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private boolean cityStructuresEnabled;
|
||||
// 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();
|
||||
public boolean protectsDecoration(net.minecraft.core.BlockPos pos) {
|
||||
if (pos.getY() < 0 || pos.getY() >= 384 || !capacity().areaExists(pos.getX() >> 4, pos.getZ() >> 4)) return false;
|
||||
int cx = pos.getX() >> 4, cz = pos.getZ() >> 4;
|
||||
long key = ((long) cx << 32) ^ (cz & 0xffffffffL);
|
||||
return protections.computeIfAbsent(key, ignored -> SanctuaryDecorationProtection.create(starterContext(), cx, cz)).protects(pos);
|
||||
}
|
||||
public void rejectedDecorationWrite() { rejectedWrites.increment(); }
|
||||
public long rejectedDecorationWrites() { return rejectedWrites.sum(); }
|
||||
|
||||
public SanctuaryChunkGenerator(SanctuaryBiomeSource source, int initialDiameter) {
|
||||
this(source, initialDiameter, 13);
|
||||
}
|
||||
private SanctuaryChunkGenerator(SanctuaryBiomeSource source, int initialDiameter, int generation) {
|
||||
this(source, initialDiameter, generation, false);
|
||||
}
|
||||
private SanctuaryChunkGenerator(SanctuaryBiomeSource source, int initialDiameter, int generation, boolean experimental) {
|
||||
super(source, initialDiameter, true);
|
||||
this.experimentalStructures = (generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && experimental;
|
||||
this.generation = generation;
|
||||
if (generation != source.generation()) throw new IllegalArgumentException("Sanctuary generator and biome source versions must agree");
|
||||
}
|
||||
@Override public SanctuaryBiomeSource source() { return (SanctuaryBiomeSource) super.source(); }
|
||||
@Override protected MapCodec<SanctuaryChunkGenerator> codec() { return generation == 22 ? SELECTIVE22_CODEC : generation == 21 ? NETWORK21_CODEC : generation == 20 ? ARCHITECTURE20_CODEC : generation == 19 ? HERITAGE19_CODEC : generation == 17 ? RUINS17_CODEC : generation == 16 ? CITY16_CODEC : generation == 15 ? CITY15_CODEC : generation == 14 ? CITY_CODEC : CODEC; }
|
||||
public SanctuaryChunkGenerator withInitialDiameter(int diameter) {
|
||||
return new SanctuaryChunkGenerator(source().copy(), diameter, generation, experimentalStructures);
|
||||
}
|
||||
public boolean experimentalStructures() { return experimentalStructures; }
|
||||
public SanctuaryChunkGenerator withExperimentalStructures(boolean enabled) {
|
||||
return new SanctuaryChunkGenerator(source().copy(), initialDiameter(), generation, enabled);
|
||||
}
|
||||
public fr.koka.sanctuary.worldgen.ruins.RuinsPlan17 ruinsPlan17() { return ruinsPlan17; }
|
||||
public fr.koka.sanctuary.worldgen.heritage.HeritagePlan19 heritagePlan19() { return heritagePlan19; }
|
||||
public boolean intersectsExperimentalStructures(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
|
||||
return (generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && ruinsPlan17.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ)
|
||||
|| (generation == 19 || generation == 20 || generation == 21 || generation == 22) && heritagePlan19.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ)
|
||||
|| (generation == 21 || generation == 22) && transitPlan21.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ)
|
||||
|| generation == 22 && undergroundHallProtection22.intersects(minX, minY, minZ, maxX, maxY, maxZ);
|
||||
}
|
||||
public IslandCapacity capacity() {
|
||||
return switch (initialDiameter()) {
|
||||
case 512 -> IslandCapacity.FIVE;
|
||||
case 724 -> IslandCapacity.TEN;
|
||||
case 1024 -> IslandCapacity.TWENTY;
|
||||
default -> throw new IllegalStateException("Unvalidated Sanctuary size");
|
||||
};
|
||||
}
|
||||
@Override public net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome> structureBiome(
|
||||
net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome> biome) {
|
||||
String name = biome.unwrapKey().orElseThrow().identifier().toString();
|
||||
String equivalent = switch (name) {
|
||||
case "sanctuary:population_oak_forest" -> "forest";
|
||||
case "sanctuary:population_birch_forest" -> "birch_forest";
|
||||
case "sanctuary:population_clearing" -> "plains";
|
||||
case "sanctuary:population_dry_woodland" -> "forest";
|
||||
case "sanctuary:population_rocky_heath" -> "meadow";
|
||||
case "sanctuary:population_dark_grove" -> "dark_forest";
|
||||
case "sanctuary:population_bamboo_grove" -> "bamboo_jungle";
|
||||
default -> null;
|
||||
};
|
||||
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-v" + generation; }
|
||||
@Override public int rootGeneration() { return generation; }
|
||||
@Override public void initialize(ServerLevel level, List<ExpansionIsland> islands) {
|
||||
heightAccessor = level;
|
||||
cityStructuresEnabled = level.structureManager().shouldGenerateStructures();
|
||||
super.initialize(level, islands);
|
||||
// Publish one root context before any generation workers use it.
|
||||
context(islands.getFirst());
|
||||
prepareCities(islands);
|
||||
if (generation == 22) { prepareTransit(islands); prepareUndergroundHall22(islands); }
|
||||
prepareRuins(islands);
|
||||
prepareHeritage(islands);
|
||||
prepareTransit(islands);
|
||||
}
|
||||
@Override public void setIslands(List<ExpansionIsland> islands) {
|
||||
// Complete and publish city geometry before workers can observe the new owner list.
|
||||
prepareCities(islands);
|
||||
if (generation == 22) { prepareTransit(islands); prepareUndergroundHall22(islands); }
|
||||
prepareRuins(islands);
|
||||
prepareHeritage(islands);
|
||||
prepareTransit(islands);
|
||||
super.setIslands(islands);
|
||||
}
|
||||
private void prepareCities(List<ExpansionIsland> islands) {
|
||||
if (!ExpansionIsland.spatialGeneration(generation)) return;
|
||||
if (generation == 15 && !cityStructuresEnabled) {
|
||||
cityProtection15 = fr.koka.sanctuary.worldgen.city.LostCityProtection15.EMPTY;
|
||||
activeCityPlans = java.util.Map.of();
|
||||
source().setCityPlans(java.util.Map.of());
|
||||
return;
|
||||
}
|
||||
if ((generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && !cityStructuresEnabled) {
|
||||
cityProtection16 = fr.koka.sanctuary.worldgen.city.LostCityProtection16.EMPTY;
|
||||
activeCityPlans = java.util.Map.of();
|
||||
source().setCityPlans(java.util.Map.of());
|
||||
return;
|
||||
}
|
||||
var published = new java.util.LinkedHashMap<String, fr.koka.sanctuary.worldgen.city.LostCityPlan>();
|
||||
for (var island : islands) published.put(island.id(), lostCityPlan(island));
|
||||
if (generation == 15) {
|
||||
var protection = new fr.koka.sanctuary.worldgen.city.LostCityProtection15.Builder();
|
||||
for (var island : islands) {
|
||||
protection.add(published.get(island.id()), undergroundPlan(island));
|
||||
for (var support : citySupports(island)) protection.addViaductSupport(support);
|
||||
}
|
||||
cityProtection15 = protection.build();
|
||||
}
|
||||
if (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) {
|
||||
var protection = new fr.koka.sanctuary.worldgen.city.LostCityProtection16.Builder();
|
||||
for (var island : islands) {
|
||||
protection.add(published.get(island.id()), undergroundPlan16(island));
|
||||
for (var support : citySupports16(island)) protection.addViaductSupport(support);
|
||||
var reservation = cityReservation16(island);
|
||||
if (reservation != null) protection.addReservation(reservation);
|
||||
}
|
||||
cityProtection16 = protection.build();
|
||||
}
|
||||
// Rejected read-only prospects remain private and never enter the biome source.
|
||||
activeCityPlans = java.util.Map.copyOf(published);
|
||||
source().setCityPlans(published);
|
||||
}
|
||||
private void prepareRuins(List<ExpansionIsland> islands) {
|
||||
if ((generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22) || !experimentalStructures || !cityStructuresEnabled || ruinsPrepared) return;
|
||||
var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow();
|
||||
var terrain = context(root);
|
||||
var sampled = new fr.koka.sanctuary.worldgen.ruins.RuinsTerrain17(root, terrain.generator(), terrain.random(),
|
||||
heightAccessor, pos -> protectsCore22(pos) || protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()));
|
||||
long start = System.nanoTime();
|
||||
var plan = fr.koka.sanctuary.worldgen.ruins.RuinsPlanner17.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled);
|
||||
ruinsPlan17 = plan;
|
||||
ruinsPrepared = true;
|
||||
SanctuaryMod.LOGGER.info("Experimental ruins17 seed {}: {} networks, {} probes, {} noise columns in {} ms",
|
||||
root.seed(), plan.networks().size(), plan.samples(), sampled.columnsUsed(), (System.nanoTime() - start) / 1_000_000);
|
||||
}
|
||||
private void prepareHeritage(List<ExpansionIsland> islands) {
|
||||
if ((generation != 19 && generation != 20 && generation != 21 && generation != 22) || !experimentalStructures || !cityStructuresEnabled || heritagePrepared) return;
|
||||
var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow();
|
||||
var terrain = context(root);
|
||||
if (generation == 21 || generation == 22) {
|
||||
var sampled = new fr.koka.sanctuary.worldgen.heritage.HeritageTerrain21(root, terrain.generator(), terrain.random(),
|
||||
heightAccessor, pos -> protectsCore22(pos) || protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ())
|
||||
|| ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ()));
|
||||
long start = System.nanoTime();
|
||||
sampled.discoverLandmarks(lostCityPlan(root), ruinsPlan17);
|
||||
var plan = generation == 22
|
||||
? fr.koka.sanctuary.worldgen.heritage.HeritagePlanner22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled)
|
||||
: fr.koka.sanctuary.worldgen.heritage.HeritagePlanner21.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled);
|
||||
heritagePlan19 = plan;
|
||||
heritagePrepared = true;
|
||||
SanctuaryMod.LOGGER.info("Heritage" + generation + " seed {}: {} sites, {} routes, {} segments, {} probes, {} native columns in {} ms; {}",
|
||||
root.seed(), plan.sites().size(), plan.links().size(), plan.segments().size(), plan.samples(), sampled.columnsUsed(),
|
||||
(System.nanoTime() - start) / 1_000_000, plan.diagnostics());
|
||||
return;
|
||||
}
|
||||
var sampled = new fr.koka.sanctuary.worldgen.heritage.HeritageTerrain19(root, terrain.generator(), terrain.random(),
|
||||
heightAccessor, pos -> protectsCore22(pos) || protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ())
|
||||
|| ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ()));
|
||||
long start = System.nanoTime();
|
||||
sampled.discoverLandmarks(lostCityPlan(root), ruinsPlan17);
|
||||
var plan = fr.koka.sanctuary.worldgen.heritage.HeritagePlanner19.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled);
|
||||
heritagePlan19 = plan;
|
||||
heritagePrepared = true;
|
||||
SanctuaryMod.LOGGER.info("Heritage19 seed {}: {} sites, {} routes, {} segments, {} probes, {} native columns in {} ms; {}",
|
||||
root.seed(), plan.sites().size(), plan.links().size(), plan.segments().size(), plan.samples(), sampled.columnsUsed(),
|
||||
(System.nanoTime() - start) / 1_000_000, plan.diagnostics());
|
||||
}
|
||||
private void prepareTransit(List<ExpansionIsland> islands) {
|
||||
if ((generation != 21 && generation != 22) || generation == 21 && !experimentalStructures || !cityStructuresEnabled || transitPrepared) return;
|
||||
var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow();
|
||||
var terrain = context(root);
|
||||
var sampled = new fr.koka.sanctuary.worldgen.transit.TransitTerrain21(root, terrain.generator(), terrain.random(),
|
||||
heightAccessor, pos -> protectsCore22(pos) || protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ())
|
||||
|| ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ()) || heritagePlan19.protects(pos.getX(), pos.getY(), pos.getZ()));
|
||||
long start = System.nanoTime();
|
||||
transitPlan21 = generation == 22
|
||||
? fr.koka.sanctuary.worldgen.transit.TransitPlanner22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled)
|
||||
: fr.koka.sanctuary.worldgen.transit.TransitPlanner21.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled);
|
||||
int directColumns = 0;
|
||||
if (generation == 22 && transitPlan21.isEmpty()) {
|
||||
directColumns = sampled.columnsUsed();
|
||||
sampled = new fr.koka.sanctuary.worldgen.transit.TransitTerrain21(root, terrain.generator(), terrain.random(),
|
||||
heightAccessor, pos -> protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()));
|
||||
transitPlan21 = fr.koka.sanctuary.worldgen.transit.TransitRoute22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled);
|
||||
}
|
||||
transitPrepared = true;
|
||||
SanctuaryMod.LOGGER.info("Transit" + generation + " seed {}: {} modules, {} probes, {} columns in {} ms; {}", root.seed(),
|
||||
transitPlan21.modules().size(), transitPlan21.samples(), directColumns + sampled.columnsUsed(), (System.nanoTime() - start) / 1_000_000, transitPlan21.diagnostic());
|
||||
}
|
||||
private boolean protectsCore22(net.minecraft.core.BlockPos pos) {
|
||||
return generation == 22 && (transitPlan21.protects(pos.getX(), pos.getY(), pos.getZ())
|
||||
|| undergroundHallProtection22.contains(pos.getX(), pos.getY(), pos.getZ()));
|
||||
}
|
||||
private void prepareUndergroundHall22(List<ExpansionIsland> islands) {
|
||||
if (generation != 22 || !cityStructuresEnabled || undergroundHallPrepared22) return;
|
||||
var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow();
|
||||
if (undergroundPlan16(root).networks().stream().flatMap(n -> n.modules().stream())
|
||||
.anyMatch(m -> m.kind() == fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Kind.EXPANSION_HALL)) {
|
||||
undergroundHallPrepared22 = true; return;
|
||||
}
|
||||
var terrain = context(root);
|
||||
var sampled = new fr.koka.sanctuary.worldgen.city.LostCityTerrain21(root, terrain.generator(), terrain.random(), heightAccessor,
|
||||
pos -> protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()) || protectsCore22(pos));
|
||||
long start = System.nanoTime();
|
||||
undergroundHall22 = fr.koka.sanctuary.worldgen.city.LostCityUnderground22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(),
|
||||
new fr.koka.sanctuary.worldgen.city.LostCityUnderground22.Terrain() {
|
||||
@Override public int surfaceY(int x, int z) { var ground = sampled.ground(x, z); return ground.dry() ? ground.top() : -1; }
|
||||
@Override public boolean owns(int x, int z) { return sampled.owns(x, z); }
|
||||
@Override public boolean solid(int x, int y, int z) { return sampled.solid(x, y, z); }
|
||||
@Override public boolean protectedAt(int x, int y, int z) { return sampled.protectedAt(x, y, z); }
|
||||
@Override public boolean exteriorAir(int x, int y, int z, int dx, int dz) { return sampled.exteriorAir(x, y, z, dx, dz); }
|
||||
});
|
||||
var protection = new fr.koka.sanctuary.worldgen.city.LostCityProtection16.Builder();
|
||||
protection.add(fr.koka.sanctuary.worldgen.city.LostCityPlan.EMPTY, undergroundHall22);
|
||||
undergroundHallProtection22 = protection.build(); undergroundHallPrepared22 = true;
|
||||
SanctuaryMod.LOGGER.info("Independent hall22 seed {}: {} networks, {} probes, {} columns in {} ms", root.seed(),
|
||||
undergroundHall22.networks().size(), undergroundHall22.samples(), sampled.columnsUsed(), (System.nanoTime() - start) / 1_000_000);
|
||||
}
|
||||
private static ExpansionIsland cityIdentity(ExpansionIsland island) {
|
||||
// State and receipt do not change the terrain geometry used by a cached plan.
|
||||
var ready = island.status().equals("ready") ? island : island.ready();
|
||||
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);
|
||||
cityPlacements21.remove(key);
|
||||
undergroundPlans.remove(key);
|
||||
undergroundPlans16.remove(key);
|
||||
citySupports.remove(key);
|
||||
citySupports16.remove(key);
|
||||
cityReservations16.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 (!ExpansionIsland.spatialGeneration(generation) || island.generation() != generation || (!island.origin() && !island.climate().equals("lost_city")))
|
||||
return fr.koka.sanctuary.worldgen.city.LostCityPlan.EMPTY;
|
||||
return cityPlans.computeIfAbsent(cityIdentity(island), ignored -> {
|
||||
long start = System.nanoTime();
|
||||
var terrain = context(island);
|
||||
if (generation == 21 || generation == 22) {
|
||||
var sampled = new fr.koka.sanctuary.worldgen.city.LostCityTerrain21(island, terrain.generator(), terrain.random(),
|
||||
heightAccessor, pos -> island.origin() && protectsDecoration(pos));
|
||||
var placement = generation == 22
|
||||
? fr.koka.sanctuary.worldgen.city.LostCityPlanner22.plan(island.seed(), island.centerX(), island.centerZ(), island.diameter(), island.origin(), sampled)
|
||||
: fr.koka.sanctuary.worldgen.city.LostCityPlanner21.plan(island.seed(), island.centerX(), island.centerZ(), island.diameter(), island.origin(), sampled);
|
||||
var surface = placement.surface();
|
||||
cityPlacements21.put(cityIdentity(island), placement);
|
||||
citySupports16.put(cityIdentity(island), placement.supports());
|
||||
if (placement.reservation() != null) cityReservations16.put(cityIdentity(island), placement.reservation());
|
||||
var supports = new fr.koka.sanctuary.worldgen.city.LostCityProtection16.Builder();
|
||||
for (var support : placement.supports()) supports.addViaductSupport(support);
|
||||
if (placement.reservation() != null) supports.addReservation(placement.reservation());
|
||||
var extraDistricts = surface.districts().stream()
|
||||
.filter(d -> !placement.core().surface().districts().contains(d)).toList();
|
||||
supports.add(new fr.koka.sanctuary.worldgen.city.LostCityPlan(extraDistricts),
|
||||
new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan(List.of(), 0));
|
||||
var supportMask = supports.build();
|
||||
var underground = fr.koka.sanctuary.worldgen.city.LostCityUnderground16.plan(island.seed(), placement.core().surface(),
|
||||
new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Terrain() {
|
||||
@Override public boolean solid(int x, int y, int z) { return sampled.solid(x, y, z); }
|
||||
@Override public boolean owns(int x, int z) { return sampled.owns(x, z); }
|
||||
@Override public boolean exteriorAir(int x, int y, int z, int dx, int dz) { return sampled.exteriorAir(x, y, z, dx, dz); }
|
||||
@Override public boolean protectedAt(int x, int y, int z) {
|
||||
return supportMask.contains(x, y, z) || sampled.protectedAt(x, y, z);
|
||||
}
|
||||
});
|
||||
undergroundPlans16.put(cityIdentity(island), underground);
|
||||
SanctuaryMod.LOGGER.info("Lost City" + generation + " {} seed {}: {} city districts, {} underground networks, {} noise columns in {} ms",
|
||||
island.id(), island.seed(), surface.districts().size(), underground.networks().size(), sampled.columnsUsed(),
|
||||
(System.nanoTime() - start) / 1_000_000);
|
||||
SanctuaryMod.LOGGER.info("City" + generation + " extensions: {}", placement.diagnostics());
|
||||
return surface;
|
||||
}
|
||||
if (generation == 16 || generation == 17 || generation == 19 || generation == 20) {
|
||||
var sampled = new fr.koka.sanctuary.worldgen.city.LostCityTerrain16(island, terrain.generator(), terrain.random(),
|
||||
heightAccessor, pos -> island.origin() && protectsDecoration(pos));
|
||||
var placement = LostCityPlanner16.plan(island.seed(), island.centerX(), island.centerZ(),
|
||||
island.diameter(), island.origin(), sampled);
|
||||
var surface = placement.surface();
|
||||
citySupports16.put(cityIdentity(island), placement.supports());
|
||||
if (placement.reservation() != null) cityReservations16.put(cityIdentity(island), placement.reservation());
|
||||
var supports = new fr.koka.sanctuary.worldgen.city.LostCityProtection16.Builder();
|
||||
for (var support : placement.supports()) supports.addViaductSupport(support);
|
||||
if (placement.reservation() != null) supports.addReservation(placement.reservation());
|
||||
var supportMask = supports.build();
|
||||
var underground = fr.koka.sanctuary.worldgen.city.LostCityUnderground16.plan(island.seed(), surface,
|
||||
new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Terrain() {
|
||||
@Override public boolean solid(int x, int y, int z) { return sampled.solid(x, y, z); }
|
||||
@Override public boolean owns(int x, int z) { return sampled.owns(x, z); }
|
||||
@Override public boolean exteriorAir(int x, int y, int z, int dx, int dz) { return sampled.exteriorAir(x, y, z, dx, dz); }
|
||||
@Override public boolean protectedAt(int x, int y, int z) {
|
||||
return supportMask.contains(x, y, z) || sampled.protectedAt(x, y, z);
|
||||
}
|
||||
});
|
||||
undergroundPlans16.put(cityIdentity(island), underground);
|
||||
SanctuaryMod.LOGGER.info("Lost City16 {} seed {}: {} city districts, {} underground networks, {} noise columns in {} ms",
|
||||
island.id(), island.seed(), surface.districts().size(), underground.networks().size(), sampled.columnsUsed(),
|
||||
(System.nanoTime() - start) / 1_000_000);
|
||||
return surface;
|
||||
}
|
||||
if (generation == 15) {
|
||||
var sampled = new fr.koka.sanctuary.worldgen.city.LostCityTerrain15(island, terrain.generator(), terrain.random(),
|
||||
heightAccessor, pos -> island.origin() && protectsDecoration(pos));
|
||||
var placement = LostCityPlanner15.plan(island.seed(), island.centerX(), island.centerZ(),
|
||||
island.diameter(), island.origin(), sampled);
|
||||
var surface = placement.surface();
|
||||
citySupports.put(cityIdentity(island), placement.supports());
|
||||
var supports = new fr.koka.sanctuary.worldgen.city.LostCityProtection15.Builder();
|
||||
for (var support : placement.supports()) supports.addViaductSupport(support);
|
||||
var supportMask = supports.build();
|
||||
var underground = fr.koka.sanctuary.worldgen.city.LostCityUnderground15.plan(island.seed(), surface,
|
||||
new fr.koka.sanctuary.worldgen.city.LostCityUnderground15.Terrain() {
|
||||
@Override public boolean solid(int x, int y, int z) { return sampled.solid(x, y, z); }
|
||||
@Override public boolean owns(int x, int z) { return sampled.owns(x, z); }
|
||||
@Override public boolean protectedAt(int x, int y, int z) {
|
||||
return supportMask.contains(x, y, z) || sampled.protectedAt(x, y, z);
|
||||
}
|
||||
});
|
||||
undergroundPlans.put(cityIdentity(island), underground);
|
||||
SanctuaryMod.LOGGER.info("Lost City15 {} seed {}: {} city districts, {} underground networks, {} noise columns in {} ms",
|
||||
island.id(), island.seed(), surface.districts().size(), underground.networks().size(), sampled.columnsUsed(),
|
||||
(System.nanoTime() - start) / 1_000_000);
|
||||
return surface;
|
||||
}
|
||||
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 == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) return cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ())
|
||||
|| (generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ())
|
||||
|| (generation == 19 || generation == 20 || generation == 21 || generation == 22) && heritagePlan19.protects(pos.getX(), pos.getY(), pos.getZ())
|
||||
|| (generation == 21 || generation == 22) && transitPlan21.protects(pos.getX(), pos.getY(), pos.getZ())
|
||||
|| generation == 22 && undergroundHallProtection22.contains(pos.getX(), pos.getY(), pos.getZ());
|
||||
if (generation == 15) return cityProtection15.contains(pos.getX(), pos.getY(), pos.getZ());
|
||||
if (generation != 14) return false;
|
||||
var island = islandAt(pos.getX(), pos.getZ());
|
||||
// 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());
|
||||
}
|
||||
public boolean intersectsCityConstruction15(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
|
||||
return generation == 15 && cityProtection15.intersects(minX, minY, minZ, maxX, maxY, maxZ);
|
||||
}
|
||||
public fr.koka.sanctuary.worldgen.city.LostCityUnderground15.Plan undergroundPlan(ExpansionIsland island) {
|
||||
if (generation != 15) return new fr.koka.sanctuary.worldgen.city.LostCityUnderground15.Plan(List.of(), 0);
|
||||
lostCityPlan(island);
|
||||
return undergroundPlans.getOrDefault(cityIdentity(island), new fr.koka.sanctuary.worldgen.city.LostCityUnderground15.Plan(List.of(), 0));
|
||||
}
|
||||
public List<LostCityPlanner15.Support> citySupports(ExpansionIsland island) {
|
||||
if (generation != 15) return List.of();
|
||||
lostCityPlan(island);
|
||||
return citySupports.getOrDefault(cityIdentity(island), List.of());
|
||||
}
|
||||
public boolean intersectsCityConstruction16(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
|
||||
return (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && cityProtection16.intersects(minX, minY, minZ, maxX, maxY, maxZ);
|
||||
}
|
||||
public fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan undergroundPlan16(ExpansionIsland island) {
|
||||
if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22) return new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan(List.of(), 0);
|
||||
lostCityPlan(island);
|
||||
return undergroundPlans16.getOrDefault(cityIdentity(island), new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan(List.of(), 0));
|
||||
}
|
||||
public List<LostCityPlanner16.Support> citySupports16(ExpansionIsland island) {
|
||||
if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22) return List.of();
|
||||
lostCityPlan(island);
|
||||
return citySupports16.getOrDefault(cityIdentity(island), List.of());
|
||||
}
|
||||
public LostCityPlanner16.Fallback cityReservation16(ExpansionIsland island) {
|
||||
if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22) return null;
|
||||
lostCityPlan(island);
|
||||
return cityReservations16.get(cityIdentity(island));
|
||||
}
|
||||
/** 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 (generation == 22 && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.KEY))
|
||||
return fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.findNearest(level, structures, origin, radius, skipReferenced);
|
||||
if (structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.city.LostCityStructures.KEY))
|
||||
return fr.koka.sanctuary.worldgen.city.LostCityStructures.findNearest(level, structures, origin, radius, skipReferenced);
|
||||
if ((generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.KEY))
|
||||
return fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.findNearest(level, structures, origin, radius, skipReferenced);
|
||||
if ((generation == 19 || generation == 20 || generation == 21 || generation == 22) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.KEY))
|
||||
return fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.findNearest(level, structures, origin, radius, skipReferenced);
|
||||
if ((generation == 21 || generation == 22) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.transit.TransitStructures21.KEY))
|
||||
return fr.koka.sanctuary.worldgen.transit.TransitStructures21.findNearest(level, structures, origin, radius, skipReferenced);
|
||||
return super.findNearestMapStructure(level, structures, origin, radius, skipReferenced);
|
||||
}
|
||||
@Override protected Context context(ExpansionIsland island) {
|
||||
if (!island.origin()) return super.context(island);
|
||||
Context current = originContext;
|
||||
if (current != null) return current;
|
||||
synchronized (this) {
|
||||
if (originContext == null) {
|
||||
if (registries == null) throw new IllegalStateException("Sanctuary used before world attachment");
|
||||
var settings = registries.lookupOrThrow(Registries.NOISE_SETTINGS)
|
||||
.getOrThrow(net.minecraft.resources.ResourceKey.create(Registries.NOISE_SETTINGS,
|
||||
SanctuaryMod.id("unified_" + capacity().players())));
|
||||
var delegate = new NoiseBasedChunkGenerator(source(), settings);
|
||||
var random = RandomState.create(registries.lookupOrThrow(Registries.NOISE), island.seed(), settings.value());
|
||||
originContext = new Context(delegate, random);
|
||||
}
|
||||
return originContext;
|
||||
}
|
||||
}
|
||||
public PopulationGenerationContext.Binding starterContext() {
|
||||
var current = originContext;
|
||||
if (current == null) throw new IllegalStateException("Sanctuary used before world attachment");
|
||||
return new PopulationGenerationContext.Binding(current.generator(), current.random());
|
||||
}
|
||||
@Override protected void afterTerrain(ExpansionIsland island, ChunkAccess chunk) {
|
||||
// Root water/lava are already applied by the Population hook on the named delegate.
|
||||
if (!island.origin()) super.afterTerrain(island, chunk);
|
||||
if ((generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && cityStructuresEnabled) {
|
||||
var reserved = cityReservations16.get(cityIdentity(island));
|
||||
if (reserved != null) {
|
||||
var mutable = new net.minecraft.core.BlockPos.MutableBlockPos();
|
||||
var at = chunk.getPos();
|
||||
for (int x = Math.max(reserved.minX(), at.getMinBlockX()); x <= Math.min(reserved.maxX(), at.getMaxBlockX()); x++)
|
||||
for (int z = Math.max(reserved.minZ(), at.getMinBlockZ()); z <= Math.min(reserved.maxZ(), at.getMaxBlockZ()); z++)
|
||||
for (int y = reserved.minY(); y <= reserved.maxY(); y++)
|
||||
chunk.setBlockState(mutable.set(x, y, z), net.minecraft.world.level.block.Blocks.AIR.defaultBlockState(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import java.util.BitSet;
|
||||
import net.minecraft.core.BlockPos;
|
||||
|
||||
/** Immutable per-chunk mask of the starter's water, beds, supports and flow passages.
|
||||
* Native cave features may decorate freely outside these previously planned volumes. */
|
||||
public final class SanctuaryDecorationProtection {
|
||||
private final int minX, minZ;
|
||||
private final BitSet mask = new BitSet(16 * 16 * 384);
|
||||
private SanctuaryDecorationProtection(int chunkX, int chunkZ) { minX = chunkX * 16; minZ = chunkZ * 16; }
|
||||
|
||||
public static SanctuaryDecorationProtection create(PopulationGenerationContext.Binding binding, int chunkX, int chunkZ) {
|
||||
var result = new SanctuaryDecorationProtection(chunkX, chunkZ);
|
||||
for (var region : PopulationHydrologyRuntime.plansIntersecting(binding.generator(), binding.random(),
|
||||
result.minX - 1, result.minZ - 1, result.minX + 16, result.minZ + 16)) {
|
||||
// A retaining wall can belong to the neighbouring chunk of its water cell.
|
||||
for (int cx = chunkX - 1; cx <= chunkX + 1; cx++)
|
||||
for (int cz = chunkZ - 1; cz <= chunkZ + 1; cz++)
|
||||
for (var cell : region.water().cellsInChunk(cx, cz)) {
|
||||
if (cell.x() < result.minX - 1 || cell.x() > result.minX + 16
|
||||
|| cell.z() < result.minZ - 1 || cell.z() > result.minZ + 16) continue;
|
||||
result.box(cell.x() - 1, cell.bedY() - cell.sedimentDepth() - 2, cell.z() - 1,
|
||||
cell.x() + 1, cell.carveTop() + 1, cell.z() + 1);
|
||||
}
|
||||
for (var spring : region.water().springs()) {
|
||||
result.flow(spring.flowBounds(), 2);
|
||||
var source = spring.source();
|
||||
result.cube(source.x(), source.y(), source.z(), 3);
|
||||
}
|
||||
for (var spill : region.water().spills()) result.flow(spill.flowBounds(), 3);
|
||||
for (var fall : region.lava().flowBounds()) result.flow(fall, 8);
|
||||
for (var cell : region.lava().cells()) result.cube(cell.x(), cell.y(), cell.z(), 2);
|
||||
for (var support : region.lava().supports()) result.cube(support.x(), support.y(), support.z(), 2);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
public boolean protects(BlockPos pos) {
|
||||
int x = pos.getX() - minX, z = pos.getZ() - minZ, y = pos.getY();
|
||||
return x >= 0 && x < 16 && z >= 0 && z < 16 && y >= 0 && y < 384
|
||||
&& mask.get((x * 16 + z) * 384 + y);
|
||||
}
|
||||
public int protectedBlocks() { return mask.cardinality(); }
|
||||
private void flow(PopulationHydrology.FlowBounds b, int pad) {
|
||||
box(b.minX() - pad, b.minY() - pad, b.minZ() - pad,
|
||||
b.maxX() + pad, b.maxY() + pad, b.maxZ() + pad);
|
||||
}
|
||||
private void cube(int x, int y, int z, int radius) {
|
||||
box(x - radius, y - radius, z - radius, x + radius, y + radius, z + radius);
|
||||
}
|
||||
private void box(int x1, int y1, int z1, int x2, int y2, int z2) {
|
||||
int lo = Math.max(0, y1), hi = Math.min(383, y2);
|
||||
if (lo > hi) return;
|
||||
for (int x = Math.max(minX, x1); x <= Math.min(minX + 15, x2); x++)
|
||||
for (int z = Math.max(minZ, z1); z <= Math.min(minZ + 15, z2); z++) {
|
||||
int column = ((x - minX) * 16 + z - minZ) * 384;
|
||||
mask.set(column + lo, column + hi + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package fr.koka.sanctuary.worldgen.city;
|
||||
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/** Explicit masonry for the mandatory starter's last-resort architectural foundation.
|
||||
* The upper city owns every block from floorY-2 upward. This renderer writes only below that
|
||||
* boundary, so loading native pieces in another order cannot erase streets or buildings. */
|
||||
public final class LostCityFoundation16 {
|
||||
private LostCityFoundation16() {}
|
||||
@FunctionalInterface public interface Writer { void set(int x, int y, int z, BlockState state); }
|
||||
|
||||
/** World coordinates, with no terrain reads and no chunk requests. */
|
||||
public static void render(LostCityPlanner16.Fallback reservation, long seed, Writer writer) {
|
||||
int floor = reservation.floorY();
|
||||
for (int z = reservation.minZ(); z <= reservation.maxZ(); z++)
|
||||
for (int x = reservation.minX(); x <= reservation.maxX(); x++) {
|
||||
int lx = x - reservation.minX(), lz = z - reservation.minZ();
|
||||
boolean wall = lx == 0 || lx == 48 || lz == 0 || lz == 48;
|
||||
boolean column = nearPier(lx) && nearPier(lz);
|
||||
for (int y = reservation.minY(); y <= floor - 3; y++) {
|
||||
BlockState state;
|
||||
if (y >= floor - 5) state = y == floor - 3 ? Blocks.SMOOTH_STONE.defaultBlockState()
|
||||
: LostCityPalette.masonry(seed, lx, y - floor, lz);
|
||||
else if (y == reservation.minY()) state = Blocks.SMOOTH_STONE.defaultBlockState();
|
||||
else if (wall || column || y == floor - 6 && (nearPier(lx) || nearPier(lz)))
|
||||
state = LostCityPalette.masonry(seed, lx, y - floor, lz);
|
||||
else state = Blocks.AIR.defaultBlockState();
|
||||
writer.set(x, y, z, state);
|
||||
}
|
||||
}
|
||||
for (var anchor : reservation.anchors())
|
||||
for (int z = anchor.minZ(); z <= anchor.maxZ(); z++)
|
||||
for (int x = anchor.minX(); x <= anchor.maxX(); x++)
|
||||
for (int y = anchor.minY(); y <= anchor.maxY(); y++)
|
||||
writer.set(x, y, z, y == anchor.minY() || y == anchor.maxY()
|
||||
? Blocks.SMOOTH_STONE.defaultBlockState()
|
||||
: LostCityPalette.masonry(seed, x - reservation.minX(), y - floor, z - reservation.minZ()));
|
||||
}
|
||||
|
||||
private static boolean nearPier(int coordinate) {
|
||||
for (int center = 2; center <= 46; center += 11) if (Math.abs(coordinate - center) <= 1) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package fr.koka.sanctuary.worldgen.city;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
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.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;
|
||||
|
||||
/** Saved generation16 fallback masonry. The enclosing bounding box is NOT its write mask:
|
||||
* below the basement only the explicitly saved three-by-three anchors are written. */
|
||||
public final class LostCityFoundationPiece16 extends StructurePiece {
|
||||
private final LostCityPlanner16.Fallback reservation;
|
||||
private final long seed;
|
||||
|
||||
public LostCityFoundationPiece16(LostCityPlanner16.Fallback reservation, long seed) {
|
||||
super(LostCityStructures.FOUNDATION16, 0, bounds(reservation));
|
||||
this.reservation = reservation; this.seed = seed;
|
||||
setOrientation(null);
|
||||
}
|
||||
public LostCityFoundationPiece16(StructurePieceSerializationContext context, CompoundTag tag) {
|
||||
super(LostCityStructures.FOUNDATION16, tag);
|
||||
reservation = readReservation(tag); seed = tag.getLong("FoundationSeed").orElseThrow();
|
||||
if (!getBoundingBox().equals(bounds(reservation)))
|
||||
throw new IllegalArgumentException("Saved city foundation bounds disagree with its geometry");
|
||||
setOrientation(null);
|
||||
}
|
||||
public LostCityPlanner16.Fallback reservation() { return reservation; }
|
||||
|
||||
@Override public BlockPos getLocatorPosition() {
|
||||
return new BlockPos(reservation.minX() + 24, reservation.floorY() + 1, reservation.minZ() + 24);
|
||||
}
|
||||
@Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) {
|
||||
writeReservation(reservation, tag); tag.putLong("FoundationSeed", seed);
|
||||
}
|
||||
/** Public codec helpers are also used by round-trip tests without a live StructureStart. */
|
||||
public static void writeReservation(LostCityPlanner16.Fallback r, CompoundTag tag) {
|
||||
tag.putInt("SanctuarySchema", 1);
|
||||
tag.putInt("MinX", r.minX()); tag.putInt("MinZ", r.minZ());
|
||||
tag.putInt("Width", r.width()); tag.putInt("Depth", r.depth()); tag.putInt("FloorY", r.floorY());
|
||||
tag.putInt("MinY", r.minY()); tag.putInt("MaxY", r.maxY());
|
||||
int[] anchors = new int[r.anchors().size() * 6]; int i = 0;
|
||||
for (var a : r.anchors()) {
|
||||
anchors[i++] = a.minX(); anchors[i++] = a.minY(); anchors[i++] = a.minZ();
|
||||
anchors[i++] = a.maxX(); anchors[i++] = a.maxY(); anchors[i++] = a.maxZ();
|
||||
}
|
||||
tag.putIntArray("Anchors", anchors);
|
||||
}
|
||||
public static LostCityPlanner16.Fallback readReservation(CompoundTag tag) {
|
||||
if (tag.getInt("SanctuarySchema").orElseThrow() != 1)
|
||||
throw new IllegalArgumentException("Unsupported generation16 foundation schema");
|
||||
int[] saved = tag.getIntArray("Anchors").orElseThrow();
|
||||
if (saved.length % 6 != 0 || saved.length > 150) throw new IllegalArgumentException("Invalid foundation anchors");
|
||||
var anchors = new ArrayList<LostCityPlanner16.Anchor>();
|
||||
for (int i = 0; i < saved.length; i += 6)
|
||||
anchors.add(new LostCityPlanner16.Anchor(saved[i], saved[i + 1], saved[i + 2], saved[i + 3], saved[i + 4], saved[i + 5]));
|
||||
return new LostCityPlanner16.Fallback(tag.getInt("MinX").orElseThrow(), tag.getInt("MinZ").orElseThrow(),
|
||||
tag.getInt("Width").orElseThrow(), tag.getInt("Depth").orElseThrow(), tag.getInt("FloorY").orElseThrow(),
|
||||
tag.getInt("MinY").orElseThrow(), tag.getInt("MaxY").orElseThrow(), anchors);
|
||||
}
|
||||
@Override public void postProcess(WorldGenLevel level, StructureManager manager, ChunkGenerator generator,
|
||||
RandomSource random, BoundingBox clip, ChunkPos chunk, BlockPos pivot) {
|
||||
LostCityStructures.placing(() -> LostCityFoundation16.render(reservation, seed, (x, y, z, state) -> {
|
||||
if (clip.isInside(x, y, z)) placeBlock(level, state, x, y, z, clip);
|
||||
}));
|
||||
}
|
||||
public static BoundingBox bounds(LostCityPlanner16.Fallback r) {
|
||||
int bottom = r.anchors().stream().mapToInt(LostCityPlanner16.Anchor::minY).min().orElse(r.minY());
|
||||
return new BoundingBox(r.minX(), bottom, r.minZ(), r.maxX(), r.floorY() - 3, r.maxZ());
|
||||
}
|
||||
}
|
||||
+468
@@ -0,0 +1,468 @@
|
||||
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.ArrayList;
|
||||
import java.util.List;
|
||||
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.block.state.properties.DoorHingeSide;
|
||||
import net.minecraft.world.level.block.state.properties.DoubleBlockHalf;
|
||||
import net.minecraft.world.level.block.state.properties.SlabType;
|
||||
import net.minecraft.world.level.storage.loot.BuiltInLootTables;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
|
||||
/** Complete generation-15 buildings. Generation 14 deliberately never calls this renderer.
|
||||
* A north-facing plan is rotated once; all positions remain relative to the saved parcel. */
|
||||
public final class LostCityInteriors15 {
|
||||
private static final BlockState AIR = Blocks.AIR.defaultBlockState();
|
||||
private static final BlockState STONE = Blocks.STONE.defaultBlockState();
|
||||
private static final BlockState SMOOTH = Blocks.SMOOTH_STONE.defaultBlockState();
|
||||
private static final BlockState WOOD = Blocks.DARK_OAK_PLANKS.defaultBlockState();
|
||||
private static final BlockState TILE = Blocks.POLISHED_ANDESITE.defaultBlockState();
|
||||
private static final int STOREY = 5;
|
||||
|
||||
private LostCityInteriors15() {}
|
||||
|
||||
/** Reachable standing positions, in the same rotated local coordinates as BlockWriter. */
|
||||
public record Room(String name, int x, int y, int z) {}
|
||||
|
||||
public static void render(Parcel parcel, LostCityRenderer.BlockWriter output) {
|
||||
validate(parcel);
|
||||
new Building(parcel, output).render();
|
||||
}
|
||||
|
||||
/** These describe actual rooms and services, not decorative labels placed in the world. */
|
||||
public static List<Room> rooms(Parcel parcel) {
|
||||
validate(parcel);
|
||||
List<Room> result = new ArrayList<>();
|
||||
add(result, parcel, "street_entrance", 8, 1, 0);
|
||||
add(result, parcel, "vestibule", 8, 1, 3);
|
||||
for (int floor = 0; floor < parcel.floors(); floor++) {
|
||||
int y = floor * STOREY + 1;
|
||||
String prefix = "floor_" + floor + "/";
|
||||
add(result, parcel, prefix + "corridor", 8, y, 8);
|
||||
add(result, parcel, prefix + "stair_landing", 4, y, 8);
|
||||
String front = switch (parcel.kind()) {
|
||||
case APARTMENT -> "kitchen_dining";
|
||||
case HOTEL -> floor == 0 ? "reception" : "guest_suite";
|
||||
case LIBRARY -> "book_stacks";
|
||||
case RESTAURANT -> "dining_room";
|
||||
case BARRACKS -> "dormitory";
|
||||
case HALL -> floor == 0 ? "ticket_office" : "administration";
|
||||
default -> throw new IllegalArgumentException("Not an interior kind");
|
||||
};
|
||||
add(result, parcel, prefix + front, 11, y, 6);
|
||||
if (parcel.kind() == Kind.HOTEL && floor > 0)
|
||||
add(result, parcel, prefix + "suite_bathroom", 12, y, 3);
|
||||
if (parcel.kind() == Kind.HALL && floor < 2) {
|
||||
if (floor == 0) {
|
||||
add(result, parcel, prefix + "auditorium", 8, y, 11);
|
||||
add(result, parcel, prefix + "stage", 9, y + 1, 13);
|
||||
} else add(result, parcel, prefix + "gallery", 8, y, 10);
|
||||
} else {
|
||||
String west = switch (parcel.kind()) {
|
||||
case APARTMENT, HOTEL -> floor == 0 && parcel.kind() == Kind.HOTEL ? "lounge" : "bedroom";
|
||||
case LIBRARY -> "reading_room";
|
||||
case RESTAURANT, BARRACKS -> "washrooms";
|
||||
default -> "meeting_room";
|
||||
};
|
||||
String east = switch (parcel.kind()) {
|
||||
case APARTMENT -> "bathroom";
|
||||
case HOTEL -> floor == 0 ? "laundry" : "guest_room";
|
||||
case LIBRARY -> "archive";
|
||||
case RESTAURANT -> "kitchen";
|
||||
case BARRACKS -> "equipment_store";
|
||||
default -> "meeting_room_east";
|
||||
};
|
||||
add(result, parcel, prefix + west, 5, y, 12);
|
||||
add(result, parcel, prefix + east, 11, y, 12);
|
||||
if (parcel.kind() == Kind.APARTMENT)
|
||||
add(result, parcel, prefix + "bathroom_cubicle", 12, y, 14);
|
||||
if (parcel.kind() == Kind.RESTAURANT || parcel.kind() == Kind.BARRACKS)
|
||||
add(result, parcel, prefix + "washroom_cubicle", 3, y, 14);
|
||||
}
|
||||
}
|
||||
add(result, parcel, "roof", 8, parcel.floors() * STOREY + 1, 8);
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static void validate(Parcel p) {
|
||||
if (!p.kind().building() || p.width() != 17 || p.depth() != 17 || p.floors() < 2)
|
||||
throw new IllegalArgumentException("Generation-15 interiors require a 17x17 building with 2..5 floors");
|
||||
}
|
||||
|
||||
private static void add(List<Room> result, Parcel p, String name, int x, int y, int z) {
|
||||
result.add(new Room(name, rx(x, z, p.facing()), y, rz(x, z, p.facing())));
|
||||
}
|
||||
private static int rx(int x, int z, int facing) {
|
||||
return switch (facing) { case 0 -> x; case 1 -> 16 - z; case 2 -> 16 - x; default -> z; };
|
||||
}
|
||||
private static int rz(int x, int z, int facing) {
|
||||
return switch (facing) { case 0 -> z; case 1 -> x; case 2 -> 16 - z; default -> 16 - x; };
|
||||
}
|
||||
|
||||
private static final class Building {
|
||||
final Parcel p;
|
||||
final LostCityRenderer.BlockWriter output;
|
||||
final Rotation rotation;
|
||||
Building(Parcel p, LostCityRenderer.BlockWriter output) {
|
||||
this.p = p;
|
||||
this.output = output;
|
||||
rotation = switch (p.facing()) {
|
||||
case 0 -> Rotation.NONE; case 1 -> Rotation.CLOCKWISE_90;
|
||||
case 2 -> Rotation.CLOCKWISE_180; default -> Rotation.COUNTERCLOCKWISE_90;
|
||||
};
|
||||
}
|
||||
void set(int x, int y, int z, BlockState state) {
|
||||
output.set(rx(x, z, p.facing()), y, rz(x, z, p.facing()), state.rotate(rotation));
|
||||
}
|
||||
void set(int x, int y, int z, Block block) { set(x, y, z, block.defaultBlockState()); }
|
||||
void box(int x0, int y0, int z0, int x1, int y1, int z1, BlockState state) {
|
||||
for (int x = x0; x <= x1; x++) for (int z = z0; z <= z1; z++)
|
||||
for (int y = y0; y <= y1; y++) set(x, y, z, state);
|
||||
}
|
||||
BlockState facing(Block block, Direction direction) {
|
||||
return block.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, direction);
|
||||
}
|
||||
BlockState brick(int x, int y, int z) { return LostCityPalette.masonry(p.seed(), x, y, z); }
|
||||
int roll(int x, int y, int z, int bound) { return LostCityPalette.roll(p.seed(), x, y, z, bound); }
|
||||
|
||||
void render() {
|
||||
int roof = p.floors() * STOREY;
|
||||
box(0, -p.foundationDepth(), 0, 16, -1, 16, STONE);
|
||||
box(0, 0, 0, 16, 0, 16, SMOOTH);
|
||||
box(0, 1, 0, 16, roof + 4, 16, AIR);
|
||||
shell(roof);
|
||||
for (int floor = 0; floor < p.floors(); floor++) {
|
||||
partitions(floor);
|
||||
furnishings(floor);
|
||||
}
|
||||
stairs();
|
||||
entrance();
|
||||
roof(roof);
|
||||
// Only one deferred loot table per building; empty furniture is not a resource vault.
|
||||
int lootX = 14;
|
||||
int lootZ = p.kind() == Kind.HALL ? 6 : 14;
|
||||
box(lootX, 2, lootZ, lootX, 3, lootZ, AIR);
|
||||
ResourceKey<LootTable> table = switch (p.kind()) {
|
||||
case LIBRARY, HALL -> BuiltInLootTables.VILLAGE_CARTOGRAPHER;
|
||||
case RESTAURANT -> BuiltInLootTables.VILLAGE_BUTCHER;
|
||||
case BARRACKS -> BuiltInLootTables.VILLAGE_FLETCHER;
|
||||
default -> BuiltInLootTables.VILLAGE_PLAINS_HOUSE;
|
||||
};
|
||||
output.chest(rx(lootX, lootZ, p.facing()), 1, rz(lootX, lootZ, p.facing()), table,
|
||||
p.seed() ^ 0x15c17a7eL ^ p.kind().ordinal());
|
||||
}
|
||||
|
||||
void shell(int roof) {
|
||||
for (int floor = 0; floor <= p.floors(); floor++) {
|
||||
int base = floor * STOREY;
|
||||
box(1, base, 1, 15, base, 15, floor == p.floors() ? SMOOTH : floorMaterial());
|
||||
if (floor == p.floors()) continue;
|
||||
// A pale ceiling and contrasting structural floor are separate layers.
|
||||
box(2, base + 4, 2, 14, base + 4, 14,
|
||||
Blocks.SMOOTH_STONE_SLAB.defaultBlockState().setValue(BlockStateProperties.SLAB_TYPE, SlabType.TOP));
|
||||
for (int y = 1; y <= 4; y++) for (int horizontal = 1; horizontal <= 15; horizontal++) {
|
||||
facade(horizontal, base + y, 1, horizontal, y, true);
|
||||
facade(horizontal, base + y, 15, horizontal, y, true);
|
||||
facade(1, base + y, horizontal, horizontal, y, false);
|
||||
facade(15, base + y, horizontal, horizontal, y, false);
|
||||
}
|
||||
// Floor bands remain intact even where windows have fallen out.
|
||||
for (int n = 1; n <= 15; n++) {
|
||||
set(n, base, 1, SMOOTH); set(n, base, 15, SMOOTH);
|
||||
set(1, base, n, SMOOTH); set(15, base, n, SMOOTH);
|
||||
}
|
||||
if (p.kind() == Kind.HOTEL || p.kind() == Kind.LIBRARY) {
|
||||
for (int x : new int[]{3, 13}) for (int y = 0; y <= 4; y++) set(x, base + y, 0, SMOOTH);
|
||||
}
|
||||
if (p.kind() == Kind.APARTMENT) for (int x = 11; x <= 14; x++)
|
||||
set(x, base + 1, 0, Blocks.SMOOTH_STONE_SLAB);
|
||||
if (p.kind() == Kind.RESTAURANT) box(10, base + 4, 0, 14, base + 4, 0,
|
||||
Blocks.DARK_OAK_SLAB.defaultBlockState());
|
||||
}
|
||||
if (p.kind() == Kind.HALL) {
|
||||
// The auditorium spans two storeys; its upper level is a guarded gallery.
|
||||
box(3, 4, 11, 13, 5, 14, AIR);
|
||||
for (int x = 3; x <= 13; x++) set(x, 6, 11, rail(true));
|
||||
for (int z = 12; z <= 14; z++) { set(3, 6, z, rail(false)); set(13, 6, z, rail(false)); }
|
||||
}
|
||||
}
|
||||
|
||||
BlockState floorMaterial() {
|
||||
return switch (p.kind()) {
|
||||
case LIBRARY, APARTMENT, HOTEL -> WOOD;
|
||||
case RESTAURANT, BARRACKS -> TILE;
|
||||
default -> SMOOTH;
|
||||
};
|
||||
}
|
||||
|
||||
void facade(int x, int y, int z, int horizontal, int withinFloor, boolean eastWest) {
|
||||
boolean column = horizontal == 1 || horizontal == 6 || horizontal == 10 || horizontal == 15;
|
||||
boolean window = !column && (withinFloor == 2 || withinFloor == 3
|
||||
|| withinFloor == 1 && (p.kind() == Kind.LIBRARY || p.kind() == Kind.RESTAURANT || p.kind() == Kind.HALL));
|
||||
if (column) set(x, y, z, p.kind() == Kind.BARRACKS ? brick(x, y, z) : STONE);
|
||||
else if (window) {
|
||||
if (roll(x, y / 5, z, 13) != 0)
|
||||
set(x, y, z, p.kind() == Kind.BARRACKS ? rail(eastWest) : pane(eastWest));
|
||||
} else set(x, y, z, withinFloor == 4 ? SMOOTH : brick(x, y, z));
|
||||
}
|
||||
|
||||
void partitions(int floor) {
|
||||
int y = floor * STOREY;
|
||||
// The west stairwell, central corridor and vestibule are independent of furniture.
|
||||
box(5, y + 1, 2, 5, y + 4, 9, SMOOTH);
|
||||
door(5, y + 1, 8, Direction.WEST, false);
|
||||
box(10, y + 1, 2, 10, y + 4, 7, SMOOTH);
|
||||
box(10, y + 1, 7, 14, y + 4, 7, SMOOTH);
|
||||
door(10, y + 1, 6, Direction.EAST, false);
|
||||
if (floor == 0) {
|
||||
box(6, 1, 4, 9, 4, 4, SMOOTH);
|
||||
door(8, 1, 4, Direction.SOUTH, false);
|
||||
}
|
||||
if (p.kind() == Kind.HALL && floor < 2) {
|
||||
if (floor == 0) {
|
||||
box(2, 1, 10, 14, 4, 10, SMOOTH);
|
||||
door(7, 1, 10, Direction.SOUTH, true);
|
||||
door(8, 1, 10, Direction.SOUTH, false);
|
||||
}
|
||||
} else {
|
||||
box(2, y + 1, 10, 6, y + 4, 10, SMOOTH);
|
||||
box(6, y + 1, 10, 6, y + 4, 14, SMOOTH);
|
||||
box(10, y + 1, 10, 14, y + 4, 10, SMOOTH);
|
||||
box(10, y + 1, 10, 10, y + 4, 14, SMOOTH);
|
||||
door(6, y + 1, 12, Direction.WEST, false);
|
||||
door(10, y + 1, 12, Direction.EAST, false);
|
||||
}
|
||||
// A hanging lantern touches a full ceiling block, never the recessed underside of a top slab.
|
||||
set(8, y + 4, 8, SMOOTH);
|
||||
set(8, y + 3, 8, Blocks.LANTERN.defaultBlockState().setValue(BlockStateProperties.HANGING, true));
|
||||
// The hall's stage is raised one block: keep its two-block headroom free of debris.
|
||||
if (p.kind() == Kind.HALL && floor == 0) set(14, y + 3, 14, Blocks.COBWEB);
|
||||
else set(9, y + 3, 13, Blocks.COBWEB);
|
||||
set(14, y + 3, 6, Blocks.COBWEB);
|
||||
}
|
||||
|
||||
void furnishings(int floor) {
|
||||
int y = floor * STOREY;
|
||||
switch (p.kind()) {
|
||||
case APARTMENT -> {
|
||||
kitchen(y, true);
|
||||
bedroom(y, false);
|
||||
washroom(y, true);
|
||||
}
|
||||
case HOTEL -> {
|
||||
if (floor == 0) { reception(y); lounge(y, false); laundry(y); }
|
||||
else { guestSuite(y); bedroom(y, false); bedroom(y, true); }
|
||||
}
|
||||
case LIBRARY -> {
|
||||
box(14, y + 1, 2, 14, y + 3, 6, Blocks.BOOKSHELF.defaultBlockState());
|
||||
box(12, y + 1, 2, 12, y + 2, 3, Blocks.BOOKSHELF.defaultBlockState());
|
||||
desk(12, y, 5, Direction.SOUTH);
|
||||
readingRoom(y, false);
|
||||
box(12, y + 1, 14, 14, y + 3, 14, Blocks.BOOKSHELF.defaultBlockState());
|
||||
box(14, y + 1, 11, 14, y + 2, 12, Blocks.BARREL.defaultBlockState());
|
||||
set(12, y + 1, 11, facing(Blocks.LECTERN, Direction.SOUTH));
|
||||
}
|
||||
case RESTAURANT -> {
|
||||
table(13, y, 3); table(13, y, 5);
|
||||
set(11, y + 1, 2, Blocks.POTTED_DEAD_BUSH);
|
||||
kitchen(y, false); washroom(y, false);
|
||||
}
|
||||
case BARRACKS -> {
|
||||
bed(12, y + 1, 3, Direction.SOUTH); bed(14, y + 1, 3, Direction.SOUTH);
|
||||
set(11, y + 1, 2, Blocks.BARREL); set(13, y + 1, 2, Blocks.BARREL);
|
||||
seat(13, y + 1, 6, Direction.NORTH);
|
||||
washroom(y, false);
|
||||
box(12, y + 1, 14, 14, y + 2, 14, Blocks.BARREL.defaultBlockState());
|
||||
set(14, y + 1, 11, Blocks.FLETCHING_TABLE);
|
||||
set(12, y + 1, 11, Blocks.GRINDSTONE);
|
||||
}
|
||||
case HALL -> {
|
||||
reception(y);
|
||||
if (floor == 0) auditorium();
|
||||
else if (floor >= 2) { readingRoom(y, false); readingRoom(y, true); }
|
||||
}
|
||||
default -> throw new IllegalArgumentException("Not an interior kind");
|
||||
}
|
||||
}
|
||||
|
||||
void kitchen(int y, boolean front) {
|
||||
int z = front ? 2 : 14;
|
||||
Direction face = front ? Direction.SOUTH : Direction.NORTH;
|
||||
set(11, y + 1, z, facing(Blocks.FURNACE, face));
|
||||
set(12, y + 1, z, Blocks.WATER_CAULDRON.defaultBlockState().setValue(BlockStateProperties.LEVEL_CAULDRON, 2));
|
||||
set(13, y + 1, z, facing(Blocks.SMOKER, face));
|
||||
set(14, y + 1, z, Blocks.BARREL);
|
||||
box(11, y + 3, z, 14, y + 3, z, SMOOTH);
|
||||
set(13, y + 2, z, Blocks.IRON_TRAPDOOR);
|
||||
if (front) table(13, y, 5);
|
||||
else {
|
||||
set(14, y + 1, 11, SMOOTH); set(14, y + 2, 11, Blocks.FLOWER_POT);
|
||||
set(12, y + 1, 11, Blocks.CRAFTING_TABLE);
|
||||
}
|
||||
for (int x = 11; x <= 14; x++) set(x, y, z, (x & 1) == 0 ? SMOOTH : TILE);
|
||||
}
|
||||
|
||||
void bedroom(int y, boolean east) {
|
||||
int x = east ? 13 : 2;
|
||||
bed(x, y + 1, 12, Direction.SOUTH);
|
||||
set(x + 1, y + 1, 13, Blocks.BARREL);
|
||||
set(x + 1, y + 2, 13, Blocks.FLOWER_POT);
|
||||
int deskX = east ? 12 : 3;
|
||||
desk(deskX, y, 11, Direction.SOUTH);
|
||||
set(east ? 14 : 2, y + 1, 14, Blocks.BOOKSHELF);
|
||||
}
|
||||
|
||||
void guestSuite(int y) {
|
||||
box(11, y + 1, 4, 14, y + 4, 4, SMOOTH);
|
||||
door(12, y + 1, 4, Direction.NORTH, false);
|
||||
bed(14, y + 1, 5, Direction.SOUTH);
|
||||
set(11, y + 1, 5, Blocks.BARREL); set(11, y + 2, 5, Blocks.FLOWER_POT);
|
||||
set(14, y + 1, 2, Blocks.WATER_CAULDRON.defaultBlockState().setValue(BlockStateProperties.LEVEL_CAULDRON, 2));
|
||||
set(13, y + 1, 2, seating(Blocks.POLISHED_ANDESITE_STAIRS, Direction.SOUTH));
|
||||
set(13, y + 2, 2, Blocks.IRON_TRAPDOOR);
|
||||
for (int x = 11; x <= 14; x++) for (int z = 2; z <= 3; z++) set(x, y, z, ((x + z) & 1) == 0 ? SMOOTH : TILE);
|
||||
}
|
||||
|
||||
void washroom(int y, boolean east) {
|
||||
int left = east ? 11 : 2;
|
||||
int doorX = east ? 12 : 3;
|
||||
box(left, y + 1, 13, left + 2, y + 3, 13, SMOOTH);
|
||||
door(doorX, y + 1, 13, Direction.SOUTH, false);
|
||||
set(left, y + 1, 14, seating(Blocks.POLISHED_ANDESITE_STAIRS, Direction.EAST));
|
||||
set(left, y + 2, 14, Blocks.IRON_TRAPDOOR);
|
||||
set(left + 2, y + 1, 14, Blocks.WATER_CAULDRON.defaultBlockState().setValue(BlockStateProperties.LEVEL_CAULDRON, 2));
|
||||
set(left, y + 1, 11, Blocks.CAULDRON);
|
||||
set(left + 2, y + 1, 11, SMOOTH); set(left + 2, y + 2, 11, Blocks.FLOWER_POT);
|
||||
for (int x = left; x <= left + 3; x++) for (int z = 11; z <= 14; z++) set(x, y, z, ((x + z) & 1) == 0 ? SMOOTH : TILE);
|
||||
}
|
||||
|
||||
void reception(int y) {
|
||||
box(12, y + 1, 5, 14, y + 1, 5, WOOD);
|
||||
set(13, y + 2, 5, Blocks.FLOWER_POT);
|
||||
set(14, y + 1, 2, Blocks.BOOKSHELF); set(14, y + 2, 2, Blocks.BARREL);
|
||||
desk(12, y, 3, Direction.SOUTH);
|
||||
set(11, y + 1, 2, Blocks.POTTED_DEAD_BUSH);
|
||||
}
|
||||
|
||||
void laundry(int y) {
|
||||
for (int z = 11; z <= 12; z++) set(14, y + 1, z, Blocks.WATER_CAULDRON.defaultBlockState()
|
||||
.setValue(BlockStateProperties.LEVEL_CAULDRON, z == 11 ? 1 : 2));
|
||||
box(12, y + 1, 14, 14, y + 1, 14, Blocks.BARREL.defaultBlockState());
|
||||
box(12, y + 3, 14, 14, y + 3, 14, Blocks.DARK_OAK_SLAB.defaultBlockState());
|
||||
set(12, y + 1, 11, Blocks.LOOM);
|
||||
}
|
||||
|
||||
void lounge(int y, boolean east) {
|
||||
int start = east ? 11 : 2;
|
||||
box(start, y + 1, 14, start + 2, y + 1, 14, seating(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
|
||||
desk(start + 1, y, 11, Direction.SOUTH);
|
||||
set(start, y + 1, 12, Blocks.BOOKSHELF);
|
||||
}
|
||||
|
||||
void readingRoom(int y, boolean east) {
|
||||
int left = east ? 11 : 2;
|
||||
desk(left + 1, y, 12, Direction.SOUTH);
|
||||
box(left, y + 1, 14, left + 2, y + 2, 14, Blocks.BOOKSHELF.defaultBlockState());
|
||||
set(left, y + 1, 11, facing(Blocks.LECTERN, Direction.SOUTH));
|
||||
}
|
||||
|
||||
void auditorium() {
|
||||
for (int x : new int[]{3, 4, 5, 11, 12, 13}) seat(x, 1, 11, Direction.SOUTH);
|
||||
box(3, 1, 13, 13, 1, 14, WOOD);
|
||||
set(8, 1, 12, facing(Blocks.DARK_OAK_STAIRS, Direction.SOUTH));
|
||||
set(8, 2, 14, facing(Blocks.LECTERN, Direction.NORTH));
|
||||
for (int x : new int[]{3, 13}) {
|
||||
box(x, 2, 14, x, 3, 14, WOOD);
|
||||
set(x, 4, 14, Blocks.STONE_BRICK_SLAB);
|
||||
}
|
||||
}
|
||||
|
||||
void stairs() {
|
||||
for (int floor = 0; floor < p.floors(); floor++) {
|
||||
int base = floor * STOREY;
|
||||
for (int step = 1; step <= STOREY; step++) {
|
||||
int z = step + 2;
|
||||
box(2, base + step, z, 3, base + step + 3, z, AIR);
|
||||
for (int x = 2; x <= 3; x++) set(x, base + step, z,
|
||||
facing(Blocks.STONE_BRICK_STAIRS, Direction.SOUTH));
|
||||
}
|
||||
box(2, base + STOREY, 8, 4, base + STOREY, 9, TILE);
|
||||
box(2, base + STOREY + 1, 8, 4, base + STOREY + 3, 9, AIR);
|
||||
}
|
||||
// This full-height side aisle joins every lower stair approach to its landing door.
|
||||
for (int floor = 0; floor <= p.floors(); floor++) {
|
||||
int y = floor * STOREY;
|
||||
box(4, y + 1, 2, 4, y + 3, 9, AIR);
|
||||
}
|
||||
}
|
||||
|
||||
void entrance() {
|
||||
for (int x : new int[]{6, 9}) box(x, 1, 0, x, 3, 0, SMOOTH);
|
||||
box(6, 4, 0, 9, 4, 1, Blocks.STONE_BRICK_SLAB.defaultBlockState());
|
||||
box(7, 0, 0, 8, 0, 3, TILE);
|
||||
// Native SOUTH/RIGHT opens against the west jamb; SOUTH/LEFT against the east jamb.
|
||||
door(7, 1, 1, Direction.SOUTH, true);
|
||||
door(8, 1, 1, Direction.SOUTH, false);
|
||||
set(4, 1, 0, Blocks.POTTED_DEAD_BUSH);
|
||||
set(12, 1, 16, Blocks.COMPOSTER);
|
||||
}
|
||||
|
||||
void roof(int y) {
|
||||
for (int n = 1; n <= 15; n++) {
|
||||
if (roll(n, y, 1, 9) != 0) set(n, y + 1, 1, brick(n, y, 1));
|
||||
if (roll(n, y, 15, 9) != 0) set(n, y + 1, 15, brick(n, y, 15));
|
||||
set(1, y + 1, n, brick(1, y, n)); set(15, y + 1, n, brick(15, y, n));
|
||||
}
|
||||
box(11, y + 1, 11, 13, y + 1, 13, SMOOTH);
|
||||
set(12, y + 2, 12, Blocks.IRON_TRAPDOOR);
|
||||
set(6, y + 1, 13, Blocks.POTTED_DEAD_BUSH);
|
||||
set(14, y + 2, 14, Blocks.COBWEB);
|
||||
}
|
||||
|
||||
void door(int x, int y, int z, Direction facing, boolean rightHinge) {
|
||||
BlockState state = facing(Blocks.DARK_OAK_DOOR, facing)
|
||||
.setValue(BlockStateProperties.DOOR_HINGE, rightHinge ? DoorHingeSide.RIGHT : DoorHingeSide.LEFT)
|
||||
.setValue(BlockStateProperties.OPEN, false)
|
||||
.setValue(BlockStateProperties.POWERED, false);
|
||||
set(x, y, z, state.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.LOWER));
|
||||
set(x, y + 1, z, state.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.UPPER));
|
||||
}
|
||||
void bed(int x, int y, int z, Direction facing) {
|
||||
BlockState state = facing(Blocks.BED.white(), facing);
|
||||
set(x, y, z, state.setValue(BlockStateProperties.BED_PART, BedPart.FOOT));
|
||||
set(x + facing.getStepX(), y, z + facing.getStepZ(), state.setValue(BlockStateProperties.BED_PART, BedPart.HEAD));
|
||||
}
|
||||
void table(int x, int y, int z) {
|
||||
set(x, y + 1, z, Blocks.DARK_OAK_FENCE); set(x, y + 2, z, Blocks.DARK_OAK_PRESSURE_PLATE);
|
||||
seat(x - 1, y + 1, z, Direction.EAST);
|
||||
seat(x + 1, y + 1, z, Direction.WEST);
|
||||
}
|
||||
void desk(int x, int y, int z, Direction approachSide) {
|
||||
set(x, y + 1, z, Blocks.DARK_OAK_FENCE); set(x, y + 2, z, Blocks.DARK_OAK_PRESSURE_PLATE);
|
||||
seat(x + approachSide.getStepX(), y + 1, z + approachSide.getStepZ(), approachSide.getOpposite());
|
||||
}
|
||||
void seat(int x, int y, int z, Direction lookingToward) {
|
||||
set(x, y, z, seating(Blocks.DARK_OAK_STAIRS, lookingToward));
|
||||
}
|
||||
BlockState seating(Block stair, Direction lookingToward) {
|
||||
// Stair FACING points uphill, into its full-height back, opposite a seated player's view.
|
||||
return facing(stair, lookingToward.getOpposite());
|
||||
}
|
||||
BlockState rail(boolean eastWest) { return connected(Blocks.IRON_BARS, eastWest); }
|
||||
BlockState pane(boolean eastWest) { return connected(Blocks.GLASS_PANE, eastWest); }
|
||||
BlockState connected(Block block, boolean eastWest) {
|
||||
return block.defaultBlockState().setValue(BlockStateProperties.EAST, eastWest)
|
||||
.setValue(BlockStateProperties.WEST, eastWest).setValue(BlockStateProperties.NORTH, !eastWest)
|
||||
.setValue(BlockStateProperties.SOUTH, !eastWest);
|
||||
}
|
||||
}
|
||||
}
|
||||
+584
@@ -0,0 +1,584 @@
|
||||
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.ArrayList;
|
||||
import java.util.List;
|
||||
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.block.state.properties.DoorHingeSide;
|
||||
import net.minecraft.world.level.block.state.properties.DoubleBlockHalf;
|
||||
import net.minecraft.world.level.block.state.properties.SlabType;
|
||||
import net.minecraft.world.level.storage.loot.BuiltInLootTables;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
|
||||
/** Complete generation-16 buildings. Generation 15 deliberately never calls this renderer.
|
||||
* A north-facing plan is rotated once; all positions remain relative to the saved parcel. */
|
||||
public final class LostCityInteriors16 {
|
||||
private static final BlockState AIR = Blocks.AIR.defaultBlockState();
|
||||
private static final BlockState STONE = Blocks.STONE.defaultBlockState();
|
||||
private static final BlockState SMOOTH = Blocks.SMOOTH_STONE.defaultBlockState();
|
||||
private static final BlockState WOOD = Blocks.DARK_OAK_PLANKS.defaultBlockState();
|
||||
private static final BlockState TILE = Blocks.POLISHED_ANDESITE.defaultBlockState();
|
||||
private static final int STOREY = 5;
|
||||
|
||||
private LostCityInteriors16() {}
|
||||
|
||||
/** Reachable standing positions, in the same rotated local coordinates as BlockWriter. */
|
||||
public record Room(String name, int x, int y, int z) {}
|
||||
|
||||
/** Local, already rotated enclosure including the floor, full ceiling, four walls and door. */
|
||||
public record Bathroom(String name, int minX, int minY, int minZ, int maxX, int maxY, int maxZ,
|
||||
int doorX, int doorY, int doorZ) {}
|
||||
|
||||
private record Area(int left, int front, int right, int back) {}
|
||||
|
||||
/** Adjacent storeys always change plan; the saved parcel seed chooses which plan comes first. */
|
||||
public static int layout(Parcel parcel, int floor) {
|
||||
validate(parcel);
|
||||
if (floor < 0 || floor >= parcel.floors()) throw new IllegalArgumentException("Invalid interior floor");
|
||||
return (int) ((parcel.seed() ^ (parcel.seed() >>> 32)) & 1L) ^ (floor & 1);
|
||||
}
|
||||
|
||||
private static Area rear(Parcel p, int floor, boolean east) {
|
||||
boolean wide = layout(p, floor) == 1;
|
||||
return east ? new Area(wide ? 10 : 11, wide ? 10 : 11, 14, 14)
|
||||
: new Area(2, wide ? 10 : 11, wide ? 6 : 5, 14);
|
||||
}
|
||||
|
||||
private static int frontWall(Parcel p, int floor) { return layout(p, floor) == 0 ? 7 : 8; }
|
||||
|
||||
private static String frontUse(Parcel p, int floor) {
|
||||
int variant = layout(p, floor);
|
||||
return switch (p.kind()) {
|
||||
case APARTMENT -> variant == 0 ? "kitchen_dining" : "studio_bedroom";
|
||||
case HOTEL -> floor == 0 ? "reception" : "guest_suite";
|
||||
case LIBRARY -> variant == 0 ? "book_stacks" : "periodicals_reading";
|
||||
case RESTAURANT -> variant == 0 ? "dining_room" : "kitchen";
|
||||
case BARRACKS -> variant == 0 ? "dormitory" : "duty_office";
|
||||
case HALL -> floor == 0 ? "ticket_office" : "administration";
|
||||
default -> throw new IllegalArgumentException("Not an interior kind");
|
||||
};
|
||||
}
|
||||
|
||||
private static String rearUse(Parcel p, int floor, boolean east) {
|
||||
int variant = layout(p, floor);
|
||||
return switch (p.kind()) {
|
||||
case APARTMENT -> east ? "bathroom" : variant == 0 ? "bedroom" : "kitchen_dining";
|
||||
case HOTEL -> floor == 0 ? east ? "laundry" : "lounge"
|
||||
: variant == 0 ? east ? "guest_room" : "bedroom" : east ? "bathroom" : "guest_lounge";
|
||||
case LIBRARY -> variant == 0 ? east ? "archive" : "reading_room" : east ? "bathroom" : "catalogue_office";
|
||||
case RESTAURANT -> variant == 0 ? east ? "kitchen" : "washrooms" : east ? "pantry" : "dining_room";
|
||||
case BARRACKS -> east ? "equipment_store" : variant == 0 ? "washrooms" : "officer_bedroom";
|
||||
case HALL -> variant == 0 ? east ? "bathroom" : "meeting_room" : east ? "meeting_room" : "archive";
|
||||
default -> throw new IllegalArgumentException("Not an interior kind");
|
||||
};
|
||||
}
|
||||
|
||||
public static void render(Parcel parcel, LostCityRenderer.BlockWriter output) {
|
||||
validate(parcel);
|
||||
new Building(parcel, output).render();
|
||||
}
|
||||
|
||||
/** These describe actual rooms and services, not decorative labels placed in the world. */
|
||||
public static List<Room> rooms(Parcel parcel) {
|
||||
validate(parcel);
|
||||
List<Room> result = new ArrayList<>();
|
||||
add(result, parcel, "street_entrance", 8, 1, 0);
|
||||
add(result, parcel, "vestibule", 8, 1, 3);
|
||||
for (int floor = 0; floor < parcel.floors(); floor++) {
|
||||
int y = floor * STOREY + 1;
|
||||
String prefix = "floor_" + floor + "/";
|
||||
add(result, parcel, prefix + "corridor", 8, y, 8);
|
||||
add(result, parcel, prefix + "stair_landing", 4, y, 8);
|
||||
add(result, parcel, prefix + frontUse(parcel, floor), 11, y, 6);
|
||||
if (parcel.kind() == Kind.HALL && floor < 2) {
|
||||
if (floor == 0) {
|
||||
add(result, parcel, prefix + "auditorium", 8, y, 11);
|
||||
add(result, parcel, prefix + "stage", 9, y + 1, 13);
|
||||
} else add(result, parcel, prefix + "gallery", 8, y, 10);
|
||||
} else {
|
||||
Area west = rear(parcel, floor, false), east = rear(parcel, floor, true);
|
||||
add(result, parcel, prefix + rearUse(parcel, floor, false), west.right(), y, 11);
|
||||
add(result, parcel, prefix + rearUse(parcel, floor, true), east.left(), y, 11);
|
||||
}
|
||||
}
|
||||
for (var bath : bathroomPlans(parcel)) {
|
||||
int targetZ = bath.southDoor() ? bath.z1() - 1 : bath.z0() + 1;
|
||||
add(result, parcel, bath.name(), bath.doorX(), bath.floor() * STOREY + 1, targetZ);
|
||||
}
|
||||
add(result, parcel, "roof", 8, parcel.floors() * STOREY + 1, 8);
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private record BathPlan(String name, int floor, int x0, int z0, int x1, int z1, int doorX, boolean southDoor) {}
|
||||
|
||||
private static List<BathPlan> bathroomPlans(Parcel p) {
|
||||
List<BathPlan> result = new ArrayList<>();
|
||||
for (int floor = 0; floor < p.floors(); floor++) {
|
||||
if (p.kind() == Kind.HOTEL && floor > 0)
|
||||
result.add(new BathPlan("floor_" + floor + "/suite_bathroom", floor, 10, 1, 15, 4, 12, true));
|
||||
if (p.kind() == Kind.HALL && floor < 2) continue;
|
||||
for (boolean east : new boolean[]{false, true}) {
|
||||
String use = rearUse(p, floor, east);
|
||||
if (!use.equals("bathroom") && !use.equals("washrooms")) continue;
|
||||
Area room = rear(p, floor, east);
|
||||
result.add(new BathPlan("floor_" + floor + "/" + (east ? "east" : "west") + "_bathroom_interior",
|
||||
floor, room.left() - 1, 12, room.right() + 1, 15, east ? 12 : 4, false));
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
public static List<Bathroom> bathrooms(Parcel p) {
|
||||
validate(p);
|
||||
List<Bathroom> result = new ArrayList<>();
|
||||
for (var b : bathroomPlans(p)) {
|
||||
int x0 = rx(b.x0(), b.z0(), p.facing()), z0 = rz(b.x0(), b.z0(), p.facing());
|
||||
int x1 = rx(b.x1(), b.z1(), p.facing()), z1 = rz(b.x1(), b.z1(), p.facing());
|
||||
int doorZ = b.southDoor() ? b.z1() : b.z0();
|
||||
result.add(new Bathroom(b.name(), Math.min(x0, x1), b.floor() * STOREY, Math.min(z0, z1),
|
||||
Math.max(x0, x1), b.floor() * STOREY + 4, Math.max(z0, z1), rx(b.doorX(), doorZ, p.facing()),
|
||||
b.floor() * STOREY + 1, rz(b.doorX(), doorZ, p.facing())));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static void validate(Parcel p) {
|
||||
if (!p.kind().building() || p.width() != 17 || p.depth() != 17 || p.floors() < 2)
|
||||
throw new IllegalArgumentException("Generation-16 interiors require a 17x17 building with 2..5 floors");
|
||||
}
|
||||
|
||||
private static void add(List<Room> result, Parcel p, String name, int x, int y, int z) {
|
||||
result.add(new Room(name, rx(x, z, p.facing()), y, rz(x, z, p.facing())));
|
||||
}
|
||||
private static int rx(int x, int z, int facing) {
|
||||
return switch (facing) { case 0 -> x; case 1 -> 16 - z; case 2 -> 16 - x; default -> z; };
|
||||
}
|
||||
private static int rz(int x, int z, int facing) {
|
||||
return switch (facing) { case 0 -> z; case 1 -> x; case 2 -> 16 - z; default -> 16 - x; };
|
||||
}
|
||||
|
||||
private static final class Building {
|
||||
final Parcel p;
|
||||
final LostCityRenderer.BlockWriter output;
|
||||
final Rotation rotation;
|
||||
Building(Parcel p, LostCityRenderer.BlockWriter output) {
|
||||
this.p = p;
|
||||
this.output = output;
|
||||
rotation = switch (p.facing()) {
|
||||
case 0 -> Rotation.NONE; case 1 -> Rotation.CLOCKWISE_90;
|
||||
case 2 -> Rotation.CLOCKWISE_180; default -> Rotation.COUNTERCLOCKWISE_90;
|
||||
};
|
||||
}
|
||||
void set(int x, int y, int z, BlockState state) {
|
||||
output.set(rx(x, z, p.facing()), y, rz(x, z, p.facing()), state.rotate(rotation));
|
||||
}
|
||||
void set(int x, int y, int z, Block block) { set(x, y, z, block.defaultBlockState()); }
|
||||
void box(int x0, int y0, int z0, int x1, int y1, int z1, BlockState state) {
|
||||
for (int x = x0; x <= x1; x++) for (int z = z0; z <= z1; z++)
|
||||
for (int y = y0; y <= y1; y++) set(x, y, z, state);
|
||||
}
|
||||
BlockState facing(Block block, Direction direction) {
|
||||
return block.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, direction);
|
||||
}
|
||||
BlockState brick(int x, int y, int z) { return LostCityPalette.masonry(p.seed(), x, y, z); }
|
||||
int roll(int x, int y, int z, int bound) { return LostCityPalette.roll(p.seed(), x, y, z, bound); }
|
||||
|
||||
void render() {
|
||||
int roof = p.floors() * STOREY;
|
||||
box(0, -p.foundationDepth(), 0, 16, -1, 16, STONE);
|
||||
box(0, 0, 0, 16, 0, 16, SMOOTH);
|
||||
box(0, 1, 0, 16, roof + 4, 16, AIR);
|
||||
shell(roof);
|
||||
for (int floor = 0; floor < p.floors(); floor++) {
|
||||
partitions(floor);
|
||||
furnishings(floor);
|
||||
}
|
||||
stairs();
|
||||
entrance();
|
||||
roof(roof);
|
||||
// Only one deferred loot table per building; empty furniture is not a resource vault.
|
||||
int lootX = 6;
|
||||
int lootZ = 6;
|
||||
box(lootX, 2, lootZ, lootX, 3, lootZ, AIR);
|
||||
ResourceKey<LootTable> table = switch (p.kind()) {
|
||||
case LIBRARY, HALL -> BuiltInLootTables.VILLAGE_CARTOGRAPHER;
|
||||
case RESTAURANT -> BuiltInLootTables.VILLAGE_BUTCHER;
|
||||
case BARRACKS -> BuiltInLootTables.VILLAGE_FLETCHER;
|
||||
default -> BuiltInLootTables.VILLAGE_PLAINS_HOUSE;
|
||||
};
|
||||
output.chest(rx(lootX, lootZ, p.facing()), 1, rz(lootX, lootZ, p.facing()), table,
|
||||
p.seed() ^ 0x16c17a7eL ^ p.kind().ordinal());
|
||||
}
|
||||
|
||||
void shell(int roof) {
|
||||
for (int floor = 0; floor <= p.floors(); floor++) {
|
||||
int base = floor * STOREY;
|
||||
box(1, base, 1, 15, base, 15, floor == p.floors() ? SMOOTH : floorMaterial());
|
||||
if (floor == p.floors()) continue;
|
||||
// A pale ceiling and contrasting structural floor are separate layers.
|
||||
box(2, base + 4, 2, 14, base + 4, 14,
|
||||
Blocks.SMOOTH_STONE_SLAB.defaultBlockState().setValue(BlockStateProperties.SLAB_TYPE, SlabType.TOP));
|
||||
for (int y = 1; y <= 4; y++) for (int horizontal = 1; horizontal <= 15; horizontal++) {
|
||||
facade(horizontal, base + y, 1, horizontal, y, true);
|
||||
facade(horizontal, base + y, 15, horizontal, y, true);
|
||||
facade(1, base + y, horizontal, horizontal, y, false);
|
||||
facade(15, base + y, horizontal, horizontal, y, false);
|
||||
}
|
||||
// Floor bands remain intact even where windows have fallen out.
|
||||
for (int n = 1; n <= 15; n++) {
|
||||
set(n, base, 1, SMOOTH); set(n, base, 15, SMOOTH);
|
||||
set(1, base, n, SMOOTH); set(15, base, n, SMOOTH);
|
||||
}
|
||||
if (p.kind() == Kind.HOTEL || p.kind() == Kind.LIBRARY) {
|
||||
for (int x : new int[]{3, 13}) for (int y = 0; y <= 4; y++) set(x, base + y, 0, SMOOTH);
|
||||
}
|
||||
if (p.kind() == Kind.APARTMENT) for (int x = 11; x <= 14; x++)
|
||||
set(x, base + 1, 0, Blocks.SMOOTH_STONE_SLAB);
|
||||
if (p.kind() == Kind.RESTAURANT) box(10, base + 4, 0, 14, base + 4, 0,
|
||||
Blocks.DARK_OAK_SLAB.defaultBlockState());
|
||||
}
|
||||
if (p.kind() == Kind.HALL) {
|
||||
// The auditorium spans two storeys; its upper level is a guarded gallery.
|
||||
box(3, 4, 11, 13, 5, 14, AIR);
|
||||
for (int x = 3; x <= 13; x++) set(x, 6, 11, rail(true));
|
||||
for (int z = 12; z <= 14; z++) { set(3, 6, z, rail(false)); set(13, 6, z, rail(false)); }
|
||||
}
|
||||
}
|
||||
|
||||
BlockState floorMaterial() {
|
||||
return switch (p.kind()) {
|
||||
case LIBRARY, APARTMENT, HOTEL -> WOOD;
|
||||
case RESTAURANT, BARRACKS -> TILE;
|
||||
default -> SMOOTH;
|
||||
};
|
||||
}
|
||||
|
||||
void facade(int x, int y, int z, int horizontal, int withinFloor, boolean eastWest) {
|
||||
boolean column = horizontal == 1 || horizontal == 6 || horizontal == 10 || horizontal == 15;
|
||||
boolean window = !column && (withinFloor == 2 || withinFloor == 3
|
||||
|| withinFloor == 1 && (p.kind() == Kind.LIBRARY || p.kind() == Kind.RESTAURANT || p.kind() == Kind.HALL));
|
||||
if (column) set(x, y, z, p.kind() == Kind.BARRACKS ? brick(x, y, z) : STONE);
|
||||
else if (window) {
|
||||
if (roll(x, y / 5, z, 13) != 0)
|
||||
set(x, y, z, p.kind() == Kind.BARRACKS ? rail(eastWest) : pane(eastWest));
|
||||
} else set(x, y, z, withinFloor == 4 ? SMOOTH : brick(x, y, z));
|
||||
}
|
||||
|
||||
void partitions(int floor) {
|
||||
int y = floor * STOREY;
|
||||
int front = frontWall(p, floor);
|
||||
// The west stairwell, central corridor and vestibule are independent of furniture.
|
||||
box(5, y + 1, 2, 5, y + 4, 9, SMOOTH);
|
||||
door(5, y + 1, 8, Direction.WEST, false);
|
||||
box(10, y + 1, 2, 10, y + 4, front, SMOOTH);
|
||||
box(10, y + 1, front, 15, y + 4, front, SMOOTH);
|
||||
door(10, y + 1, 6, Direction.EAST, false);
|
||||
if (floor == 0) {
|
||||
box(6, 1, 4, 9, 4, 4, SMOOTH);
|
||||
door(8, 1, 4, Direction.SOUTH, false);
|
||||
}
|
||||
if (p.kind() == Kind.HALL && floor < 2) {
|
||||
if (floor == 0) {
|
||||
box(2, 1, 10, 14, 4, 10, SMOOTH);
|
||||
door(7, 1, 10, Direction.SOUTH, true);
|
||||
door(8, 1, 10, Direction.SOUTH, false);
|
||||
}
|
||||
} else {
|
||||
Area west = rear(p, floor, false), east = rear(p, floor, true);
|
||||
box(1, y + 1, west.front() - 1, west.right() + 1, y + 4, west.front() - 1, SMOOTH);
|
||||
box(west.right() + 1, y + 1, west.front() - 1, west.right() + 1, y + 4, 15, SMOOTH);
|
||||
box(east.left() - 1, y + 1, east.front() - 1, 15, y + 4, east.front() - 1, SMOOTH);
|
||||
box(east.left() - 1, y + 1, east.front() - 1, east.left() - 1, y + 4, 15, SMOOTH);
|
||||
door(west.right() + 1, y + 1, 11, Direction.WEST, false);
|
||||
door(east.left() - 1, y + 1, 11, Direction.EAST, false);
|
||||
}
|
||||
// A hanging lantern touches a full ceiling block, never the recessed underside of a top slab.
|
||||
set(8, y + 4, 8, SMOOTH);
|
||||
set(8, y + 3, 8, Blocks.LANTERN.defaultBlockState().setValue(BlockStateProperties.HANGING, true));
|
||||
// Weathering stays on facades and roofs: it must not replace a privacy partition.
|
||||
}
|
||||
|
||||
void furnishings(int floor) {
|
||||
int y = floor * STOREY;
|
||||
int variant = layout(p, floor);
|
||||
switch (p.kind()) {
|
||||
case APARTMENT -> {
|
||||
if (variant == 0) { kitchenFront(y); bedroom(floor, false); }
|
||||
else { frontBedroom(floor); kitchenRear(floor, false); }
|
||||
}
|
||||
case HOTEL -> {
|
||||
if (floor == 0) { reception(y); lounge(floor, false); laundry(floor); }
|
||||
else {
|
||||
anchoredBed(14, y + 1, frontWall(p, floor) - 2, Direction.SOUTH);
|
||||
set(11, y + 1, 5, Blocks.BARREL); set(11, y + 2, 5, Blocks.FLOWER_POT);
|
||||
if (variant == 0) { bedroom(floor, false); bedroom(floor, true); }
|
||||
else lounge(floor, false);
|
||||
}
|
||||
}
|
||||
case LIBRARY -> {
|
||||
if (variant == 0) {
|
||||
box(14, y + 1, 2, 14, y + 3, 6, Blocks.BOOKSHELF.defaultBlockState());
|
||||
box(12, y + 1, 2, 12, y + 2, 3, Blocks.BOOKSHELF.defaultBlockState());
|
||||
desk(12, y, 5, Direction.SOUTH);
|
||||
readingRoom(floor, false); archive(floor, true);
|
||||
} else {
|
||||
readingFront(y); readingRoom(floor, false);
|
||||
set(2, y + 1, 14, Blocks.CARTOGRAPHY_TABLE);
|
||||
}
|
||||
}
|
||||
case RESTAURANT -> {
|
||||
if (variant == 0) {
|
||||
table(13, y, 3); table(13, y, 5);
|
||||
set(11, y + 1, 2, Blocks.POTTED_DEAD_BUSH);
|
||||
kitchenRear(floor, true);
|
||||
} else {
|
||||
kitchenFront(y); diningRear(floor, false); pantry(floor, true);
|
||||
}
|
||||
}
|
||||
case BARRACKS -> {
|
||||
if (variant == 0) {
|
||||
anchoredBed(12, y + 1, 3, Direction.NORTH); anchoredBed(14, y + 1, 3, Direction.NORTH);
|
||||
set(11, y + 1, 2, Blocks.BARREL); set(11, y + 2, 2, Blocks.BARREL);
|
||||
seat(13, y + 1, 6, Direction.NORTH);
|
||||
} else { reception(y); bedroom(floor, false); }
|
||||
box(12, y + 1, 14, 14, y + 2, 14, Blocks.BARREL.defaultBlockState());
|
||||
set(14, y + 1, 11, Blocks.FLETCHING_TABLE);
|
||||
set(12, y + 1, 11, Blocks.GRINDSTONE);
|
||||
}
|
||||
case HALL -> {
|
||||
if (floor == 0 || floor > 1 && variant == 0) reception(y); else readingFront(y);
|
||||
if (floor == 0) auditorium();
|
||||
else if (floor >= 2) {
|
||||
if (variant == 0) readingRoom(floor, false);
|
||||
else { archive(floor, false); readingRoom(floor, true); }
|
||||
}
|
||||
}
|
||||
default -> throw new IllegalArgumentException("Not an interior kind");
|
||||
}
|
||||
for (var bath : bathroomPlans(p)) if (bath.floor() == floor) bathroom(bath);
|
||||
}
|
||||
|
||||
void kitchenFront(int y) {
|
||||
kitchenLine(y, 11, 2, Direction.SOUTH);
|
||||
table(13, y, 5);
|
||||
}
|
||||
|
||||
void kitchenRear(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
kitchenLine(y, room.left(), 14, Direction.NORTH);
|
||||
if (east) {
|
||||
set(14, y + 1, 11, SMOOTH); set(14, y + 2, 11, Blocks.FLOWER_POT);
|
||||
set(12, y + 1, 11, Blocks.CRAFTING_TABLE);
|
||||
} else table(room.left() + 2, y, 11);
|
||||
}
|
||||
|
||||
void kitchenLine(int y, int left, int z, Direction face) {
|
||||
set(left, y + 1, z, facing(Blocks.FURNACE, face));
|
||||
set(left + 1, y + 1, z, Blocks.WATER_CAULDRON.defaultBlockState().setValue(BlockStateProperties.LEVEL_CAULDRON, 2));
|
||||
set(left + 2, y + 1, z, facing(Blocks.SMOKER, face));
|
||||
set(left + 3, y + 1, z, Blocks.BARREL);
|
||||
box(left, y + 3, z, left + 3, y + 3, z, SMOOTH);
|
||||
set(left + 2, y + 2, z, Blocks.IRON_TRAPDOOR);
|
||||
for (int x = left; x <= left + 3; x++) set(x, y, z, (x & 1) == 0 ? SMOOTH : TILE);
|
||||
}
|
||||
|
||||
void bedroom(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
anchoredBed(east ? room.right() - 1 : room.left() + 1, y + 1, 13, east ? Direction.EAST : Direction.WEST);
|
||||
set(east ? room.right() : room.left(), y + 1, 11, Blocks.BARREL);
|
||||
desk(east ? room.left() + 1 : room.right() - 1, y, 11, Direction.SOUTH);
|
||||
set(east ? room.left() : room.right(), y + 1, 14, Blocks.BOOKSHELF);
|
||||
}
|
||||
|
||||
void frontBedroom(int floor) {
|
||||
int y = floor * STOREY;
|
||||
anchoredBed(14, y + 1, 3, Direction.NORTH);
|
||||
set(11, y + 1, 2, Blocks.BARREL); set(11, y + 2, 2, Blocks.FLOWER_POT);
|
||||
desk(12, y, 5, Direction.SOUTH);
|
||||
}
|
||||
|
||||
void bathroom(BathPlan bath) {
|
||||
int y = bath.floor() * STOREY;
|
||||
for (int x = bath.x0(); x <= bath.x1(); x++) for (int z = bath.z0(); z <= bath.z1(); z++) {
|
||||
set(x, y, z, ((x + z) & 1) == 0 ? SMOOTH : TILE);
|
||||
set(x, y + 4, z, SMOOTH);
|
||||
boolean wall = x == bath.x0() || x == bath.x1() || z == bath.z0() || z == bath.z1();
|
||||
for (int up = 1; up <= 3; up++) set(x, y + up, z, wall ? SMOOTH : AIR);
|
||||
}
|
||||
int doorZ = bath.southDoor() ? bath.z1() : bath.z0();
|
||||
door(bath.doorX(), y + 1, doorZ, bath.southDoor() ? Direction.NORTH : Direction.SOUTH, false);
|
||||
int fixtureZ = bath.southDoor() ? bath.z0() + 1 : bath.z1() - 1;
|
||||
int toiletX = bath.southDoor() ? bath.x1() - 2 : bath.x0() + 1;
|
||||
Direction view = bath.southDoor() ? Direction.SOUTH : Direction.EAST;
|
||||
set(toiletX, y + 1, fixtureZ, seating(Blocks.POLISHED_ANDESITE_STAIRS, view));
|
||||
set(toiletX, y + 2, fixtureZ, Blocks.IRON_TRAPDOOR);
|
||||
set(bath.x1() - 1, y + 1, fixtureZ, Blocks.WATER_CAULDRON.defaultBlockState().setValue(BlockStateProperties.LEVEL_CAULDRON, 2));
|
||||
}
|
||||
|
||||
void reception(int y) {
|
||||
box(12, y + 1, 5, 14, y + 1, 5, WOOD);
|
||||
set(13, y + 2, 5, Blocks.FLOWER_POT);
|
||||
set(14, y + 1, 2, Blocks.BOOKSHELF); set(14, y + 2, 2, Blocks.BARREL);
|
||||
desk(12, y, 3, Direction.SOUTH);
|
||||
set(11, y + 1, 2, Blocks.POTTED_DEAD_BUSH);
|
||||
}
|
||||
|
||||
void laundry(int floor) {
|
||||
int y = floor * STOREY;
|
||||
for (int z = 11; z <= 12; z++) set(14, y + 1, z, Blocks.WATER_CAULDRON.defaultBlockState()
|
||||
.setValue(BlockStateProperties.LEVEL_CAULDRON, z == 11 ? 1 : 2));
|
||||
box(12, y + 1, 14, 14, y + 1, 14, Blocks.BARREL.defaultBlockState());
|
||||
box(12, y + 3, 14, 14, y + 3, 14, Blocks.DARK_OAK_SLAB.defaultBlockState());
|
||||
set(12, y + 1, 11, Blocks.LOOM);
|
||||
}
|
||||
|
||||
void lounge(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
box(room.left(), y + 1, 14, room.left() + 2, y + 1, 14, seating(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
|
||||
desk(room.left() + 1, y, 11, Direction.SOUTH);
|
||||
set(room.left(), y + 1, 12, Blocks.BOOKSHELF);
|
||||
}
|
||||
|
||||
void readingRoom(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
desk(east ? room.left() + 1 : room.right() - 1, y, 11, Direction.SOUTH);
|
||||
box(room.left(), y + 1, 14, room.right(), y + 2, 14, Blocks.BOOKSHELF.defaultBlockState());
|
||||
set(east ? room.right() : room.left(), y + 1, 11, facing(Blocks.LECTERN, Direction.NORTH));
|
||||
}
|
||||
|
||||
void readingFront(int y) {
|
||||
desk(12, y, 3, Direction.SOUTH);
|
||||
box(14, y + 1, 2, 14, y + 2, 5, Blocks.BOOKSHELF.defaultBlockState());
|
||||
set(11, y + 1, 2, facing(Blocks.LECTERN, Direction.SOUTH));
|
||||
}
|
||||
|
||||
void archive(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
box(room.left(), y + 1, 14, room.right(), y + 3, 14, Blocks.BOOKSHELF.defaultBlockState());
|
||||
box(east ? room.right() : room.left(), y + 1, 11, east ? room.right() : room.left(), y + 2, 12, Blocks.BARREL.defaultBlockState());
|
||||
set(room.left() + 1, y + 1, 12, facing(Blocks.LECTERN, Direction.NORTH));
|
||||
}
|
||||
|
||||
void pantry(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
box(room.left(), y + 1, 14, room.right(), y + 2, 14, Blocks.BARREL.defaultBlockState());
|
||||
set(room.right(), y + 1, 11, Blocks.CRAFTING_TABLE);
|
||||
set(room.right(), y + 2, 11, Blocks.FLOWER_POT);
|
||||
}
|
||||
|
||||
void diningRear(int floor, boolean east) {
|
||||
Area room = rear(p, floor, east);
|
||||
int y = floor * STOREY;
|
||||
table(room.left() + 2, y, 12);
|
||||
box(room.left(), y + 1, 14, room.right(), y + 1, 14, seating(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
|
||||
}
|
||||
|
||||
void auditorium() {
|
||||
for (int x : new int[]{3, 4, 5, 11, 12, 13}) seat(x, 1, 11, Direction.SOUTH);
|
||||
box(3, 1, 13, 13, 1, 14, WOOD);
|
||||
set(8, 1, 12, facing(Blocks.DARK_OAK_STAIRS, Direction.SOUTH));
|
||||
set(8, 2, 14, facing(Blocks.LECTERN, Direction.NORTH));
|
||||
for (int x : new int[]{3, 13}) {
|
||||
box(x, 2, 14, x, 3, 14, WOOD);
|
||||
set(x, 4, 14, Blocks.STONE_BRICK_SLAB);
|
||||
}
|
||||
}
|
||||
|
||||
void stairs() {
|
||||
for (int floor = 0; floor < p.floors(); floor++) {
|
||||
int base = floor * STOREY;
|
||||
for (int step = 1; step <= STOREY; step++) {
|
||||
int z = step + 2;
|
||||
box(2, base + step, z, 3, base + step + 3, z, AIR);
|
||||
for (int x = 2; x <= 3; x++) set(x, base + step, z,
|
||||
facing(Blocks.STONE_BRICK_STAIRS, Direction.SOUTH));
|
||||
}
|
||||
box(2, base + STOREY, 8, 4, base + STOREY, 9, TILE);
|
||||
box(2, base + STOREY + 1, 8, 4, base + STOREY + 3, 9, AIR);
|
||||
}
|
||||
// This full-height side aisle joins every lower stair approach to its landing door.
|
||||
for (int floor = 0; floor <= p.floors(); floor++) {
|
||||
int y = floor * STOREY;
|
||||
box(4, y + 1, 2, 4, y + 3, 9, AIR);
|
||||
}
|
||||
}
|
||||
|
||||
void entrance() {
|
||||
for (int x : new int[]{6, 9}) box(x, 1, 0, x, 3, 0, SMOOTH);
|
||||
box(6, 4, 0, 9, 4, 1, Blocks.STONE_BRICK_SLAB.defaultBlockState());
|
||||
box(7, 0, 0, 8, 0, 3, TILE);
|
||||
// Native SOUTH/RIGHT opens against the west jamb; SOUTH/LEFT against the east jamb.
|
||||
door(7, 1, 1, Direction.SOUTH, true);
|
||||
door(8, 1, 1, Direction.SOUTH, false);
|
||||
set(4, 1, 0, Blocks.POTTED_DEAD_BUSH);
|
||||
set(12, 1, 16, Blocks.COMPOSTER);
|
||||
}
|
||||
|
||||
void roof(int y) {
|
||||
for (int n = 1; n <= 15; n++) {
|
||||
if (roll(n, y, 1, 9) != 0) set(n, y + 1, 1, brick(n, y, 1));
|
||||
if (roll(n, y, 15, 9) != 0) set(n, y + 1, 15, brick(n, y, 15));
|
||||
set(1, y + 1, n, brick(1, y, n)); set(15, y + 1, n, brick(15, y, n));
|
||||
}
|
||||
box(11, y + 1, 11, 13, y + 1, 13, SMOOTH);
|
||||
set(12, y + 2, 12, Blocks.IRON_TRAPDOOR);
|
||||
set(6, y + 1, 13, Blocks.POTTED_DEAD_BUSH);
|
||||
set(14, y + 2, 14, Blocks.COBWEB);
|
||||
}
|
||||
|
||||
void door(int x, int y, int z, Direction facing, boolean rightHinge) {
|
||||
BlockState state = facing(Blocks.DARK_OAK_DOOR, facing)
|
||||
.setValue(BlockStateProperties.DOOR_HINGE, rightHinge ? DoorHingeSide.RIGHT : DoorHingeSide.LEFT)
|
||||
.setValue(BlockStateProperties.OPEN, false)
|
||||
.setValue(BlockStateProperties.POWERED, false);
|
||||
set(x, y, z, state.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.LOWER));
|
||||
set(x, y + 1, z, state.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.UPPER));
|
||||
}
|
||||
void anchoredBed(int x, int y, int z, Direction facing) {
|
||||
BlockState state = facing(Blocks.BED.white(), facing);
|
||||
set(x, y, z, state.setValue(BlockStateProperties.BED_PART, BedPart.FOOT));
|
||||
set(x + facing.getStepX(), y, z + facing.getStepZ(), state.setValue(BlockStateProperties.BED_PART, BedPart.HEAD));
|
||||
int wallX = x + facing.getStepX() * 2, wallZ = z + facing.getStepZ() * 2;
|
||||
Direction alongWall = facing.getClockWise();
|
||||
for (int width = -1; width <= 1; width++) for (int up = 0; up <= 3; up++)
|
||||
set(wallX + alongWall.getStepX() * width, y + up, wallZ + alongWall.getStepZ() * width, SMOOTH);
|
||||
}
|
||||
void table(int x, int y, int z) {
|
||||
set(x, y + 1, z, Blocks.DARK_OAK_FENCE); set(x, y + 2, z, Blocks.DARK_OAK_PRESSURE_PLATE);
|
||||
seat(x - 1, y + 1, z, Direction.EAST);
|
||||
seat(x + 1, y + 1, z, Direction.WEST);
|
||||
}
|
||||
void desk(int x, int y, int z, Direction approachSide) {
|
||||
set(x, y + 1, z, Blocks.DARK_OAK_FENCE); set(x, y + 2, z, Blocks.DARK_OAK_PRESSURE_PLATE);
|
||||
seat(x + approachSide.getStepX(), y + 1, z + approachSide.getStepZ(), approachSide.getOpposite());
|
||||
}
|
||||
void seat(int x, int y, int z, Direction lookingToward) {
|
||||
set(x, y, z, seating(Blocks.DARK_OAK_STAIRS, lookingToward));
|
||||
}
|
||||
BlockState seating(Block stair, Direction lookingToward) {
|
||||
// Stair FACING points uphill, into its full-height back, opposite a seated player's view.
|
||||
return facing(stair, lookingToward.getOpposite());
|
||||
}
|
||||
BlockState rail(boolean eastWest) { return connected(Blocks.IRON_BARS, eastWest); }
|
||||
BlockState pane(boolean eastWest) { return connected(Blocks.GLASS_PANE, eastWest); }
|
||||
BlockState connected(Block block, boolean eastWest) {
|
||||
return block.defaultBlockState().setValue(BlockStateProperties.EAST, eastWest)
|
||||
.setValue(BlockStateProperties.WEST, eastWest).setValue(BlockStateProperties.NORTH, !eastWest)
|
||||
.setValue(BlockStateProperties.SOUTH, !eastWest);
|
||||
}
|
||||
}
|
||||
}
|
||||
+755
@@ -0,0 +1,755 @@
|
||||
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.ArrayList;
|
||||
import fr.koka.sanctuary.worldgen.detail.ConnectedBlocks20;
|
||||
import java.util.List;
|
||||
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.block.state.properties.DoorHingeSide;
|
||||
import net.minecraft.world.level.block.state.properties.DoubleBlockHalf;
|
||||
import net.minecraft.world.level.block.state.properties.SlabType;
|
||||
import net.minecraft.world.level.storage.loot.BuiltInLootTables;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
|
||||
/** Complete generation-20 buildings. Published renderers deliberately never call this renderer.
|
||||
* A north-facing plan is rotated once; all positions remain relative to the saved parcel. */
|
||||
public final class LostCityInteriors20 {
|
||||
private static final BlockState AIR = Blocks.AIR.defaultBlockState();
|
||||
private static final BlockState STONE = Blocks.STONE.defaultBlockState();
|
||||
private static final BlockState SMOOTH = Blocks.SMOOTH_STONE.defaultBlockState();
|
||||
private static final BlockState WOOD = Blocks.OAK_PLANKS.defaultBlockState();
|
||||
private static final BlockState TILE = Blocks.POLISHED_ANDESITE.defaultBlockState();
|
||||
private static final int STOREY = 5;
|
||||
|
||||
private LostCityInteriors20() {}
|
||||
|
||||
/** Reachable standing positions, in the same rotated local coordinates as BlockWriter. */
|
||||
public record Room(String name, int x, int y, int z) {}
|
||||
|
||||
/** Local, already rotated enclosure including the floor, full ceiling, four walls and door. */
|
||||
public record Bathroom(String name, int minX, int minY, int minZ, int maxX, int maxY, int maxZ,
|
||||
int doorX, int doorY, int doorZ) {}
|
||||
|
||||
private record Area(int left, int front, int right, int back) {}
|
||||
|
||||
/** Adjacent storeys always change plan; the saved parcel seed chooses which plan comes first. */
|
||||
public static int layout(Parcel parcel, int floor) {
|
||||
validate(parcel);
|
||||
if (floor < 0 || floor >= parcel.floors()) throw new IllegalArgumentException("Invalid interior floor");
|
||||
return (int) ((parcel.seed() ^ (parcel.seed() >>> 32)) & 1L) ^ (floor & 1);
|
||||
}
|
||||
|
||||
private static Area rear(Parcel p, int floor, boolean east) {
|
||||
boolean wide = layout(p, floor) == 1;
|
||||
return east ? new Area(wide ? 10 : 11, wide ? 10 : 11, 14, 14)
|
||||
: new Area(2, wide ? 10 : 11, wide ? 6 : 5, 14);
|
||||
}
|
||||
|
||||
private static int frontWall(Parcel p, int floor) { return layout(p, floor) == 0 ? 7 : 8; }
|
||||
|
||||
private static String frontUse(Parcel p, int floor) {
|
||||
int variant = layout(p, floor);
|
||||
return switch (p.kind()) {
|
||||
case APARTMENT -> variant == 0 ? "kitchen_dining" : "studio_bedroom";
|
||||
case HOTEL -> floor == 0 ? "reception" : "guest_suite";
|
||||
case LIBRARY -> variant == 0 ? "book_stacks" : "periodicals_reading";
|
||||
case RESTAURANT -> variant == 0 ? "dining_room" : "kitchen";
|
||||
case BARRACKS -> variant == 0 ? "dormitory" : "duty_office";
|
||||
case HALL -> floor == 0 ? "ticket_office" : "administration";
|
||||
default -> throw new IllegalArgumentException("Not an interior kind");
|
||||
};
|
||||
}
|
||||
|
||||
private static String rearUse(Parcel p, int floor, boolean east) {
|
||||
int variant = layout(p, floor);
|
||||
return switch (p.kind()) {
|
||||
case APARTMENT -> east ? "bathroom" : variant == 0 ? "bedroom" : "kitchen_dining";
|
||||
case HOTEL -> floor == 0 ? east ? "laundry" : "lounge"
|
||||
: variant == 0 ? east ? "guest_room" : "bedroom" : east ? "bathroom" : "guest_lounge";
|
||||
case LIBRARY -> variant == 0 ? east ? "archive" : "reading_room" : east ? "bathroom" : "catalogue_office";
|
||||
case RESTAURANT -> variant == 0 ? east ? "kitchen" : "washrooms" : east ? "pantry" : "dining_room";
|
||||
case BARRACKS -> east ? "equipment_store" : variant == 0 ? "washrooms" : "officer_bedroom";
|
||||
case HALL -> variant == 0 ? east ? "bathroom" : "meeting_room" : east ? "meeting_room" : "archive";
|
||||
default -> throw new IllegalArgumentException("Not an interior kind");
|
||||
};
|
||||
}
|
||||
|
||||
public static void render(Parcel parcel, LostCityRenderer.BlockWriter output) {
|
||||
validate(parcel);
|
||||
var connected = ConnectedBlocks20.buffer(output);
|
||||
new Building(parcel, connected).render();
|
||||
connected.flush();
|
||||
}
|
||||
|
||||
/** These describe actual rooms and services, not decorative labels placed in the world. */
|
||||
public static List<Room> rooms(Parcel parcel) {
|
||||
validate(parcel);
|
||||
List<Room> result = new ArrayList<>();
|
||||
add(result, parcel, "street_entrance", 8, 1, 0);
|
||||
add(result, parcel, "vestibule", 8, 1, 3);
|
||||
for (int floor = 0; floor < parcel.floors(); floor++) {
|
||||
int y = floor * STOREY + 1;
|
||||
String prefix = "floor_" + floor + "/";
|
||||
add(result, parcel, prefix + "corridor", 8, y, 8);
|
||||
add(result, parcel, prefix + "stair_landing", 4, y, 8);
|
||||
add(result, parcel, prefix + frontUse(parcel, floor), 11, y, 6);
|
||||
if (parcel.kind() == Kind.HALL && floor < 2) {
|
||||
if (floor == 0) {
|
||||
add(result, parcel, prefix + "auditorium", 8, y, 11);
|
||||
add(result, parcel, prefix + "stage", 9, y + 1, 13);
|
||||
} else add(result, parcel, prefix + "gallery", 8, y, 10);
|
||||
} else {
|
||||
Area west = rear(parcel, floor, false), east = rear(parcel, floor, true);
|
||||
add(result, parcel, prefix + rearUse(parcel, floor, false), west.right(), y, 11);
|
||||
add(result, parcel, prefix + rearUse(parcel, floor, true), east.left(), y, 11);
|
||||
}
|
||||
}
|
||||
for (var bath : bathroomPlans(parcel)) {
|
||||
int targetZ = bath.southDoor() ? bath.z1() - 1 : bath.z0() + 1;
|
||||
add(result, parcel, bath.name(), bath.doorX(), bath.floor() * STOREY + 1, targetZ);
|
||||
}
|
||||
add(result, parcel, "roof", 8, parcel.floors() * STOREY + 1, 8);
|
||||
switch (parcel.kind()) {
|
||||
case HOTEL -> add(result, parcel, "roof_laundry_pavilion", 10, parcel.floors() * STOREY + 1, 5);
|
||||
case APARTMENT -> add(result, parcel, "roof_residents_terrace", 8, parcel.floors() * STOREY + 1, 10);
|
||||
case LIBRARY -> add(result, parcel, "roof_reading_terrace", 11, parcel.floors() * STOREY + 1, 10);
|
||||
case RESTAURANT -> add(result, parcel, "roof_dining_terrace", 11, parcel.floors() * STOREY + 1, 8);
|
||||
case BARRACKS -> add(result, parcel, "roof_service_stair", 4, parcel.floors() * STOREY + 1, 8);
|
||||
default -> { }
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private record BathPlan(String name, int floor, int x0, int z0, int x1, int z1, int doorX, boolean southDoor) {}
|
||||
|
||||
private static List<BathPlan> bathroomPlans(Parcel p) {
|
||||
List<BathPlan> result = new ArrayList<>();
|
||||
for (int floor = 0; floor < p.floors(); floor++) {
|
||||
if (p.kind() == Kind.HOTEL && floor > 0)
|
||||
result.add(new BathPlan("floor_" + floor + "/suite_bathroom", floor, 10, 1, 15, 4, 12, true));
|
||||
if (p.kind() == Kind.HALL && floor < 2) continue;
|
||||
for (boolean east : new boolean[]{false, true}) {
|
||||
String use = rearUse(p, floor, east);
|
||||
if (!use.equals("bathroom") && !use.equals("washrooms")) continue;
|
||||
Area room = rear(p, floor, east);
|
||||
result.add(new BathPlan("floor_" + floor + "/" + (east ? "east" : "west") + "_bathroom_interior",
|
||||
floor, room.left() - 1, 12, room.right() + 1, 15, east ? 12 : 4, false));
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
public static List<Bathroom> bathrooms(Parcel p) {
|
||||
validate(p);
|
||||
List<Bathroom> result = new ArrayList<>();
|
||||
for (var b : bathroomPlans(p)) {
|
||||
int x0 = rx(b.x0(), b.z0(), p.facing()), z0 = rz(b.x0(), b.z0(), p.facing());
|
||||
int x1 = rx(b.x1(), b.z1(), p.facing()), z1 = rz(b.x1(), b.z1(), p.facing());
|
||||
int doorZ = b.southDoor() ? b.z1() : b.z0();
|
||||
result.add(new Bathroom(b.name(), Math.min(x0, x1), b.floor() * STOREY, Math.min(z0, z1),
|
||||
Math.max(x0, x1), b.floor() * STOREY + 4, Math.max(z0, z1), rx(b.doorX(), doorZ, p.facing()),
|
||||
b.floor() * STOREY + 1, rz(b.doorX(), doorZ, p.facing())));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static void validate(Parcel p) {
|
||||
if (!p.kind().building() || p.width() != 17 || p.depth() != 17 || p.floors() < 2)
|
||||
throw new IllegalArgumentException("Generation-20 interiors require a 17x17 building with 2..5 floors");
|
||||
}
|
||||
|
||||
private static void add(List<Room> result, Parcel p, String name, int x, int y, int z) {
|
||||
result.add(new Room(name, rx(x, z, p.facing()), y, rz(x, z, p.facing())));
|
||||
}
|
||||
private static int rx(int x, int z, int facing) {
|
||||
return switch (facing) { case 0 -> x; case 1 -> 16 - z; case 2 -> 16 - x; default -> z; };
|
||||
}
|
||||
private static int rz(int x, int z, int facing) {
|
||||
return switch (facing) { case 0 -> z; case 1 -> x; case 2 -> 16 - z; default -> 16 - x; };
|
||||
}
|
||||
|
||||
private static final class Building {
|
||||
final Parcel p;
|
||||
final LostCityRenderer.BlockWriter output;
|
||||
final Rotation rotation;
|
||||
Building(Parcel p, LostCityRenderer.BlockWriter output) {
|
||||
this.p = p;
|
||||
this.output = output;
|
||||
rotation = switch (p.facing()) {
|
||||
case 0 -> Rotation.NONE; case 1 -> Rotation.CLOCKWISE_90;
|
||||
case 2 -> Rotation.CLOCKWISE_180; default -> Rotation.COUNTERCLOCKWISE_90;
|
||||
};
|
||||
}
|
||||
void set(int x, int y, int z, BlockState state) {
|
||||
output.set(rx(x, z, p.facing()), y, rz(x, z, p.facing()), state.rotate(rotation));
|
||||
}
|
||||
void set(int x, int y, int z, Block block) { set(x, y, z, block.defaultBlockState()); }
|
||||
void box(int x0, int y0, int z0, int x1, int y1, int z1, BlockState state) {
|
||||
for (int x = x0; x <= x1; x++) for (int z = z0; z <= z1; z++)
|
||||
for (int y = y0; y <= y1; y++) set(x, y, z, state);
|
||||
}
|
||||
BlockState facing(Block block, Direction direction) {
|
||||
return block.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, direction);
|
||||
}
|
||||
BlockState brick(int x, int y, int z) { return LostCityPalette.masonry(p.seed(), x, y, z); }
|
||||
int roll(int x, int y, int z, int bound) { return LostCityPalette.roll(p.seed(), x, y, z, bound); }
|
||||
|
||||
void render() {
|
||||
int roof = p.floors() * STOREY;
|
||||
box(0, -p.foundationDepth(), 0, 16, -1, 16, Blocks.STONE_BRICKS.defaultBlockState());
|
||||
box(0, 0, 0, 16, 0, 16, SMOOTH);
|
||||
box(0, 1, 0, 16, roof + 4, 16, AIR);
|
||||
shell(roof);
|
||||
architecturalEnvelope(roof);
|
||||
for (int floor = 0; floor < p.floors(); floor++) {
|
||||
partitions(floor);
|
||||
furnishings(floor);
|
||||
}
|
||||
stairs();
|
||||
entrance();
|
||||
roof(roof);
|
||||
// Only one deferred loot table per building; empty furniture is not a resource vault.
|
||||
int lootX = 6;
|
||||
int lootZ = 6;
|
||||
box(lootX, 2, lootZ, lootX, 3, lootZ, AIR);
|
||||
ResourceKey<LootTable> table = switch (p.kind()) {
|
||||
case LIBRARY, HALL -> BuiltInLootTables.VILLAGE_CARTOGRAPHER;
|
||||
case RESTAURANT -> BuiltInLootTables.VILLAGE_BUTCHER;
|
||||
case BARRACKS -> BuiltInLootTables.VILLAGE_FLETCHER;
|
||||
default -> BuiltInLootTables.VILLAGE_PLAINS_HOUSE;
|
||||
};
|
||||
output.chest(rx(lootX, lootZ, p.facing()), 1, rz(lootX, lootZ, p.facing()), table,
|
||||
p.seed() ^ 0x20c17a7eL ^ p.kind().ordinal());
|
||||
}
|
||||
|
||||
void shell(int roof) {
|
||||
for (int floor = 0; floor <= p.floors(); floor++) {
|
||||
int base = floor * STOREY;
|
||||
for (int x = 1; x <= 15; x++) for (int z = 1; z <= 15; z++)
|
||||
set(x, base, z, floor == p.floors() ? SMOOTH : floorMaterial(floor, x, z));
|
||||
if (floor == p.floors()) continue;
|
||||
// A pale ceiling and contrasting structural floor are separate layers.
|
||||
box(2, base + 4, 2, 14, base + 4, 14,
|
||||
Blocks.SMOOTH_STONE_SLAB.defaultBlockState().setValue(BlockStateProperties.SLAB_TYPE, SlabType.TOP));
|
||||
for (int y = 1; y <= 4; y++) for (int horizontal = 1; horizontal <= 15; horizontal++) {
|
||||
facade(horizontal, base + y, 1, horizontal, y, true);
|
||||
facade(horizontal, base + y, 15, horizontal, y, true);
|
||||
facade(1, base + y, horizontal, horizontal, y, false);
|
||||
facade(15, base + y, horizontal, horizontal, y, false);
|
||||
}
|
||||
// Floor bands remain intact even where windows have fallen out.
|
||||
for (int n = 1; n <= 15; n++) {
|
||||
set(n, base, 1, SMOOTH); set(n, base, 15, SMOOTH);
|
||||
set(1, base, n, SMOOTH); set(15, base, n, SMOOTH);
|
||||
}
|
||||
if (p.kind() == Kind.HOTEL || p.kind() == Kind.LIBRARY) {
|
||||
for (int x : new int[]{3, 13}) for (int y = 0; y <= 4; y++) set(x, base + y, 0, SMOOTH);
|
||||
}
|
||||
if (p.kind() == Kind.APARTMENT) for (int x = 11; x <= 14; x++)
|
||||
set(x, base + 1, 0, Blocks.SMOOTH_STONE_SLAB);
|
||||
if (p.kind() == Kind.RESTAURANT) box(10, base + 4, 0, 14, base + 4, 0,
|
||||
Blocks.DARK_OAK_SLAB.defaultBlockState());
|
||||
}
|
||||
if (p.kind() == Kind.HALL) {
|
||||
// The auditorium spans two storeys; its upper level is a guarded gallery.
|
||||
box(3, 4, 11, 13, 5, 14, AIR);
|
||||
for (int x = 3; x <= 13; x++) set(x, 6, 11, rail(true));
|
||||
for (int z = 12; z <= 14; z++) { set(3, 6, z, rail(false)); set(13, 6, z, rail(false)); }
|
||||
}
|
||||
}
|
||||
|
||||
BlockState floorMaterial(int floor, int x, int z) {
|
||||
boolean border = x == 1 || z == 1 || x == 15 || z == 15;
|
||||
if (border) return Blocks.TUFF_BRICKS.defaultBlockState();
|
||||
return switch (p.kind()) {
|
||||
case LIBRARY -> (x <= 5 || z >= 10 ? Blocks.OAK_PLANKS : Blocks.BIRCH_PLANKS).defaultBlockState();
|
||||
case APARTMENT, HOTEL -> ((floor & 1) == 0 ? Blocks.OAK_PLANKS : Blocks.BIRCH_PLANKS).defaultBlockState();
|
||||
case RESTAURANT -> ((x / 3 + z / 3) & 1) == 0 ? SMOOTH : Blocks.POLISHED_DIORITE.defaultBlockState();
|
||||
case BARRACKS -> (x <= 5 ? Blocks.POLISHED_ANDESITE : Blocks.OAK_PLANKS).defaultBlockState();
|
||||
default -> (x == 7 || x == 9 ? Blocks.POLISHED_GRANITE : Blocks.SMOOTH_STONE).defaultBlockState();
|
||||
};
|
||||
}
|
||||
|
||||
BlockState pier() {
|
||||
return switch (p.kind()) {
|
||||
case APARTMENT -> Blocks.STRIPPED_OAK_LOG.defaultBlockState();
|
||||
case HOTEL -> Blocks.POLISHED_DIORITE.defaultBlockState();
|
||||
case LIBRARY -> Blocks.STRIPPED_BIRCH_LOG.defaultBlockState();
|
||||
case RESTAURANT -> Blocks.POLISHED_GRANITE.defaultBlockState();
|
||||
case BARRACKS -> Blocks.TUFF_BRICKS.defaultBlockState();
|
||||
default -> Blocks.CHISELED_TUFF_BRICKS.defaultBlockState();
|
||||
};
|
||||
}
|
||||
|
||||
void facade(int x, int y, int z, int horizontal, int withinFloor, boolean eastWest) {
|
||||
boolean column = horizontal == 1 || horizontal == 6 || horizontal == 10 || horizontal == 15;
|
||||
boolean window = !column && (withinFloor == 2 || withinFloor == 3
|
||||
|| withinFloor == 1 && (p.kind() == Kind.LIBRARY || p.kind() == Kind.RESTAURANT || p.kind() == Kind.HALL));
|
||||
if (column) set(x, y, z, pier());
|
||||
else if (window) {
|
||||
if (roll(x, y / 5, z, 17) != 0) set(x, y, z,
|
||||
p.kind() == Kind.BARRACKS ? rail(eastWest) : pane(eastWest));
|
||||
} else set(x, y, z, withinFloor == 4 ? Blocks.TUFF_BRICKS.defaultBlockState()
|
||||
: p.kind() == Kind.APARTMENT || p.kind() == Kind.LIBRARY ? Blocks.BIRCH_PLANKS.defaultBlockState() : STONE);
|
||||
}
|
||||
|
||||
/** Volume belongs to its use: projected bays, clipped corners and distinct roof profiles. */
|
||||
void architecturalEnvelope(int roof) {
|
||||
for (int floor = 0; floor < p.floors(); floor++) {
|
||||
int y = floor * STOREY;
|
||||
// Project the floor plates as a continuous, one-block cornice inside the parcel.
|
||||
for (int n = 1; n <= 15; n++) {
|
||||
set(n, y, 0, stairTrim(Direction.NORTH)); set(n, y, 16, stairTrim(Direction.SOUTH));
|
||||
set(0, y, n, stairTrim(Direction.WEST)); set(16, y, n, stairTrim(Direction.EAST));
|
||||
}
|
||||
switch (p.kind()) {
|
||||
case APARTMENT -> {
|
||||
// Bay windows increase the living room rather than drawing flat stripes on it.
|
||||
for (int z : new int[]{4, 5, 6, 11, 12, 13}) {
|
||||
set(16, y, z, Blocks.BIRCH_PLANKS);
|
||||
set(16, y + 1, z, Blocks.OAK_PLANKS);
|
||||
set(16, y + 2, z, Blocks.GLASS_PANE);
|
||||
set(16, y + 3, z, Blocks.GLASS_PANE);
|
||||
set(16, y + 4, z, Blocks.BIRCH_SLAB.defaultBlockState().setValue(BlockStateProperties.SLAB_TYPE, SlabType.TOP));
|
||||
}
|
||||
for (int z : new int[]{3, 7, 10, 14}) box(16, y + 1, z, 16, y + 4, z, pier());
|
||||
// Original wall remains the sill, with an open bay above it; privacy walls restore themselves later.
|
||||
for (int z : new int[]{4, 5, 6, 11, 12, 13}) box(15, y + 2, z, 15, y + 3, z, AIR);
|
||||
bevelFront(y);
|
||||
}
|
||||
case HOTEL -> {
|
||||
for (int x : new int[]{2, 5, 11, 14}) box(x, y + 1, 0, x, y + 4, 0, pier());
|
||||
for (int x : new int[]{3, 4, 12, 13}) set(x, y + 1, 0, Blocks.BIRCH_FENCE);
|
||||
box(2, y + 4, 0, 5, y + 4, 0, Blocks.BIRCH_PLANKS.defaultBlockState());
|
||||
box(11, y + 4, 0, 14, y + 4, 0, Blocks.BIRCH_PLANKS.defaultBlockState());
|
||||
}
|
||||
case LIBRARY -> {
|
||||
// Tall fins frame broad glazing and project beyond the reading rooms.
|
||||
for (int x : new int[]{2, 5, 11, 14}) box(x, y + 1, 0, x, y + 4, 0, Blocks.STRIPPED_BIRCH_LOG.defaultBlockState());
|
||||
for (int x : new int[]{3, 4, 12, 13}) {
|
||||
set(x, y + 4, 0, Blocks.OAK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.SOUTH));
|
||||
}
|
||||
bevelFront(y);
|
||||
}
|
||||
case RESTAURANT -> {
|
||||
// Separate fascia and timber valance shelter the shop windows.
|
||||
for (int x = 10; x <= 14; x++) {
|
||||
set(x, y + 3, 0, Blocks.BIRCH_SLAB);
|
||||
set(x, y + 4, 0, ((x / 2) & 1) == 0 ? Blocks.OAK_PLANKS : Blocks.BIRCH_PLANKS);
|
||||
}
|
||||
for (int z : new int[]{3, 7, 11, 14}) set(16, y + 4, z, Blocks.POLISHED_GRANITE);
|
||||
}
|
||||
case BARRACKS -> {
|
||||
for (int z : new int[]{2, 7, 12, 15}) box(0, y + 1, z, 0, y + 4, z, pier());
|
||||
for (int x : new int[]{2, 5, 11, 14}) box(x, y + 1, 0, x, y + 4, 0, Blocks.TUFF_BRICKS.defaultBlockState());
|
||||
for (int x : new int[]{3, 4, 12, 13}) set(x, y + 1, 0, Blocks.CHISELED_TUFF_BRICKS);
|
||||
}
|
||||
case HALL -> {
|
||||
for (int x : new int[]{2, 5, 11, 14}) box(x, y + 1, 0, x, y + 4, 0, pier());
|
||||
for (int x = 2; x <= 14; x++) set(x, y + 4, 0,
|
||||
Blocks.POLISHED_DIORITE_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.SOUTH));
|
||||
}
|
||||
default -> throw new IllegalStateException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BlockState stairTrim(Direction outward) {
|
||||
Block block = p.kind() == Kind.RESTAURANT ? Blocks.POLISHED_GRANITE_STAIRS : Blocks.TUFF_BRICK_STAIRS;
|
||||
return facing(block, outward.getOpposite()).setValue(BlockStateProperties.HALF,
|
||||
net.minecraft.world.level.block.state.properties.Half.TOP);
|
||||
}
|
||||
|
||||
void bevelFront(int y) {
|
||||
// Remove two rectangular corners and close them with actual stepped returns.
|
||||
for (int x : new int[]{1, 15}) {
|
||||
box(x, y, 1, x, y + 4, 1, AIR);
|
||||
int adjacent = x == 1 ? 2 : 14;
|
||||
box(adjacent, y + 1, 1, adjacent, y + 4, 1, pier());
|
||||
box(x, y + 1, 2, x, y + 4, 2, pier());
|
||||
}
|
||||
}
|
||||
|
||||
void partitions(int floor) {
|
||||
int y = floor * STOREY;
|
||||
int front = frontWall(p, floor);
|
||||
// The west stairwell, central corridor and vestibule are independent of furniture.
|
||||
box(5, y + 1, 2, 5, y + 4, 9, SMOOTH);
|
||||
door(5, y + 1, 8, Direction.WEST, false);
|
||||
box(10, y + 1, 2, 10, y + 4, front, SMOOTH);
|
||||
box(10, y + 1, front, 15, y + 4, front, SMOOTH);
|
||||
door(10, y + 1, 6, Direction.EAST, false);
|
||||
if (floor == 0) {
|
||||
box(6, 1, 4, 9, 4, 4, SMOOTH);
|
||||
door(8, 1, 4, Direction.SOUTH, false);
|
||||
}
|
||||
if (p.kind() == Kind.HALL && floor < 2) {
|
||||
if (floor == 0) {
|
||||
box(2, 1, 10, 14, 4, 10, SMOOTH);
|
||||
door(7, 1, 10, Direction.SOUTH, true);
|
||||
door(8, 1, 10, Direction.SOUTH, false);
|
||||
}
|
||||
} else {
|
||||
Area west = rear(p, floor, false), east = rear(p, floor, true);
|
||||
box(1, y + 1, west.front() - 1, west.right() + 1, y + 4, west.front() - 1, SMOOTH);
|
||||
box(west.right() + 1, y + 1, west.front() - 1, west.right() + 1, y + 4, 15, SMOOTH);
|
||||
box(east.left() - 1, y + 1, east.front() - 1, 15, y + 4, east.front() - 1, SMOOTH);
|
||||
box(east.left() - 1, y + 1, east.front() - 1, east.left() - 1, y + 4, 15, SMOOTH);
|
||||
door(west.right() + 1, y + 1, 11, Direction.WEST, false);
|
||||
door(east.left() - 1, y + 1, 11, Direction.EAST, false);
|
||||
}
|
||||
// A hanging lantern touches a full ceiling block, never the recessed underside of a top slab.
|
||||
set(8, y + 4, 8, SMOOTH);
|
||||
set(8, y + 3, 8, Blocks.LANTERN.defaultBlockState().setValue(BlockStateProperties.HANGING, true));
|
||||
// Weathering stays on facades and roofs: it must not replace a privacy partition.
|
||||
}
|
||||
|
||||
void furnishings(int floor) {
|
||||
int y = floor * STOREY;
|
||||
int variant = layout(p, floor);
|
||||
switch (p.kind()) {
|
||||
case APARTMENT -> {
|
||||
if (variant == 0) { kitchenFront(y); bedroom(floor, false); }
|
||||
else { frontBedroom(floor); kitchenRear(floor, false); }
|
||||
}
|
||||
case HOTEL -> {
|
||||
if (floor == 0) { reception(y); lounge(floor, false); laundry(floor); }
|
||||
else {
|
||||
anchoredBed(14, y + 1, frontWall(p, floor) - 2, Direction.SOUTH);
|
||||
set(11, y + 1, 5, Blocks.BARREL); set(11, y + 2, 5, Blocks.FLOWER_POT);
|
||||
if (variant == 0) { bedroom(floor, false); bedroom(floor, true); }
|
||||
else lounge(floor, false);
|
||||
}
|
||||
}
|
||||
case LIBRARY -> {
|
||||
if (variant == 0) {
|
||||
box(14, y + 1, 2, 14, y + 3, 6, Blocks.BOOKSHELF.defaultBlockState());
|
||||
box(12, y + 1, 2, 12, y + 2, 3, Blocks.BOOKSHELF.defaultBlockState());
|
||||
desk(12, y, 5, Direction.SOUTH);
|
||||
readingRoom(floor, false); archive(floor, true);
|
||||
} else {
|
||||
readingFront(y); readingRoom(floor, false);
|
||||
set(2, y + 1, 14, Blocks.CARTOGRAPHY_TABLE);
|
||||
}
|
||||
}
|
||||
case RESTAURANT -> {
|
||||
if (variant == 0) {
|
||||
table(13, y, 3); table(13, y, 5);
|
||||
set(11, y + 1, 2, Blocks.POTTED_DEAD_BUSH);
|
||||
kitchenRear(floor, true);
|
||||
} else {
|
||||
kitchenFront(y); diningRear(floor, false); pantry(floor, true);
|
||||
}
|
||||
}
|
||||
case BARRACKS -> {
|
||||
if (variant == 0) {
|
||||
anchoredBed(12, y + 1, 3, Direction.NORTH); anchoredBed(14, y + 1, 3, Direction.NORTH);
|
||||
set(11, y + 1, 2, Blocks.BARREL); set(11, y + 2, 2, Blocks.BARREL);
|
||||
seat(13, y + 1, 6, Direction.NORTH);
|
||||
} else { reception(y); bedroom(floor, false); }
|
||||
box(12, y + 1, 14, 14, y + 2, 14, Blocks.BARREL.defaultBlockState());
|
||||
set(14, y + 1, 11, Blocks.FLETCHING_TABLE);
|
||||
set(12, y + 1, 11, Blocks.GRINDSTONE);
|
||||
}
|
||||
case HALL -> {
|
||||
if (floor == 0 || floor > 1 && variant == 0) reception(y); else readingFront(y);
|
||||
if (floor == 0) auditorium();
|
||||
else if (floor >= 2) {
|
||||
if (variant == 0) readingRoom(floor, false);
|
||||
else { archive(floor, false); readingRoom(floor, true); }
|
||||
}
|
||||
}
|
||||
default -> throw new IllegalArgumentException("Not an interior kind");
|
||||
}
|
||||
for (var bath : bathroomPlans(p)) if (bath.floor() == floor) bathroom(bath);
|
||||
}
|
||||
|
||||
void kitchenFront(int y) {
|
||||
kitchenLine(y, 11, 2, Direction.SOUTH);
|
||||
table(13, y, 5);
|
||||
}
|
||||
|
||||
void kitchenRear(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
kitchenLine(y, room.left(), 14, Direction.NORTH);
|
||||
if (east) {
|
||||
set(14, y + 1, 11, SMOOTH); set(14, y + 2, 11, Blocks.FLOWER_POT);
|
||||
set(12, y + 1, 11, Blocks.CRAFTING_TABLE);
|
||||
} else table(room.left() + 2, y, 11);
|
||||
}
|
||||
|
||||
void kitchenLine(int y, int left, int z, Direction face) {
|
||||
set(left, y + 1, z, facing(Blocks.FURNACE, face));
|
||||
set(left + 1, y + 1, z, Blocks.WATER_CAULDRON.defaultBlockState().setValue(BlockStateProperties.LEVEL_CAULDRON, 2));
|
||||
set(left + 2, y + 1, z, facing(Blocks.SMOKER, face));
|
||||
set(left + 3, y + 1, z, Blocks.BARREL);
|
||||
box(left, y + 3, z, left + 3, y + 3, z, SMOOTH);
|
||||
set(left + 2, y + 2, z, Blocks.IRON_TRAPDOOR);
|
||||
for (int x = left; x <= left + 3; x++) set(x, y + 3, z, Blocks.POLISHED_DIORITE);
|
||||
for (int x = left; x <= left + 3; x++) set(x, y, z, (x & 1) == 0 ? SMOOTH : TILE);
|
||||
}
|
||||
|
||||
void bedroom(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
anchoredBed(east ? room.right() - 1 : room.left() + 1, y + 1, 13, east ? Direction.EAST : Direction.WEST);
|
||||
set(east ? room.right() : room.left(), y + 1, 11, Blocks.BARREL);
|
||||
desk(east ? room.left() + 1 : room.right() - 1, y, 11, Direction.SOUTH);
|
||||
set(east ? room.left() : room.right(), y + 1, 14, Blocks.BOOKSHELF);
|
||||
}
|
||||
|
||||
void frontBedroom(int floor) {
|
||||
int y = floor * STOREY;
|
||||
anchoredBed(14, y + 1, 3, Direction.NORTH);
|
||||
set(11, y + 1, 2, Blocks.BARREL); set(11, y + 2, 2, Blocks.FLOWER_POT);
|
||||
desk(12, y, 5, Direction.SOUTH);
|
||||
}
|
||||
|
||||
void bathroom(BathPlan bath) {
|
||||
int y = bath.floor() * STOREY;
|
||||
for (int x = bath.x0(); x <= bath.x1(); x++) for (int z = bath.z0(); z <= bath.z1(); z++) {
|
||||
set(x, y, z, ((x + z) & 1) == 0 ? SMOOTH : TILE);
|
||||
set(x, y + 4, z, SMOOTH);
|
||||
boolean wall = x == bath.x0() || x == bath.x1() || z == bath.z0() || z == bath.z1();
|
||||
for (int up = 1; up <= 3; up++) set(x, y + up, z, wall ? SMOOTH : AIR);
|
||||
}
|
||||
int doorZ = bath.southDoor() ? bath.z1() : bath.z0();
|
||||
door(bath.doorX(), y + 1, doorZ, bath.southDoor() ? Direction.NORTH : Direction.SOUTH, false);
|
||||
int fixtureZ = bath.southDoor() ? bath.z0() + 1 : bath.z1() - 1;
|
||||
int toiletX = bath.southDoor() ? bath.x1() - 2 : bath.x0() + 1;
|
||||
Direction view = bath.southDoor() ? Direction.SOUTH : Direction.EAST;
|
||||
set(toiletX, y + 1, fixtureZ, seating(Blocks.POLISHED_ANDESITE_STAIRS, view));
|
||||
set(toiletX, y + 2, fixtureZ, Blocks.IRON_TRAPDOOR);
|
||||
set(bath.x1() - 1, y + 1, fixtureZ, Blocks.WATER_CAULDRON.defaultBlockState().setValue(BlockStateProperties.LEVEL_CAULDRON, 2));
|
||||
}
|
||||
|
||||
void reception(int y) {
|
||||
box(12, y + 1, 5, 14, y + 1, 5, Blocks.BIRCH_PLANKS.defaultBlockState());
|
||||
set(13, y + 2, 5, Blocks.FLOWER_POT);
|
||||
set(14, y + 1, 2, Blocks.BOOKSHELF); set(14, y + 2, 2, Blocks.BARREL);
|
||||
desk(12, y, 3, Direction.SOUTH);
|
||||
set(11, y + 1, 2, Blocks.POTTED_DEAD_BUSH);
|
||||
}
|
||||
|
||||
void laundry(int floor) {
|
||||
int y = floor * STOREY;
|
||||
for (int z = 11; z <= 12; z++) set(14, y + 1, z, Blocks.WATER_CAULDRON.defaultBlockState()
|
||||
.setValue(BlockStateProperties.LEVEL_CAULDRON, z == 11 ? 1 : 2));
|
||||
box(12, y + 1, 14, 14, y + 1, 14, Blocks.BARREL.defaultBlockState());
|
||||
box(12, y + 3, 14, 14, y + 3, 14, Blocks.DARK_OAK_SLAB.defaultBlockState());
|
||||
set(12, y + 1, 11, Blocks.LOOM);
|
||||
}
|
||||
|
||||
void lounge(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
box(room.left(), y + 1, 14, room.left() + 2, y + 1, 14, seating(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
|
||||
desk(room.left() + 1, y, 11, Direction.SOUTH);
|
||||
set(room.left(), y + 1, 12, Blocks.BOOKSHELF);
|
||||
}
|
||||
|
||||
void readingRoom(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
desk(east ? room.left() + 1 : room.right() - 1, y, 11, Direction.SOUTH);
|
||||
box(room.left(), y + 1, 14, room.right(), y + 2, 14, Blocks.BOOKSHELF.defaultBlockState());
|
||||
set(east ? room.right() : room.left(), y + 1, 11, facing(Blocks.LECTERN, Direction.NORTH));
|
||||
}
|
||||
|
||||
void readingFront(int y) {
|
||||
desk(12, y, 3, Direction.SOUTH);
|
||||
box(14, y + 1, 2, 14, y + 2, 5, Blocks.BOOKSHELF.defaultBlockState());
|
||||
set(11, y + 1, 2, facing(Blocks.LECTERN, Direction.SOUTH));
|
||||
}
|
||||
|
||||
void archive(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
box(room.left(), y + 1, 14, room.right(), y + 3, 14, Blocks.BOOKSHELF.defaultBlockState());
|
||||
box(east ? room.right() : room.left(), y + 1, 11, east ? room.right() : room.left(), y + 2, 12, Blocks.BARREL.defaultBlockState());
|
||||
set(room.left() + 1, y + 1, 12, facing(Blocks.LECTERN, Direction.NORTH));
|
||||
}
|
||||
|
||||
void pantry(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
box(room.left(), y + 1, 14, room.right(), y + 2, 14, Blocks.BARREL.defaultBlockState());
|
||||
set(room.right(), y + 1, 11, Blocks.CRAFTING_TABLE);
|
||||
set(room.right(), y + 2, 11, Blocks.FLOWER_POT);
|
||||
}
|
||||
|
||||
void diningRear(int floor, boolean east) {
|
||||
Area room = rear(p, floor, east);
|
||||
int y = floor * STOREY;
|
||||
table(room.left() + 2, y, 12);
|
||||
box(room.left(), y + 1, 14, room.right(), y + 1, 14, seating(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
|
||||
}
|
||||
|
||||
void auditorium() {
|
||||
for (int x : new int[]{3, 4, 5, 11, 12, 13}) seat(x, 1, 11, Direction.SOUTH);
|
||||
box(3, 1, 13, 13, 1, 14, WOOD);
|
||||
set(8, 1, 12, facing(Blocks.DARK_OAK_STAIRS, Direction.SOUTH));
|
||||
set(8, 2, 14, facing(Blocks.LECTERN, Direction.NORTH));
|
||||
for (int x : new int[]{3, 13}) {
|
||||
box(x, 2, 14, x, 3, 14, WOOD);
|
||||
set(x, 4, 14, Blocks.STONE_BRICK_SLAB);
|
||||
}
|
||||
}
|
||||
|
||||
void stairs() {
|
||||
for (int floor = 0; floor < p.floors(); floor++) {
|
||||
int base = floor * STOREY;
|
||||
for (int step = 1; step <= STOREY; step++) {
|
||||
int z = step + 2;
|
||||
box(2, base + step, z, 3, base + step + 3, z, AIR);
|
||||
for (int x = 2; x <= 3; x++) set(x, base + step, z,
|
||||
facing(Blocks.STONE_BRICK_STAIRS, Direction.SOUTH));
|
||||
}
|
||||
box(2, base + STOREY, 8, 4, base + STOREY, 9, TILE);
|
||||
box(2, base + STOREY + 1, 8, 4, base + STOREY + 3, 9, AIR);
|
||||
}
|
||||
// This full-height side aisle joins every lower stair approach to its landing door.
|
||||
for (int floor = 0; floor <= p.floors(); floor++) {
|
||||
int y = floor * STOREY;
|
||||
box(4, y + 1, 2, 4, y + 3, 9, AIR);
|
||||
}
|
||||
}
|
||||
|
||||
void entrance() {
|
||||
for (int x : new int[]{6, 9}) box(x, 1, 0, x, 3, 0, SMOOTH);
|
||||
box(6, 4, 0, 9, 4, 1, Blocks.STONE_BRICK_SLAB.defaultBlockState());
|
||||
box(7, 0, 0, 8, 0, 3, TILE);
|
||||
// Native SOUTH/RIGHT opens against the west jamb; SOUTH/LEFT against the east jamb.
|
||||
door(7, 1, 1, Direction.SOUTH, true);
|
||||
door(8, 1, 1, Direction.SOUTH, false);
|
||||
set(4, 1, 0, Blocks.POTTED_DEAD_BUSH);
|
||||
set(12, 1, 16, Blocks.COMPOSTER);
|
||||
}
|
||||
|
||||
void roof(int y) {
|
||||
// Clipped parapets and a heavy coping keep each accessible roof legible from below.
|
||||
for (int n = 2; n <= 14; n++) {
|
||||
set(n, y + 1, 1, Blocks.TUFF_BRICKS); set(n, y + 1, 15, Blocks.TUFF_BRICKS);
|
||||
set(1, y + 1, n, Blocks.TUFF_BRICKS); set(15, y + 1, n, Blocks.TUFF_BRICKS);
|
||||
set(n, y + 2, 1, Blocks.TUFF_BRICK_SLAB); set(n, y + 2, 15, Blocks.TUFF_BRICK_SLAB);
|
||||
set(1, y + 2, n, Blocks.TUFF_BRICK_SLAB); set(15, y + 2, n, Blocks.TUFF_BRICK_SLAB);
|
||||
}
|
||||
switch (p.kind()) {
|
||||
case APARTMENT -> {
|
||||
// Two small roof gardens and a pergola leave the stair landing and central crossing free.
|
||||
roofPlanter(10, y, 4); roofPlanter(10, y, 12);
|
||||
for (int x : new int[]{6, 13}) box(x, y + 1, 14, x, y + 3, 14, Blocks.STRIPPED_OAK_LOG.defaultBlockState());
|
||||
box(6, y + 4, 14, 13, y + 4, 14, Blocks.BIRCH_SLAB.defaultBlockState());
|
||||
desk(8, y, 12, Direction.NORTH);
|
||||
}
|
||||
case HOTEL -> {
|
||||
roofRoom(y, 9, 3, 14, 7, Blocks.BIRCH_PLANKS);
|
||||
door(9, y + 1, 5, Direction.EAST, false);
|
||||
set(12, y + 1, 4, Blocks.BARREL); set(13, y + 1, 4, Blocks.LOOM);
|
||||
box(11, y + 1, 12, 13, y + 1, 12, seating(Blocks.DARK_OAK_STAIRS, Direction.NORTH));
|
||||
}
|
||||
case LIBRARY -> {
|
||||
// Clerestory above the stacks; its raised glass ridge is distinct from a flat roof.
|
||||
for (int x = 10; x <= 13; x++) for (int z = 3; z <= 7; z++) {
|
||||
set(x, y + 1, z, x == 10 || x == 13 || z == 3 || z == 7 ? Blocks.STRIPPED_BIRCH_LOG : Blocks.GLASS);
|
||||
if (x == 11 || x == 12) set(x, y + 2, z, Blocks.GLASS);
|
||||
}
|
||||
// Cut both the roof plate and the recessed ceiling beneath the glass.
|
||||
// The six-column light well stays enclosed by its complete timber frame;
|
||||
// the glass cap and the surrounding roof walkway remain intact.
|
||||
box(11, y - 1, 4, 12, y, 6, AIR);
|
||||
desk(11, y, 12, Direction.NORTH); set(14, y + 1, 12, Blocks.LECTERN);
|
||||
}
|
||||
case RESTAURANT -> {
|
||||
// A real dining terrace and rear kitchen extraction with full supporting columns.
|
||||
table(11, y, 6); table(11, y, 11);
|
||||
for (int z : new int[]{3, 14}) box(14, y + 1, z, 14, y + 3, z, Blocks.STRIPPED_OAK_LOG.defaultBlockState());
|
||||
box(14, y + 4, 3, 14, y + 4, 14, Blocks.BIRCH_PLANKS.defaultBlockState());
|
||||
box(6, y + 1, 14, 7, y + 3, 14, Blocks.TUFF_BRICKS.defaultBlockState());
|
||||
set(6, y + 4, 14, Blocks.IRON_BARS); set(7, y + 4, 14, Blocks.IRON_BARS);
|
||||
}
|
||||
case BARRACKS -> {
|
||||
// Enclosed stair head gives the barracks a service tower, without hiding its usable exit.
|
||||
// The whole last flight sits inside the stair head: a front wall at z=6
|
||||
// would cross the headroom over its penultimate step.
|
||||
roofRoom(y, 1, 2, 5, 10, Blocks.TUFF_BRICKS);
|
||||
door(5, y + 1, 8, Direction.EAST, false);
|
||||
box(10, y + 1, 12, 13, y + 1, 14, Blocks.POLISHED_ANDESITE.defaultBlockState());
|
||||
set(11, y + 2, 13, Blocks.IRON_TRAPDOOR);
|
||||
}
|
||||
case HALL -> {
|
||||
for (int step = 0; step < 3; step++) {
|
||||
int edge = 2 + step * 2;
|
||||
box(edge, y + 1 + step, 1, 16 - edge, y + 1 + step, 2, Blocks.TUFF_BRICKS.defaultBlockState());
|
||||
}
|
||||
for (int x : new int[]{4, 12}) set(x, y + 4, 2, Blocks.CHISELED_TUFF_BRICKS);
|
||||
desk(11, y, 12, Direction.NORTH);
|
||||
}
|
||||
default -> throw new IllegalStateException();
|
||||
}
|
||||
}
|
||||
|
||||
void roofRoom(int y, int x0, int z0, int x1, int z1, Block wall) {
|
||||
for (int x = x0; x <= x1; x++) for (int z = z0; z <= z1; z++) {
|
||||
boolean edge = x == x0 || x == x1 || z == z0 || z == z1;
|
||||
for (int up = 1; up <= 3; up++) set(x, y + up, z, edge ? wall.defaultBlockState() : AIR);
|
||||
set(x, y + 4, z, Blocks.TUFF_BRICK_SLAB.defaultBlockState().setValue(BlockStateProperties.SLAB_TYPE, SlabType.TOP));
|
||||
}
|
||||
}
|
||||
void roofPlanter(int x, int y, int z) {
|
||||
for (int dx = 0; dx <= 3; dx++) set(x + dx, y + 1, z, Blocks.TUFF_BRICKS);
|
||||
set(x + 1, y + 1, z, Blocks.DIRT); set(x + 2, y + 1, z, Blocks.DIRT);
|
||||
set(x + 1, y + 2, z, Blocks.OXEYE_DAISY); set(x + 2, y + 2, z, Blocks.CORNFLOWER);
|
||||
}
|
||||
|
||||
void door(int x, int y, int z, Direction facing, boolean rightHinge) {
|
||||
BlockState state = facing(Blocks.DARK_OAK_DOOR, facing)
|
||||
.setValue(BlockStateProperties.DOOR_HINGE, rightHinge ? DoorHingeSide.RIGHT : DoorHingeSide.LEFT)
|
||||
.setValue(BlockStateProperties.OPEN, false)
|
||||
.setValue(BlockStateProperties.POWERED, false);
|
||||
set(x, y, z, state.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.LOWER));
|
||||
set(x, y + 1, z, state.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.UPPER));
|
||||
}
|
||||
void anchoredBed(int x, int y, int z, Direction facing) {
|
||||
BlockState state = facing(Blocks.BED.white(), facing);
|
||||
set(x, y, z, state.setValue(BlockStateProperties.BED_PART, BedPart.FOOT));
|
||||
set(x + facing.getStepX(), y, z + facing.getStepZ(), state.setValue(BlockStateProperties.BED_PART, BedPart.HEAD));
|
||||
int wallX = x + facing.getStepX() * 2, wallZ = z + facing.getStepZ() * 2;
|
||||
Direction alongWall = facing.getClockWise();
|
||||
for (int width = -1; width <= 1; width++) for (int up = 0; up <= 3; up++)
|
||||
set(wallX + alongWall.getStepX() * width, y + up, wallZ + alongWall.getStepZ() * width, SMOOTH);
|
||||
}
|
||||
void table(int x, int y, int z) {
|
||||
set(x, y + 1, z, Blocks.DARK_OAK_FENCE); set(x, y + 2, z, Blocks.DARK_OAK_PRESSURE_PLATE);
|
||||
seat(x - 1, y + 1, z, Direction.EAST);
|
||||
seat(x + 1, y + 1, z, Direction.WEST);
|
||||
}
|
||||
void desk(int x, int y, int z, Direction approachSide) {
|
||||
set(x, y + 1, z, Blocks.DARK_OAK_FENCE); set(x, y + 2, z, Blocks.DARK_OAK_PRESSURE_PLATE);
|
||||
seat(x + approachSide.getStepX(), y + 1, z + approachSide.getStepZ(), approachSide.getOpposite());
|
||||
}
|
||||
void seat(int x, int y, int z, Direction lookingToward) {
|
||||
set(x, y, z, seating(Blocks.DARK_OAK_STAIRS, lookingToward));
|
||||
}
|
||||
BlockState seating(Block stair, Direction lookingToward) {
|
||||
// Stair FACING points uphill, into its full-height back, opposite a seated player's view.
|
||||
return facing(stair, lookingToward.getOpposite());
|
||||
}
|
||||
BlockState rail(boolean eastWest) { return connected(Blocks.IRON_BARS, eastWest); }
|
||||
BlockState pane(boolean eastWest) { return connected(Blocks.GLASS_PANE, eastWest); }
|
||||
BlockState connected(Block block, boolean eastWest) {
|
||||
return block.defaultBlockState().setValue(BlockStateProperties.EAST, eastWest)
|
||||
.setValue(BlockStateProperties.WEST, eastWest).setValue(BlockStateProperties.NORTH, !eastWest)
|
||||
.setValue(BlockStateProperties.SOUTH, !eastWest);
|
||||
}
|
||||
}
|
||||
}
|
||||
+767
@@ -0,0 +1,767 @@
|
||||
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.ArrayList;
|
||||
import fr.koka.sanctuary.worldgen.detail.ConnectedBlocks20;
|
||||
import java.util.List;
|
||||
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.block.state.properties.DoorHingeSide;
|
||||
import net.minecraft.world.level.block.state.properties.DoubleBlockHalf;
|
||||
import net.minecraft.world.level.block.state.properties.SlabType;
|
||||
import net.minecraft.world.level.storage.loot.BuiltInLootTables;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
|
||||
/** Complete generation-21 buildings. Published renderers deliberately never call this renderer.
|
||||
* A north-facing plan is rotated once; all positions remain relative to the saved parcel. */
|
||||
public final class LostCityInteriors21 {
|
||||
private static final BlockState AIR = Blocks.AIR.defaultBlockState();
|
||||
private static final BlockState STONE = Blocks.STONE.defaultBlockState();
|
||||
private static final BlockState SMOOTH = Blocks.SMOOTH_STONE.defaultBlockState();
|
||||
private static final BlockState WOOD = Blocks.OAK_PLANKS.defaultBlockState();
|
||||
private static final BlockState TILE = Blocks.POLISHED_ANDESITE.defaultBlockState();
|
||||
private static final int STOREY = 5;
|
||||
|
||||
private LostCityInteriors21() {}
|
||||
|
||||
/** Reachable standing positions, in the same rotated local coordinates as BlockWriter. */
|
||||
public record Room(String name, int x, int y, int z) {}
|
||||
|
||||
/** Local, already rotated enclosure including the floor, full ceiling, four walls and door. */
|
||||
public record Bathroom(String name, int minX, int minY, int minZ, int maxX, int maxY, int maxZ,
|
||||
int doorX, int doorY, int doorZ) {}
|
||||
|
||||
private record Area(int left, int front, int right, int back) {}
|
||||
|
||||
/** Adjacent storeys always change plan; the saved parcel seed chooses which plan comes first. */
|
||||
public static int layout(Parcel parcel, int floor) {
|
||||
validate(parcel);
|
||||
if (floor < 0 || floor >= parcel.floors()) throw new IllegalArgumentException("Invalid interior floor");
|
||||
return (int) ((parcel.seed() ^ (parcel.seed() >>> 32)) & 1L) ^ (floor & 1);
|
||||
}
|
||||
|
||||
private static Area rear(Parcel p, int floor, boolean east) {
|
||||
boolean wide = layout(p, floor) == 1;
|
||||
return east ? new Area(wide ? 10 : 11, wide ? 10 : 11, 14, 14)
|
||||
: new Area(2, wide ? 10 : 11, wide ? 6 : 5, 14);
|
||||
}
|
||||
|
||||
private static int frontWall(Parcel p, int floor) { return layout(p, floor) == 0 ? 7 : 8; }
|
||||
|
||||
private static String frontUse(Parcel p, int floor) {
|
||||
int variant = layout(p, floor);
|
||||
return switch (p.kind()) {
|
||||
case APARTMENT -> variant == 0 ? "kitchen_dining" : "studio_bedroom";
|
||||
case HOTEL -> floor == 0 ? "reception" : "guest_suite";
|
||||
case LIBRARY -> variant == 0 ? "book_stacks" : "periodicals_reading";
|
||||
case RESTAURANT -> variant == 0 ? "dining_room" : "kitchen";
|
||||
case BARRACKS -> variant == 0 ? "dormitory" : "duty_office";
|
||||
case HALL -> floor == 0 ? "ticket_office" : "administration";
|
||||
default -> throw new IllegalArgumentException("Not an interior kind");
|
||||
};
|
||||
}
|
||||
|
||||
private static String rearUse(Parcel p, int floor, boolean east) {
|
||||
int variant = layout(p, floor);
|
||||
return switch (p.kind()) {
|
||||
case APARTMENT -> east ? "bathroom" : variant == 0 ? "bedroom" : "kitchen_dining";
|
||||
case HOTEL -> floor == 0 ? east ? "laundry" : "lounge"
|
||||
: variant == 0 ? east ? "guest_room" : "bedroom" : east ? "bathroom" : "guest_lounge";
|
||||
case LIBRARY -> variant == 0 ? east ? "archive" : "reading_room" : east ? "bathroom" : "catalogue_office";
|
||||
case RESTAURANT -> variant == 0 ? east ? "kitchen" : "washrooms" : east ? "pantry" : "dining_room";
|
||||
case BARRACKS -> east ? "equipment_store" : variant == 0 ? "washrooms" : "officer_bedroom";
|
||||
case HALL -> variant == 0 ? east ? "bathroom" : "meeting_room" : east ? "meeting_room" : "archive";
|
||||
default -> throw new IllegalArgumentException("Not an interior kind");
|
||||
};
|
||||
}
|
||||
|
||||
public static void render(Parcel parcel, LostCityRenderer.BlockWriter output) {
|
||||
validate(parcel);
|
||||
var connected = ConnectedBlocks20.buffer(output);
|
||||
new Building(parcel, connected).render();
|
||||
connected.flush();
|
||||
}
|
||||
|
||||
/** These describe actual rooms and services, not decorative labels placed in the world. */
|
||||
public static List<Room> rooms(Parcel parcel) {
|
||||
validate(parcel);
|
||||
List<Room> result = new ArrayList<>();
|
||||
add(result, parcel, "street_entrance", 8, 1, 0);
|
||||
add(result, parcel, "vestibule", 8, 1, 3);
|
||||
for (int floor = 0; floor < parcel.floors(); floor++) {
|
||||
int y = floor * STOREY + 1;
|
||||
String prefix = "floor_" + floor + "/";
|
||||
add(result, parcel, prefix + "corridor", 8, y, 8);
|
||||
add(result, parcel, prefix + "stair_landing", 4, y, 8);
|
||||
add(result, parcel, prefix + frontUse(parcel, floor), 11, y, 6);
|
||||
if (parcel.kind() == Kind.HALL && floor < 2) {
|
||||
if (floor == 0) {
|
||||
add(result, parcel, prefix + "auditorium", 8, y, 11);
|
||||
add(result, parcel, prefix + "stage", 9, y + 1, 13);
|
||||
} else add(result, parcel, prefix + "gallery", 8, y, 10);
|
||||
} else {
|
||||
Area west = rear(parcel, floor, false), east = rear(parcel, floor, true);
|
||||
add(result, parcel, prefix + rearUse(parcel, floor, false), west.right(), y, 11);
|
||||
add(result, parcel, prefix + rearUse(parcel, floor, true), east.left(), y, 11);
|
||||
}
|
||||
}
|
||||
for (var bath : bathroomPlans(parcel)) {
|
||||
int targetZ = bath.southDoor() ? bath.z1() - 1 : bath.z0() + 1;
|
||||
add(result, parcel, bath.name(), bath.doorX(), bath.floor() * STOREY + 1, targetZ);
|
||||
}
|
||||
add(result, parcel, "roof", 8, parcel.floors() * STOREY + 1, 8);
|
||||
switch (parcel.kind()) {
|
||||
case HOTEL -> add(result, parcel, "roof_laundry_pavilion", 10, parcel.floors() * STOREY + 1, 5);
|
||||
case APARTMENT -> add(result, parcel, "roof_residents_terrace", 8, parcel.floors() * STOREY + 1, 10);
|
||||
case LIBRARY -> add(result, parcel, "roof_reading_terrace", 11, parcel.floors() * STOREY + 1, 10);
|
||||
case RESTAURANT -> add(result, parcel, "roof_dining_terrace", 11, parcel.floors() * STOREY + 1, 8);
|
||||
case BARRACKS -> add(result, parcel, "roof_service_stair", 4, parcel.floors() * STOREY + 1, 8);
|
||||
default -> { }
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private record BathPlan(String name, int floor, int x0, int z0, int x1, int z1, int doorX, boolean southDoor) {}
|
||||
|
||||
private static List<BathPlan> bathroomPlans(Parcel p) {
|
||||
List<BathPlan> result = new ArrayList<>();
|
||||
for (int floor = 0; floor < p.floors(); floor++) {
|
||||
if (p.kind() == Kind.HOTEL && floor > 0)
|
||||
result.add(new BathPlan("floor_" + floor + "/suite_bathroom", floor, 10, 1, 15, 4, 12, true));
|
||||
if (p.kind() == Kind.HALL && floor < 2) continue;
|
||||
for (boolean east : new boolean[]{false, true}) {
|
||||
String use = rearUse(p, floor, east);
|
||||
if (!use.equals("bathroom") && !use.equals("washrooms")) continue;
|
||||
Area room = rear(p, floor, east);
|
||||
result.add(new BathPlan("floor_" + floor + "/" + (east ? "east" : "west") + "_bathroom_interior",
|
||||
floor, room.left() - 1, 12, room.right() + 1, 15, east ? 12 : 4, false));
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
public static List<Bathroom> bathrooms(Parcel p) {
|
||||
validate(p);
|
||||
List<Bathroom> result = new ArrayList<>();
|
||||
for (var b : bathroomPlans(p)) {
|
||||
int x0 = rx(b.x0(), b.z0(), p.facing()), z0 = rz(b.x0(), b.z0(), p.facing());
|
||||
int x1 = rx(b.x1(), b.z1(), p.facing()), z1 = rz(b.x1(), b.z1(), p.facing());
|
||||
int doorZ = b.southDoor() ? b.z1() : b.z0();
|
||||
result.add(new Bathroom(b.name(), Math.min(x0, x1), b.floor() * STOREY, Math.min(z0, z1),
|
||||
Math.max(x0, x1), b.floor() * STOREY + 4, Math.max(z0, z1), rx(b.doorX(), doorZ, p.facing()),
|
||||
b.floor() * STOREY + 1, rz(b.doorX(), doorZ, p.facing())));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static void validate(Parcel p) {
|
||||
if (!p.kind().building() || p.width() != 17 || p.depth() != 17 || p.floors() < 2)
|
||||
throw new IllegalArgumentException("Generation-21 interiors require a 17x17 building with 2..5 floors");
|
||||
}
|
||||
|
||||
private static void add(List<Room> result, Parcel p, String name, int x, int y, int z) {
|
||||
result.add(new Room(name, rx(x, z, p.facing()), y, rz(x, z, p.facing())));
|
||||
}
|
||||
private static int rx(int x, int z, int facing) {
|
||||
return switch (facing) { case 0 -> x; case 1 -> 16 - z; case 2 -> 16 - x; default -> z; };
|
||||
}
|
||||
private static int rz(int x, int z, int facing) {
|
||||
return switch (facing) { case 0 -> z; case 1 -> x; case 2 -> 16 - z; default -> 16 - x; };
|
||||
}
|
||||
|
||||
private static final class Building {
|
||||
final Parcel p;
|
||||
final LostCityRenderer.BlockWriter output;
|
||||
final Rotation rotation;
|
||||
Building(Parcel p, LostCityRenderer.BlockWriter output) {
|
||||
this.p = p;
|
||||
this.output = output;
|
||||
rotation = switch (p.facing()) {
|
||||
case 0 -> Rotation.NONE; case 1 -> Rotation.CLOCKWISE_90;
|
||||
case 2 -> Rotation.CLOCKWISE_180; default -> Rotation.COUNTERCLOCKWISE_90;
|
||||
};
|
||||
}
|
||||
void set(int x, int y, int z, BlockState state) {
|
||||
output.set(rx(x, z, p.facing()), y, rz(x, z, p.facing()), state.rotate(rotation));
|
||||
}
|
||||
void set(int x, int y, int z, Block block) { set(x, y, z, block.defaultBlockState()); }
|
||||
void box(int x0, int y0, int z0, int x1, int y1, int z1, BlockState state) {
|
||||
for (int x = x0; x <= x1; x++) for (int z = z0; z <= z1; z++)
|
||||
for (int y = y0; y <= y1; y++) set(x, y, z, state);
|
||||
}
|
||||
BlockState facing(Block block, Direction direction) {
|
||||
return block.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, direction);
|
||||
}
|
||||
BlockState brick(int x, int y, int z) { return LostCityPalette.masonry(p.seed(), x, y, z); }
|
||||
BlockState weatheredBrick(int x, int y, int z) {
|
||||
// Weathering follows a few masonry joints, never private room partitions.
|
||||
int weather = roll(x, y, z, 12);
|
||||
return (weather == 0 ? Blocks.MOSSY_STONE_BRICKS : weather < 3
|
||||
? Blocks.CRACKED_STONE_BRICKS : Blocks.STONE_BRICKS).defaultBlockState();
|
||||
}
|
||||
int roll(int x, int y, int z, int bound) { return LostCityPalette.roll(p.seed(), x, y, z, bound); }
|
||||
|
||||
void render() {
|
||||
int roof = p.floors() * STOREY;
|
||||
box(0, -p.foundationDepth(), 0, 16, -1, 16, Blocks.STONE_BRICKS.defaultBlockState());
|
||||
box(0, 0, 0, 16, 0, 16, SMOOTH);
|
||||
box(0, 1, 0, 16, roof + 4, 16, AIR);
|
||||
shell(roof);
|
||||
architecturalEnvelope(roof);
|
||||
for (int floor = 0; floor < p.floors(); floor++) {
|
||||
partitions(floor);
|
||||
furnishings(floor);
|
||||
}
|
||||
stairs();
|
||||
entrance();
|
||||
roof(roof);
|
||||
// Only one deferred loot table per building; empty furniture is not a resource vault.
|
||||
int lootX = 6;
|
||||
int lootZ = 6;
|
||||
box(lootX, 2, lootZ, lootX, 3, lootZ, AIR);
|
||||
ResourceKey<LootTable> table = switch (p.kind()) {
|
||||
case LIBRARY, HALL -> BuiltInLootTables.VILLAGE_CARTOGRAPHER;
|
||||
case RESTAURANT -> BuiltInLootTables.VILLAGE_BUTCHER;
|
||||
case BARRACKS -> BuiltInLootTables.VILLAGE_FLETCHER;
|
||||
default -> BuiltInLootTables.VILLAGE_PLAINS_HOUSE;
|
||||
};
|
||||
output.chest(rx(lootX, lootZ, p.facing()), 1, rz(lootX, lootZ, p.facing()), table,
|
||||
p.seed() ^ 0x21c17a7eL ^ p.kind().ordinal());
|
||||
}
|
||||
|
||||
void shell(int roof) {
|
||||
for (int floor = 0; floor <= p.floors(); floor++) {
|
||||
int base = floor * STOREY;
|
||||
for (int x = 1; x <= 15; x++) for (int z = 1; z <= 15; z++)
|
||||
set(x, base, z, floor == p.floors() ? SMOOTH : floorMaterial(floor, x, z));
|
||||
if (floor == p.floors()) continue;
|
||||
// A pale ceiling and contrasting structural floor are separate layers.
|
||||
box(2, base + 4, 2, 14, base + 4, 14,
|
||||
Blocks.SMOOTH_STONE_SLAB.defaultBlockState().setValue(BlockStateProperties.SLAB_TYPE, SlabType.TOP));
|
||||
for (int y = 1; y <= 4; y++) for (int horizontal = 1; horizontal <= 15; horizontal++) {
|
||||
facade(horizontal, base + y, 1, horizontal, y, true);
|
||||
facade(horizontal, base + y, 15, horizontal, y, true);
|
||||
facade(1, base + y, horizontal, horizontal, y, false);
|
||||
facade(15, base + y, horizontal, horizontal, y, false);
|
||||
}
|
||||
// Floor bands remain intact even where windows have fallen out.
|
||||
for (int n = 1; n <= 15; n++) {
|
||||
set(n, base, 1, SMOOTH); set(n, base, 15, SMOOTH);
|
||||
set(1, base, n, SMOOTH); set(15, base, n, SMOOTH);
|
||||
}
|
||||
if (p.kind() == Kind.HOTEL || p.kind() == Kind.LIBRARY) {
|
||||
for (int x : new int[]{3, 13}) for (int y = 0; y <= 4; y++) set(x, base + y, 0, SMOOTH);
|
||||
}
|
||||
if (p.kind() == Kind.APARTMENT) for (int x = 11; x <= 14; x++)
|
||||
set(x, base + 1, 0, Blocks.SMOOTH_STONE_SLAB);
|
||||
if (p.kind() == Kind.RESTAURANT) box(10, base + 4, 0, 14, base + 4, 0,
|
||||
Blocks.OAK_SLAB.defaultBlockState());
|
||||
}
|
||||
if (p.kind() == Kind.HALL) {
|
||||
// The auditorium spans two storeys; its upper level is a guarded gallery.
|
||||
box(3, 4, 11, 13, 5, 14, AIR);
|
||||
for (int x = 3; x <= 13; x++) set(x, 6, 11, rail(true));
|
||||
for (int z = 12; z <= 14; z++) { set(3, 6, z, rail(false)); set(13, 6, z, rail(false)); }
|
||||
}
|
||||
}
|
||||
|
||||
BlockState floorMaterial(int floor, int x, int z) {
|
||||
if (x == 1 || z == 1 || x == 15 || z == 15) return SMOOTH;
|
||||
return switch (p.kind()) {
|
||||
case LIBRARY -> (x <= 5 || z >= 10 ? Blocks.OAK_PLANKS : Blocks.SMOOTH_STONE).defaultBlockState();
|
||||
case APARTMENT, HOTEL -> WOOD;
|
||||
case RESTAURANT -> ((x / 3 + z / 3) & 1) == 0 ? SMOOTH : TILE;
|
||||
case BARRACKS -> x <= 5 ? TILE : WOOD;
|
||||
default -> x == 7 || x == 9 ? TILE : SMOOTH;
|
||||
};
|
||||
}
|
||||
|
||||
BlockState pier() {
|
||||
return switch (p.kind()) {
|
||||
case APARTMENT, LIBRARY -> Blocks.STRIPPED_OAK_LOG.defaultBlockState();
|
||||
case HOTEL, RESTAURANT -> SMOOTH;
|
||||
case BARRACKS -> Blocks.STONE_BRICKS.defaultBlockState();
|
||||
default -> Blocks.CHISELED_STONE_BRICKS.defaultBlockState();
|
||||
};
|
||||
}
|
||||
|
||||
void facade(int x, int y, int z, int horizontal, int withinFloor, boolean eastWest) {
|
||||
boolean column = horizontal == 1 || horizontal == 6 || horizontal == 10 || horizontal == 15;
|
||||
boolean window = !column && (withinFloor == 2 || withinFloor == 3
|
||||
|| withinFloor == 1 && (p.kind() == Kind.LIBRARY || p.kind() == Kind.RESTAURANT || p.kind() == Kind.HALL));
|
||||
if (column) set(x, y, z, pier());
|
||||
else if (window) {
|
||||
if (roll(x, y / 5, z, 17) != 0) set(x, y, z,
|
||||
p.kind() == Kind.BARRACKS ? rail(eastWest) : pane(eastWest));
|
||||
} else set(x, y, z, withinFloor == 4 && horizontal % 5 == 0
|
||||
? weatheredBrick(x, y, z) : SMOOTH);
|
||||
}
|
||||
|
||||
/** Volume belongs to its use: projected bays, clipped corners and distinct roof profiles. */
|
||||
void architecturalEnvelope(int roof) {
|
||||
for (int floor = 0; floor < p.floors(); floor++) {
|
||||
int y = floor * STOREY;
|
||||
// Project the floor plates as a continuous, one-block cornice inside the parcel.
|
||||
for (int n = 1; n <= 15; n++) {
|
||||
set(n, y, 0, stairTrim(Direction.NORTH)); set(n, y, 16, stairTrim(Direction.SOUTH));
|
||||
set(0, y, n, stairTrim(Direction.WEST)); set(16, y, n, stairTrim(Direction.EAST));
|
||||
}
|
||||
switch (p.kind()) {
|
||||
case APARTMENT -> {
|
||||
// Bay windows increase the living room rather than drawing flat stripes on it.
|
||||
for (int z : new int[]{4, 5, 6, 11, 12, 13}) {
|
||||
set(16, y, z, Blocks.OAK_PLANKS);
|
||||
set(16, y + 1, z, Blocks.OAK_PLANKS);
|
||||
set(16, y + 2, z, Blocks.GLASS_PANE);
|
||||
set(16, y + 3, z, Blocks.GLASS_PANE);
|
||||
set(16, y + 4, z, Blocks.OAK_SLAB.defaultBlockState().setValue(BlockStateProperties.SLAB_TYPE, SlabType.TOP));
|
||||
}
|
||||
for (int z : new int[]{3, 7, 10, 14}) box(16, y + 1, z, 16, y + 4, z, pier());
|
||||
// Original wall remains the sill, with an open bay above it; privacy walls restore themselves later.
|
||||
for (int z : new int[]{4, 5, 6, 11, 12, 13}) box(15, y + 2, z, 15, y + 3, z, AIR);
|
||||
bevelFront(y);
|
||||
}
|
||||
case HOTEL -> {
|
||||
for (int x : new int[]{2, 5, 11, 14}) box(x, y + 1, 0, x, y + 4, 0, pier());
|
||||
for (int x : new int[]{3, 4, 12, 13}) set(x, y + 1, 0, Blocks.OAK_FENCE);
|
||||
box(2, y + 4, 0, 5, y + 4, 0, Blocks.OAK_PLANKS.defaultBlockState());
|
||||
box(11, y + 4, 0, 14, y + 4, 0, Blocks.OAK_PLANKS.defaultBlockState());
|
||||
}
|
||||
case LIBRARY -> {
|
||||
// Tall fins frame broad glazing and project beyond the reading rooms.
|
||||
for (int x : new int[]{2, 5, 11, 14}) box(x, y + 1, 0, x, y + 4, 0, Blocks.STRIPPED_OAK_LOG.defaultBlockState());
|
||||
for (int x : new int[]{3, 4, 12, 13}) {
|
||||
set(x, y + 4, 0, Blocks.OAK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.SOUTH));
|
||||
}
|
||||
bevelFront(y);
|
||||
}
|
||||
case RESTAURANT -> {
|
||||
// Separate fascia and timber valance shelter the shop windows.
|
||||
for (int x = 10; x <= 14; x++) {
|
||||
set(x, y + 3, 0, Blocks.OAK_SLAB);
|
||||
set(x, y + 4, 0, Blocks.OAK_PLANKS);
|
||||
}
|
||||
for (int z : new int[]{3, 7, 11, 14}) set(16, y + 4, z, Blocks.ANDESITE);
|
||||
}
|
||||
case BARRACKS -> {
|
||||
for (int z : new int[]{2, 7, 12, 15}) box(0, y + 1, z, 0, y + 4, z, pier());
|
||||
for (int x : new int[]{2, 5, 11, 14}) box(x, y + 1, 0, x, y + 4, 0, Blocks.STONE_BRICKS.defaultBlockState());
|
||||
for (int x : new int[]{3, 4, 12, 13}) set(x, y + 1, 0, Blocks.CHISELED_STONE_BRICKS);
|
||||
}
|
||||
case HALL -> {
|
||||
for (int x : new int[]{2, 5, 11, 14}) box(x, y + 1, 0, x, y + 4, 0, pier());
|
||||
for (int x = 2; x <= 14; x++) set(x, y + 4, 0,
|
||||
Blocks.ANDESITE_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.SOUTH));
|
||||
}
|
||||
default -> throw new IllegalStateException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BlockState stairTrim(Direction outward) {
|
||||
Block block = Blocks.STONE_BRICK_STAIRS;
|
||||
return facing(block, outward.getOpposite()).setValue(BlockStateProperties.HALF,
|
||||
net.minecraft.world.level.block.state.properties.Half.TOP);
|
||||
}
|
||||
|
||||
void bevelFront(int y) {
|
||||
// Remove two rectangular corners and close them with actual stepped returns.
|
||||
for (int x : new int[]{1, 15}) {
|
||||
box(x, y, 1, x, y + 4, 1, AIR);
|
||||
int adjacent = x == 1 ? 2 : 14;
|
||||
box(adjacent, y + 1, 1, adjacent, y + 4, 1, pier());
|
||||
box(x, y + 1, 2, x, y + 4, 2, pier());
|
||||
}
|
||||
}
|
||||
|
||||
void partitions(int floor) {
|
||||
int y = floor * STOREY;
|
||||
int front = frontWall(p, floor);
|
||||
// The west stairwell, central corridor and vestibule are independent of furniture.
|
||||
box(5, y + 1, 2, 5, y + 4, 9, SMOOTH);
|
||||
door(5, y + 1, 8, Direction.WEST, false);
|
||||
box(10, y + 1, 2, 10, y + 4, front, SMOOTH);
|
||||
box(10, y + 1, front, 15, y + 4, front, SMOOTH);
|
||||
door(10, y + 1, 6, Direction.EAST, false);
|
||||
if (floor == 0) {
|
||||
box(6, 1, 4, 9, 4, 4, SMOOTH);
|
||||
door(8, 1, 4, Direction.SOUTH, false);
|
||||
}
|
||||
if (p.kind() == Kind.HALL && floor < 2) {
|
||||
if (floor == 0) {
|
||||
box(2, 1, 10, 14, 4, 10, SMOOTH);
|
||||
door(7, 1, 10, Direction.SOUTH, true);
|
||||
door(8, 1, 10, Direction.SOUTH, false);
|
||||
}
|
||||
} else {
|
||||
Area west = rear(p, floor, false), east = rear(p, floor, true);
|
||||
box(1, y + 1, west.front() - 1, west.right() + 1, y + 4, west.front() - 1, SMOOTH);
|
||||
box(west.right() + 1, y + 1, west.front() - 1, west.right() + 1, y + 4, 15, SMOOTH);
|
||||
box(east.left() - 1, y + 1, east.front() - 1, 15, y + 4, east.front() - 1, SMOOTH);
|
||||
box(east.left() - 1, y + 1, east.front() - 1, east.left() - 1, y + 4, 15, SMOOTH);
|
||||
door(west.right() + 1, y + 1, 11, Direction.WEST, false);
|
||||
door(east.left() - 1, y + 1, 11, Direction.EAST, false);
|
||||
}
|
||||
// A hanging lantern touches a full ceiling block, never the recessed underside of a top slab.
|
||||
set(8, y + 4, 8, SMOOTH);
|
||||
set(8, y + 3, 8, Blocks.LANTERN.defaultBlockState().setValue(BlockStateProperties.HANGING, true));
|
||||
// Weathering stays on facades and roofs: it must not replace a privacy partition.
|
||||
}
|
||||
|
||||
void furnishings(int floor) {
|
||||
int y = floor * STOREY;
|
||||
int variant = layout(p, floor);
|
||||
switch (p.kind()) {
|
||||
case APARTMENT -> {
|
||||
if (variant == 0) { kitchenFront(y); bedroom(floor, false); }
|
||||
else { frontBedroom(floor); kitchenRear(floor, false); }
|
||||
}
|
||||
case HOTEL -> {
|
||||
if (floor == 0) { reception(y); lounge(floor, false); laundry(floor); }
|
||||
else {
|
||||
anchoredBed(14, y + 1, frontWall(p, floor) - 2, Direction.SOUTH);
|
||||
set(11, y + 1, 5, Blocks.BARREL); set(11, y + 2, 5, Blocks.FLOWER_POT);
|
||||
if (variant == 0) { bedroom(floor, false); bedroom(floor, true); }
|
||||
else lounge(floor, false);
|
||||
}
|
||||
}
|
||||
case LIBRARY -> {
|
||||
if (variant == 0) {
|
||||
box(14, y + 1, 2, 14, y + 3, 6, Blocks.BOOKSHELF.defaultBlockState());
|
||||
box(12, y + 1, 2, 12, y + 2, 3, Blocks.BOOKSHELF.defaultBlockState());
|
||||
desk(12, y, 5, Direction.SOUTH);
|
||||
readingRoom(floor, false); archive(floor, true);
|
||||
} else {
|
||||
readingFront(y); readingRoom(floor, false);
|
||||
set(2, y + 1, 14, Blocks.CARTOGRAPHY_TABLE);
|
||||
}
|
||||
}
|
||||
case RESTAURANT -> {
|
||||
if (variant == 0) {
|
||||
table(13, y, 3); table(13, y, 5);
|
||||
set(11, y + 1, 2, Blocks.POTTED_DEAD_BUSH);
|
||||
kitchenRear(floor, true);
|
||||
} else {
|
||||
kitchenFront(y); diningRear(floor, false); pantry(floor, true);
|
||||
}
|
||||
}
|
||||
case BARRACKS -> {
|
||||
if (variant == 0) {
|
||||
anchoredBed(12, y + 1, 3, Direction.NORTH); anchoredBed(14, y + 1, 3, Direction.NORTH);
|
||||
set(11, y + 1, 2, Blocks.BARREL); set(11, y + 2, 2, Blocks.BARREL);
|
||||
seat(13, y + 1, 6, Direction.NORTH);
|
||||
} else { reception(y); bedroom(floor, false); }
|
||||
box(12, y + 1, 14, 14, y + 2, 14, Blocks.BARREL.defaultBlockState());
|
||||
set(14, y + 1, 11, Blocks.FLETCHING_TABLE);
|
||||
set(12, y + 1, 11, Blocks.GRINDSTONE.defaultBlockState()
|
||||
.setValue(BlockStateProperties.ATTACH_FACE,
|
||||
net.minecraft.world.level.block.state.properties.AttachFace.FLOOR));
|
||||
}
|
||||
case HALL -> {
|
||||
if (floor == 0 || floor > 1 && variant == 0) reception(y); else readingFront(y);
|
||||
if (floor == 0) auditorium();
|
||||
else if (floor >= 2) {
|
||||
if (variant == 0) readingRoom(floor, false);
|
||||
else { archive(floor, false); readingRoom(floor, true); }
|
||||
}
|
||||
}
|
||||
default -> throw new IllegalArgumentException("Not an interior kind");
|
||||
}
|
||||
for (var bath : bathroomPlans(p)) if (bath.floor() == floor) bathroom(bath);
|
||||
}
|
||||
|
||||
void kitchenFront(int y) {
|
||||
kitchenLine(y, 11, 2, Direction.SOUTH);
|
||||
table(13, y, 5);
|
||||
}
|
||||
|
||||
void kitchenRear(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
kitchenLine(y, room.left(), 14, Direction.NORTH);
|
||||
if (east) {
|
||||
set(14, y + 1, 11, SMOOTH); set(14, y + 2, 11, Blocks.FLOWER_POT);
|
||||
set(12, y + 1, 11, Blocks.CRAFTING_TABLE);
|
||||
} else table(room.left() + 2, y, 11);
|
||||
}
|
||||
|
||||
void kitchenLine(int y, int left, int z, Direction face) {
|
||||
set(left, y + 1, z, facing(Blocks.FURNACE, face));
|
||||
set(left + 1, y + 1, z, Blocks.WATER_CAULDRON.defaultBlockState().setValue(BlockStateProperties.LEVEL_CAULDRON, 2));
|
||||
set(left + 2, y + 1, z, facing(Blocks.SMOKER, face));
|
||||
set(left + 3, y + 1, z, Blocks.BARREL);
|
||||
box(left, y + 3, z, left + 3, y + 3, z, SMOOTH);
|
||||
set(left + 2, y + 2, z, Blocks.IRON_TRAPDOOR);
|
||||
for (int x = left; x <= left + 3; x++) set(x, y + 3, z, Blocks.SMOOTH_STONE);
|
||||
for (int x = left; x <= left + 3; x++) set(x, y, z, (x & 1) == 0 ? SMOOTH : TILE);
|
||||
}
|
||||
|
||||
void bedroom(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
anchoredBed(east ? room.right() - 1 : room.left() + 1, y + 1, 13, east ? Direction.EAST : Direction.WEST);
|
||||
set(east ? room.right() : room.left(), y + 1, 11, Blocks.BARREL);
|
||||
desk(east ? room.left() + 1 : room.right() - 1, y, 11, Direction.SOUTH);
|
||||
set(east ? room.left() : room.right(), y + 1, 14, Blocks.BOOKSHELF);
|
||||
}
|
||||
|
||||
void frontBedroom(int floor) {
|
||||
int y = floor * STOREY;
|
||||
anchoredBed(14, y + 1, 3, Direction.NORTH);
|
||||
set(11, y + 1, 2, Blocks.BARREL); set(11, y + 2, 2, Blocks.FLOWER_POT);
|
||||
desk(12, y, 5, Direction.SOUTH);
|
||||
}
|
||||
|
||||
void bathroom(BathPlan bath) {
|
||||
int y = bath.floor() * STOREY;
|
||||
for (int x = bath.x0(); x <= bath.x1(); x++) for (int z = bath.z0(); z <= bath.z1(); z++) {
|
||||
set(x, y, z, ((x + z) & 1) == 0 ? SMOOTH : TILE);
|
||||
set(x, y + 4, z, SMOOTH);
|
||||
boolean wall = x == bath.x0() || x == bath.x1() || z == bath.z0() || z == bath.z1();
|
||||
for (int up = 1; up <= 3; up++) set(x, y + up, z, wall ? SMOOTH : AIR);
|
||||
}
|
||||
int doorZ = bath.southDoor() ? bath.z1() : bath.z0();
|
||||
door(bath.doorX(), y + 1, doorZ, bath.southDoor() ? Direction.NORTH : Direction.SOUTH, false);
|
||||
int fixtureZ = bath.southDoor() ? bath.z0() + 1 : bath.z1() - 1;
|
||||
int toiletX = bath.southDoor() ? bath.x1() - 2 : bath.x0() + 1;
|
||||
Direction view = bath.southDoor() ? Direction.SOUTH : Direction.EAST;
|
||||
set(toiletX, y + 1, fixtureZ, seating(Blocks.POLISHED_ANDESITE_STAIRS, view));
|
||||
set(toiletX, y + 2, fixtureZ, Blocks.IRON_TRAPDOOR);
|
||||
set(bath.x1() - 1, y + 1, fixtureZ, Blocks.WATER_CAULDRON.defaultBlockState().setValue(BlockStateProperties.LEVEL_CAULDRON, 2));
|
||||
}
|
||||
|
||||
void reception(int y) {
|
||||
box(12, y + 1, 5, 14, y + 1, 5, Blocks.OAK_PLANKS.defaultBlockState());
|
||||
set(13, y + 2, 5, Blocks.FLOWER_POT);
|
||||
set(14, y + 1, 2, Blocks.BOOKSHELF); set(14, y + 2, 2, Blocks.BARREL);
|
||||
desk(12, y, 3, Direction.SOUTH);
|
||||
set(11, y + 1, 2, Blocks.POTTED_DEAD_BUSH);
|
||||
}
|
||||
|
||||
void laundry(int floor) {
|
||||
int y = floor * STOREY;
|
||||
for (int z = 11; z <= 12; z++) set(14, y + 1, z, Blocks.WATER_CAULDRON.defaultBlockState()
|
||||
.setValue(BlockStateProperties.LEVEL_CAULDRON, z == 11 ? 1 : 2));
|
||||
box(12, y + 1, 14, 14, y + 1, 14, Blocks.BARREL.defaultBlockState());
|
||||
// The rack stands on the storage counter; recessed rear glazing cannot carry it.
|
||||
for (int x : new int[]{12, 14}) set(x, y + 2, 14, Blocks.OAK_PLANKS);
|
||||
box(12, y + 3, 14, 14, y + 3, 14, Blocks.OAK_SLAB.defaultBlockState());
|
||||
set(12, y + 1, 11, Blocks.LOOM);
|
||||
}
|
||||
|
||||
void lounge(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
box(room.left(), y + 1, 14, room.left() + 2, y + 1, 14, seating(Blocks.OAK_STAIRS, Direction.NORTH));
|
||||
desk(room.left() + 1, y, 11, Direction.SOUTH);
|
||||
set(room.left(), y + 1, 12, Blocks.BOOKSHELF);
|
||||
}
|
||||
|
||||
void readingRoom(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
desk(east ? room.left() + 1 : room.right() - 1, y, 11, Direction.SOUTH);
|
||||
box(room.left(), y + 1, 14, room.right(), y + 2, 14, Blocks.BOOKSHELF.defaultBlockState());
|
||||
set(east ? room.right() : room.left(), y + 1, 11, facing(Blocks.LECTERN, Direction.NORTH));
|
||||
}
|
||||
|
||||
void readingFront(int y) {
|
||||
desk(12, y, 3, Direction.SOUTH);
|
||||
box(14, y + 1, 2, 14, y + 2, 5, Blocks.BOOKSHELF.defaultBlockState());
|
||||
set(11, y + 1, 2, facing(Blocks.LECTERN, Direction.SOUTH));
|
||||
}
|
||||
|
||||
void archive(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
box(room.left(), y + 1, 14, room.right(), y + 3, 14, Blocks.BOOKSHELF.defaultBlockState());
|
||||
box(east ? room.right() : room.left(), y + 1, 11, east ? room.right() : room.left(), y + 2, 12, Blocks.BARREL.defaultBlockState());
|
||||
set(room.left() + 1, y + 1, 12, facing(Blocks.LECTERN, Direction.NORTH));
|
||||
}
|
||||
|
||||
void pantry(int floor, boolean east) {
|
||||
int y = floor * STOREY;
|
||||
Area room = rear(p, floor, east);
|
||||
box(room.left(), y + 1, 14, room.right(), y + 2, 14, Blocks.BARREL.defaultBlockState());
|
||||
set(room.right(), y + 1, 11, Blocks.CRAFTING_TABLE);
|
||||
set(room.right(), y + 2, 11, Blocks.FLOWER_POT);
|
||||
}
|
||||
|
||||
void diningRear(int floor, boolean east) {
|
||||
Area room = rear(p, floor, east);
|
||||
int y = floor * STOREY;
|
||||
table(room.left() + 2, y, 12);
|
||||
box(room.left(), y + 1, 14, room.right(), y + 1, 14, seating(Blocks.OAK_STAIRS, Direction.NORTH));
|
||||
}
|
||||
|
||||
void auditorium() {
|
||||
for (int x : new int[]{3, 4, 5, 11, 12, 13}) seat(x, 1, 11, Direction.SOUTH);
|
||||
box(3, 1, 13, 13, 1, 14, WOOD);
|
||||
set(8, 1, 12, facing(Blocks.OAK_STAIRS, Direction.SOUTH));
|
||||
set(8, 2, 14, facing(Blocks.LECTERN, Direction.NORTH));
|
||||
for (int x : new int[]{3, 13}) {
|
||||
box(x, 2, 14, x, 3, 14, WOOD);
|
||||
set(x, 4, 14, Blocks.STONE_BRICK_SLAB);
|
||||
}
|
||||
}
|
||||
|
||||
void stairs() {
|
||||
for (int floor = 0; floor < p.floors(); floor++) {
|
||||
int base = floor * STOREY;
|
||||
for (int step = 1; step <= STOREY; step++) {
|
||||
int z = step + 2;
|
||||
box(2, base + step, z, 3, base + step + 3, z, AIR);
|
||||
for (int x = 2; x <= 3; x++) {
|
||||
// A one-block inclined spine joins successive treads physically, without
|
||||
// filling the headroom above the preceding storey's matching flight.
|
||||
set(x, base + step - 1, z, Blocks.STONE_BRICKS);
|
||||
set(x, base + step, z, facing(Blocks.STONE_BRICK_STAIRS, Direction.SOUTH));
|
||||
}
|
||||
}
|
||||
box(2, base + STOREY, 8, 4, base + STOREY, 9, TILE);
|
||||
box(2, base + STOREY + 1, 8, 4, base + STOREY + 3, 9, AIR);
|
||||
}
|
||||
// This full-height side aisle joins every lower stair approach to its landing door.
|
||||
for (int floor = 0; floor <= p.floors(); floor++) {
|
||||
int y = floor * STOREY;
|
||||
box(4, y + 1, 2, 4, y + 3, 9, AIR);
|
||||
}
|
||||
}
|
||||
|
||||
void entrance() {
|
||||
for (int x : new int[]{6, 9}) box(x, 1, 0, x, 3, 0, SMOOTH);
|
||||
box(6, 4, 0, 9, 4, 1, Blocks.STONE_BRICK_SLAB.defaultBlockState());
|
||||
box(7, 0, 0, 8, 0, 3, TILE);
|
||||
// Native SOUTH/RIGHT opens against the west jamb; SOUTH/LEFT against the east jamb.
|
||||
door(7, 1, 1, Direction.SOUTH, true);
|
||||
door(8, 1, 1, Direction.SOUTH, false);
|
||||
set(4, 1, 0, Blocks.POTTED_DEAD_BUSH);
|
||||
set(12, 1, 16, Blocks.COMPOSTER);
|
||||
}
|
||||
|
||||
void roof(int y) {
|
||||
// Clipped parapets and a heavy coping keep each accessible roof legible from below.
|
||||
for (int n = 2; n <= 14; n++) {
|
||||
set(n, y + 1, 1, Blocks.STONE_BRICKS); set(n, y + 1, 15, Blocks.STONE_BRICKS);
|
||||
set(1, y + 1, n, Blocks.STONE_BRICKS); set(15, y + 1, n, Blocks.STONE_BRICKS);
|
||||
set(n, y + 2, 1, Blocks.STONE_BRICK_SLAB); set(n, y + 2, 15, Blocks.STONE_BRICK_SLAB);
|
||||
set(1, y + 2, n, Blocks.STONE_BRICK_SLAB); set(15, y + 2, n, Blocks.STONE_BRICK_SLAB);
|
||||
}
|
||||
switch (p.kind()) {
|
||||
case APARTMENT -> {
|
||||
// Two small roof gardens and a timber portico leave the stair landing and central crossing free.
|
||||
roofPlanter(10, y, 4); roofPlanter(10, y, 12);
|
||||
for (int x : new int[]{6, 13}) box(x, y + 1, 14, x, y + 3, 14, Blocks.STRIPPED_OAK_LOG.defaultBlockState());
|
||||
box(6, y + 4, 14, 13, y + 4, 14, Blocks.OAK_SLAB.defaultBlockState());
|
||||
desk(8, y, 12, Direction.NORTH);
|
||||
}
|
||||
case HOTEL -> {
|
||||
roofRoom(y, 9, 3, 14, 7, Blocks.SMOOTH_STONE);
|
||||
door(9, y + 1, 5, Direction.EAST, false);
|
||||
set(12, y + 1, 4, Blocks.BARREL); set(13, y + 1, 4, Blocks.LOOM);
|
||||
box(11, y + 1, 12, 13, y + 1, 12, seating(Blocks.OAK_STAIRS, Direction.NORTH));
|
||||
}
|
||||
case LIBRARY -> {
|
||||
// One transparent cap above a hollow timber curb, open all the way to the stacks.
|
||||
// No opaque former roof plate or second glass floor remains under this canopy.
|
||||
for (int x = 10; x <= 13; x++) for (int z = 3; z <= 7; z++) {
|
||||
boolean edge = x == 10 || x == 13 || z == 3 || z == 7;
|
||||
set(x, y + 1, z, edge ? Blocks.STRIPPED_OAK_LOG.defaultBlockState() : AIR);
|
||||
set(x, y + 2, z, edge ? Blocks.OAK_SLAB : Blocks.GLASS);
|
||||
}
|
||||
box(11, y - 1, 4, 12, y + 1, 6, AIR);
|
||||
desk(11, y, 12, Direction.NORTH); set(14, y + 1, 12, Blocks.LECTERN);
|
||||
}
|
||||
case RESTAURANT -> {
|
||||
// A real dining terrace and rear kitchen extraction with full supporting columns.
|
||||
table(11, y, 6); table(11, y, 11);
|
||||
for (int z : new int[]{3, 14}) box(14, y + 1, z, 14, y + 3, z, Blocks.STRIPPED_OAK_LOG.defaultBlockState());
|
||||
box(14, y + 4, 3, 14, y + 4, 14, Blocks.OAK_PLANKS.defaultBlockState());
|
||||
box(6, y + 1, 14, 7, y + 3, 14, Blocks.STONE_BRICKS.defaultBlockState());
|
||||
set(6, y + 4, 14, Blocks.IRON_BARS); set(7, y + 4, 14, Blocks.IRON_BARS);
|
||||
}
|
||||
case BARRACKS -> {
|
||||
// Enclosed stair head gives the barracks a service tower, without hiding its usable exit.
|
||||
// The whole last flight sits inside the stair head: a front wall at z=6
|
||||
// would cross the headroom over its penultimate step.
|
||||
roofRoom(y, 1, 2, 5, 10, Blocks.SMOOTH_STONE);
|
||||
door(5, y + 1, 8, Direction.EAST, false);
|
||||
box(10, y + 1, 12, 13, y + 1, 14, Blocks.POLISHED_ANDESITE.defaultBlockState());
|
||||
set(11, y + 2, 13, Blocks.IRON_TRAPDOOR);
|
||||
}
|
||||
case HALL -> {
|
||||
for (int step = 0; step < 3; step++) {
|
||||
int edge = 2 + step * 2;
|
||||
box(edge, y + 1 + step, 1, 16 - edge, y + 1 + step, 2, Blocks.STONE_BRICKS.defaultBlockState());
|
||||
}
|
||||
for (int x : new int[]{6, 10}) set(x, y + 4, 2, Blocks.CHISELED_STONE_BRICKS);
|
||||
desk(11, y, 12, Direction.NORTH);
|
||||
}
|
||||
default -> throw new IllegalStateException();
|
||||
}
|
||||
}
|
||||
|
||||
void roofRoom(int y, int x0, int z0, int x1, int z1, Block wall) {
|
||||
for (int x = x0; x <= x1; x++) for (int z = z0; z <= z1; z++) {
|
||||
boolean edge = x == x0 || x == x1 || z == z0 || z == z1;
|
||||
for (int up = 1; up <= 3; up++) set(x, y + up, z, edge ? wall.defaultBlockState() : AIR);
|
||||
// A bottom slab touches the full-height walls immediately below it.
|
||||
// A top slab here would float half a block above the pavilion.
|
||||
set(x, y + 4, z, Blocks.STONE_BRICK_SLAB.defaultBlockState());
|
||||
}
|
||||
}
|
||||
void roofPlanter(int x, int y, int z) {
|
||||
for (int dx = 0; dx <= 3; dx++) set(x + dx, y + 1, z, Blocks.STONE_BRICKS);
|
||||
set(x + 1, y + 1, z, Blocks.DIRT); set(x + 2, y + 1, z, Blocks.DIRT);
|
||||
set(x + 1, y + 2, z, Blocks.OXEYE_DAISY); set(x + 2, y + 2, z, Blocks.CORNFLOWER);
|
||||
}
|
||||
|
||||
void door(int x, int y, int z, Direction facing, boolean rightHinge) {
|
||||
BlockState state = facing(Blocks.OAK_DOOR, facing)
|
||||
.setValue(BlockStateProperties.DOOR_HINGE, rightHinge ? DoorHingeSide.RIGHT : DoorHingeSide.LEFT)
|
||||
.setValue(BlockStateProperties.OPEN, false)
|
||||
.setValue(BlockStateProperties.POWERED, false);
|
||||
set(x, y, z, state.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.LOWER));
|
||||
set(x, y + 1, z, state.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.UPPER));
|
||||
}
|
||||
void anchoredBed(int x, int y, int z, Direction facing) {
|
||||
BlockState state = facing(Blocks.BED.white(), facing);
|
||||
set(x, y, z, state.setValue(BlockStateProperties.BED_PART, BedPart.FOOT));
|
||||
set(x + facing.getStepX(), y, z + facing.getStepZ(), state.setValue(BlockStateProperties.BED_PART, BedPart.HEAD));
|
||||
int wallX = x + facing.getStepX() * 2, wallZ = z + facing.getStepZ() * 2;
|
||||
Direction alongWall = facing.getClockWise();
|
||||
for (int width = -1; width <= 1; width++) for (int up = 0; up <= 3; up++)
|
||||
set(wallX + alongWall.getStepX() * width, y + up, wallZ + alongWall.getStepZ() * width, SMOOTH);
|
||||
}
|
||||
void table(int x, int y, int z) {
|
||||
set(x, y + 1, z, Blocks.OAK_FENCE); set(x, y + 2, z, Blocks.OAK_PRESSURE_PLATE);
|
||||
seat(x - 1, y + 1, z, Direction.EAST);
|
||||
seat(x + 1, y + 1, z, Direction.WEST);
|
||||
}
|
||||
void desk(int x, int y, int z, Direction approachSide) {
|
||||
set(x, y + 1, z, Blocks.OAK_FENCE); set(x, y + 2, z, Blocks.OAK_PRESSURE_PLATE);
|
||||
seat(x + approachSide.getStepX(), y + 1, z + approachSide.getStepZ(), approachSide.getOpposite());
|
||||
}
|
||||
void seat(int x, int y, int z, Direction lookingToward) {
|
||||
set(x, y, z, seating(Blocks.OAK_STAIRS, lookingToward));
|
||||
}
|
||||
BlockState seating(Block stair, Direction lookingToward) {
|
||||
// Stair FACING points uphill, into its full-height back, opposite a seated player's view.
|
||||
return facing(stair, lookingToward.getOpposite());
|
||||
}
|
||||
BlockState rail(boolean eastWest) { return connected(Blocks.IRON_BARS, eastWest); }
|
||||
BlockState pane(boolean eastWest) { return connected(Blocks.GLASS_PANE, eastWest); }
|
||||
BlockState connected(Block block, boolean eastWest) {
|
||||
return block.defaultBlockState().setValue(BlockStateProperties.EAST, eastWest)
|
||||
.setValue(BlockStateProperties.WEST, eastWest).setValue(BlockStateProperties.NORTH, !eastWest)
|
||||
.setValue(BlockStateProperties.SOUTH, !eastWest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user