Compare commits

...
Author SHA1 Message Date
koka 40a86588b5 docs: record alpha.12 packwiz publication and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 08:33:43 +02:00
koka cdfbba0089 feat: add alpha.12 branching expansion laboratory
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 07:07:04 +02:00
koka f816ffc640 Record alpha.11 publication and verified Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 06:19:35 +02:00
koka f30a350240 Add Small and Large islands and accelerate exact world generation
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 06:17:52 +02:00
koka a26b72fabd Record alpha.10 publication and verified Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 04:17:42 +02:00
60 changed files with 5243 additions and 153 deletions
+54
View File
@@ -1,5 +1,59 @@
# Changelog
## 0.1.0-alpha.12 — 2026-09-09
- Nouveau preset Sanctuary — Laboratoire, distinct de Petit/Grand. Outils
opérateur `quick`, `create`, `preview`, `list`, `visit`, `resume` pour tester
les expansions sans blocs de commande ni économie préalable.
- Îles de 64, 128, 256 ou 512 blocs de diamètre nominal ; parent et direction
enregistrés, climat libre parmi douze profils. La boussole climatique reste
un raccourci facultatif ; les branches peuvent composer plusieurs couronnes.
- Nouveaux reliefs hauts : naturel, pics, canyon, volcan. Le relief naturel est
aussi testé sur l’île initiale du laboratoire. Palette volcanique et petites
poches facultatives de fluides, sans ajout de fondations artificielles.
- 34 biomes vanilla déclarés dès le chargement, avec surface et poches souterraines.
Sélection de structures vanilla selon biomes, emprise et fondations : villages,
temples de désert/jungle, cabanes de marais et camps abandonnés.
- Journal serveur v1 atomique, paramètres immuables, reprise des réservations,
refus des chunks existants ou en cours de chargement/déchargement, vide compris.
Aucun changement des anciens générateurs ni migration dune sauvegarde.
Validation locale : `check build assemblePack`, douze tests moteur Grand de
graine 0, smokes de journal/relief, expansions et second processus de rechargement
sur la graine 42 avec camp et butin natifs. Les essais restent sans client ni
joueurs connectés. Release, MRpack et canal packwiz publiés ; la même instance
Prism est synchronisée deux fois avec 466 fichiers personnels et réglages
suivis conservés. Recherche collective,
boîtes postales, Void, hydrologie étendue et autres structures restent à construire.
Voir [le contrat du laboratoire](docs/alpha12-laboratory.md).
## 0.1.0-alpha.11 — 2026-09-09
- Deux choix à la création : **Petit** (512 blocs de diamètre nominal)
et **Grand** (environ 724 blocs), Grand par défaut. Leur aire nominale varie
dun facteur deux, sur une hauteur de 384 blocs. Les repères internes 5/10
ne désignent pas un nombre garanti de joueurs.
- Nouveau réglage `sanctuary:population_10` pour Grand. Les anciens formats
20/100 disparaissent des choix publics, mais restent chargeables avec leurs
identifiants et leur relief enregistrés. Aucune île existante nest réduite.
- Cache des coins dinterpolation conservant larithmétique exacte du moteur,
borné à 192 Kio par thread. Les plans régionaux déjà calculés restent lisibles
pendant le calcul dune nouvelle région ; le sampler est compilé une seule
fois par plan.
- À géométrie 20 identique, création et 289 chunks : 407,43 → 62,54 s
(84,65 %). Exploration de 80 chunks : 103,14 → 14,05 s. Le nouveau Grand
atteint 62,67 s sur le protocole de départ. Une seule mesure par version,
graine 0, M1/8 Gio : ce sont des temps serveur jusqu’à FULL, pas des FPS
ni une garantie de délai en jeu.
Validation : `check build assemblePack`, douze tests moteur pour Petit et
Grand, 1 800 ticks de fluides, persistance des quatre références et
3 183 804 comparaisons bit à bit de densités réelles, plus 122 528 synthétiques.
Les sources historiques de forme et les anciens réglages sont conservés.
Voir [Validation](docs/testing.md) et [Distribution](docs/packwiz.md).
Le canal stable est publié et la même instance Prism est synchronisée deux fois,
avec les 364 fichiers personnels et réglages suivis conservés.
## 0.1.0-alpha.10 — 2026-09-09
- Trois tailles de départ : **Sanctuary (20 joueurs)** par défaut, plus les
+81 -8
View File
@@ -14,8 +14,9 @@ infrastructures qui permettront progressivement d'ouvrir de nouveaux continents.
Ce dépôt démarre la nouvelle base Fabric. La [vision complète](docs/vision.md)
conserve les intentions ; le [backlog](docs/backlog.md) prépare les premiers
tickets. Les systèmes d'expansion, d'économie, de progression et les dimensions
décrits dans la vision ne sont pas encore implémentés.
tickets. Les systèmes déconomie, de progression collective et les dimensions
décrits dans la vision ne sont pas encore implémentés. Lalpha.12 fournit des
outils dexpansion réservés à un nouveau monde de laboratoire.
## Socle livré jusqu’à lalpha.8
@@ -86,9 +87,9 @@ anciennes sauvegardes alpha.5. Aucun lac, groupe de terrasses, geyser ou stock
de ressources nest garanti sur chaque graine. Voir le ticket local
[WG-10](docs/backlog.md).
## Alpha.10 : îles pour 5, 20 ou 100 joueurs
## Alpha.10, historique : îles pour 5, 20 ou 100 joueurs
Trois tailles de départ sont disponibles, avec **20 joueurs par défaut**.
Lalpha.10 proposait trois tailles, avec **20 joueurs par défaut**.
Leur aire nominale est proportionnelle à la capacité choisie, sur la base
de l’île de 512 blocs pour cinq joueurs. La hauteur reste de 384 blocs.
@@ -115,8 +116,10 @@ environ 8388 secondes pour les premiers essais 5/20 sur cette configuration.
La génération en masse reste à optimiser. Ces essais ne simulent pas
100 connexions et ne donnent aucune garantie de performances multijoueurs.
Les diamètres nominaux ne permettent pas dextrapoler les stocks ou laire
réellement habitable. Voir [WG-11](docs/backlog.md), [Validation](docs/testing.md)
et l’état de [Distribution](docs/packwiz.md).
réellement habitable. La version est publiée et la même instance Prism a été
synchronisée deux fois, avec 363 fichiers personnels et réglages suivis conservés.
Voir [WG-11](docs/backlog.md), [Validation](docs/testing.md) et
[Distribution](docs/packwiz.md).
Le premier champ de recherche climatique est défini : nord froid, sud chaud,
ouest sec et est humide, avec leurs combinaisons diagonales. Un outil de
@@ -126,6 +129,76 @@ 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
Les anciens formats sont trop grands et éloignent les constructions. Le
nouveau choix de départ se limite à **Petit** et **Grand**, avec **Grand par
défaut**. Petit garde un diamètre nominal de 512 blocs ; Grand mesure environ
724 blocs, soit deux fois laire nominale de Petit. La hauteur reste de
384 blocs. Les repères internes 5 et 10 servent au calcul de surface : ils
ne sont ni les noms des choix ni une promesse de capacité multijoueur.
Grand utilise le nouveau réglage `sanctuary:population_10`. Les anciennes
parties `population_5`, `population_20` et `population_100` gardent leurs
paramètres ; les formats 20/100 sont retirés des choix publics de création,
sans réduire les mondes déjà enregistrés. Aucun monde personnel nest ouvert
pour les essais.
Le ticket [WG-12](docs/backlog.md) réduit les calculs répétés de terrain et
permet de lire un plan régional déjà prêt pendant le calcul du suivant.
À géométrie 20 identique, la création puis 289 chunks autour du spawn passent
de 407,43 à 62,54 secondes (84,65 %) ; 80 chunks dexploration passent de
103,14 à 14,05 secondes. Le nouveau Grand atteint 62,67 secondes sur le même
protocole de départ. Ces essais serveur sur une graine et une machine ne
mesurent pas le rendu du client ni une charge multijoueur.
`check build assemblePack` passe, ainsi que douze tests moteur pour chacun
des deux choix, avec 1 800 ticks de fluides. Plus de trois millions de
comparaisons de densité confirment l’équivalence de calcul. Les mesures et
limites sont dans [Validation](docs/testing.md) ; publication et synchronisation
de la même instance sont consignées dans [Distribution](docs/packwiz.md).
Lalpha.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 dexpansion
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
Grand gardent leur génération alpha.11. Le laboratoire possède sa propre île
initiale de 512 blocs, avec un relief supérieur légèrement déformé et des biomes
vanilla. Les nouvelles îles choisissent séparément leur parent, direction,
climat, diamètre nominal (64/128/256/512) et relief (naturel, pics, canyon, volcan).
Avec les commandes autorisées ou le statut opérateur :
```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
```
Le premier exemple ouvre une forêt automnale au nord ; le second continent
part vers lest depuis cette île. `quick north` seul emploie la convention froide,
avec 128 blocs et un relief naturel. `create` permet de choisir explicitement un
identifiant et un parent. `preview` propose lemplacement sans lactiver ;
`resume <id>` reprend une réservation interrompue. Lautocomplétion liste les
directions, douze profils climatiques et quatre reliefs.
Les anciens chunks, même vides, sont refusés dans lemprise dune nouvelle île.
Le registre persiste côté serveur ; la préparation est progressive et la visite
attend un terrain prêt. Villages, temples, cabanes de marais et camps abandonnés
vanilla peuvent être retenus selon leur biome et leurs fondations. Les autres
structures, la grande hydrologie des continents, les recherches, les boîtes de
dépôt et le refuge Void restent des étapes suivantes. Un biome humide ne promet
pas encore un lagon ou un océan. Les règles, limites et essais figurent dans le
[guide du laboratoire](docs/alpha12-laboratory.md).
`check build assemblePack` et les essais dexpansion/rechargement de graine 42
passent. Lalpha.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).
## Versions et prérequis
Au 8 septembre 2026, la cible disponible est **26.3-pre-2**. Le dépôt ne prétend
@@ -140,7 +213,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.10 |
| Sanctuary / pack | 0.1.0-alpha.12, publiée et synchronisée |
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
@@ -167,7 +240,7 @@ décrits dans [Validation](docs/testing.md).
Résultats :
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.10.jar` : mod à installer avec
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.12.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).
+166
View File
@@ -0,0 +1,166 @@
# Alpha.12 — laboratoire dexpansion
Ticket local **WG-13**, branche `codex/expansion-lab`. Contrat du laboratoire et guide
dessai. Cette livraison reste une alpha ; le canal distribué est suivi séparément
dans [Distribution](packwiz.md).
## Décisions de conception
La direction géographique et le climat deviennent deux choix distincts. La
boussole nord froid / sud chaud / ouest sec / est humide reste un raccourci
facultatif. Une île peut prolonger le climat de son parent ou ouvrir une branche
vers un autre climat. Les parents et les coordonnées forment un graphe persistant,
permettant plusieurs couronnes autour de Sanctuary. Les recherches, leurs coûts,
les boîtes aux lettres et les boîtes de dépôt sont planifiés pour plus tard.
Cette alpha fournit des commandes opérateur : créer, suivre, lister et visiter
les expansions. Les tailles nominales des nouvelles îles sont 64, 128, 256 et
512 blocs. Les reliefs de laboratoire sappliquent aussi à l’île initiale, dans
un nouveau preset expérimental. Les structures vanilla compatibles sont admises
après contrôle de leur emprise et de leur support. Une absence reste possible.
Lalpha sert à éprouver les outils et les paysages. Le passage en bêta se fera
après définition du périmètre de la vision et de la progression jouable ; ce
numéro ne désigne pas une progression collective déjà livrée.
## Contrat de sauvegarde et douverture v1
- Activation exclusivement dans un **nouveau monde du laboratoire alpha.12**.
Les anciens presets, leurs paramètres, leurs chunks et leurs sauvegardes ne
sont pas convertis. Il nexiste pas de commande de migration ou de régénération.
- Un journal serveur propre au monde enregistre graine, version, origine,
identifiant, parent, direction, climat, relief, diamètre, centre et statut.
Les paramètres dune île sont immuables après réservation.
- Toute la réservation inclut terrain, décorations et halo moteur. Les régions
déjà présentes sur disque, chargées ou en cours de déchargement sont refusées,
même si leurs blocs sont tous vides. Une erreur de lecture interdit louverture.
- Le journal est écrit atomiquement et relu avant de publier le nouveau plan au
générateur. Un journal existant absent ou corrompu nest jamais remis à zéro.
- La génération travaille uniquement dans la région nouvelle. Les chunks voisins
nécessaires au moteur restent dans son halo. Aucun bloc dune ancienne région
nest remplacé. Les références et le placement des structures utilisent le moteur.
- Une réservation interrompue conserve son identité et sa graine. La préparation
peut reprendre, sans double île ni nouveau tirage, jusquau statut prêt.
- Les commandes exigent la permission opérateur. Être en créatif seul ne confère
aucun droit sur le serveur. Une opération ne téléporte personne implicitement.
- Le refuge Void et le déplacement collectif des joueurs restent un système
distinct à concevoir. Ils ne servent pas de mécanisme de protection des chunks.
Le halo impose une distance supplémentaire dans cette première version. Le placer
au plus proche ne permet pas de réutiliser silencieusement du vide déjà généré.
Les points dorigine enregistrés ne suivent pas les changements de lit ou de spawn.
## Commandes et palette
Créer un **nouveau monde**, type **Sanctuary — Laboratoire**, avec les commandes
activées. Pour un serveur neuf, utiliser `level-type=sanctuary:expansion_lab`
et un nouveau `level-name`. La graine `42` fournit un cas reproductible observé
avec un camp abandonné ; aucune structure nest garantie sur chaque graine.
```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
/sanctuary expansion preview glacier sanctuary south glacial 256 canyon
```
`quick` part toujours de Sanctuary et attribue un identifiant `island_N`.
`create <id> <parent> <direction> <climate> <diameter> <relief>` ouvre une branche
nommée depuis nimporte quelle île prête. `preview` emploie les mêmes paramètres ;
il propose des coordonnées mais ne valide ni loccupation 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.
| Profil de commande | Surface recherchée |
| --- | --- |
| `temperate` | Chênes, bouleaux, clairières et fleurs |
| `boreal` | Taïga, bosquets et neige en hauteur |
| `glacial` | Plaines enneigées, pics gelés et pointes de glace |
| `cold_humid` | Vieilles taïgas et bosquets froids |
| `humid` | Forêt sombre, marécages et Pale Garden ponctuel |
| `tropical` | Jungle, bambous et mangrove basse |
| `warm` | Savane et plateaux |
| `arid` | Désert, badlands et cheminées érodées |
| `dry` | Prairies exposées et reliefs pierreux |
| `cold_dry` | Neige, gravier et sommets gelés |
| `autumn` | Dappled Forest, prairies et bouleaux ponctuels |
| `cherry` | Cerisiers, prairies et bosquets en hauteur |
La sélection dépend de la graine, de laltitude et de variations locales ; une
petite île naffiche pas nécessairement tous les biomes de son profil. Des poches
luxuriantes, à spéléothèmes ou soufrées diversifient les étages inférieurs.
Les reliefs `natural`, `peaks`, `canyon` et `volcano` sont indépendants du climat.
Le volcan déforme et creuse la matière existante ; il ne garantit pas un cône
plein ni un lac de lave lorsque la géométrie centrale est ajourée.
Les valeurs par défaut de `quick` suivent la boussole décrite dans
[Expansion](expansion.md), avec diamètre 128 et relief naturel. Les directions
acceptées sont `north`, `northeast`, `east`, `southeast`, `south`, `southwest`,
`west`, `northwest`. Lautocomplétion fournit les paramètres disponibles.
## Portée technique et limites
Le codec `sanctuary:expansion_lab` enregistre le générateur ; le journal conserve
les descripteurs de génération **12**. Chaque île emploie sa propre graine dérivée
de la graine du monde et de son identifiant. Changer didentifiant change donc
le tirage. Les instances de bruit sont propres aux îles, y compris après reload.
Les 34 biomes vanilla sont déclarés avant la première expansion.
La densité flottante existante sert de base. Le laboratoire ajoute les
transformations des reliefs hauts et utilise les matériaux et décorations natifs.
Ses carvers vanilla sont désactivés pour conserver les supports prospectés.
Les grandes rivières et terrasses hydrologiques alpha.11 ne sont pas transférées
dans ce prototype : les anciens presets les conservent. Le laboratoire essaie
seulement de petites sources facultatives sur support rocheux fermé.
Les structures autorisées sont les villages (diamètre au moins 256), pyramides
du désert et de jungle, cabanes de marais et camps abandonnés. Les starts natifs
sont acceptés après vérification de leur emprise et de leurs fondations ; le
moteur place les pièces, références et butins. Leur grille de sélection diffère
de celle de `/locate` vanilla : cette commande nest pas un outil fiable pour
les retrouver dans le laboratoire. La preuve actuelle porte sur un camp ; elle
ne valide pas visuellement chaque type de village ou de temple.
La réservation ajoute 32 blocs de décoration et 176 blocs de halo moteur autour
de lenveloppe 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.
Il existe une limite de 64 régions, origine comprise, et de ±100 000 blocs pour
leurs centres. La vérification initiale doccupation est synchrone et peut
suspendre brièvement le serveur pendant les lectures disque ; la préparation
suivante avance chunk par chunk.
Les bateaux volants, montgolfières, panthéons, îles uniques en béton coloré,
océans coralliens, recherches, coûts et boîtes de dépôt restent dans la vision.
Aucun command block, transfert collectif au Void ou reset global nest nécessaire
pour utiliser ces commandes.
## Observations reproductibles
Sur la graine **42**, le scénario moteur a ouvert une île tropicale de 64 blocs
au nord, puis une île aride de 64 blocs à lest de celle-ci. Chaque préparation
a atteint 100 chunks FULL. Les biomes sauvegardés, les points darrivée sur sol
réel, les permissions du dispatcher, le refus des doublons incohérents, la
conservation dun bloc témoin sur Sanctuary et le refus du vide déjà chargé
ont été vérifiés. Un camp abandonné possède un tonneau placé avec sa table de
butin vanilla. Sur la graine **0**, ces expansions fonctionnent mais aucun des
11 sites de structure candidats na passé les conditions de terrain ; le test
exigeant une preuve de butin a donc échoué sur cette graine.
Un second processus Java a rouvert le monde de développement de graine 42 et
retrouvé les trois régions prêtes, le bloc dor écrit avant larrêt, les colonnes
de blocs, biomes, densités et le tonneau. Le codec sauvegardé a aussi été décodé.
Le framework GameTest reconstruit ses réglages depuis le preset : ce contrôle
ne remplace pas un essai du parcours normal de chargement dans le client.
La reprise dun journal réservé est contrôlée séparément par le smoke ; un
crash forcé en pleine génération, les téléportations de vrais joueurs et la
charge multijoueur ne sont pas simulés.
Les smokes parcourent cinq graines, les quatre tailles et les quatre reliefs :
transformation verticale monotone, dessous de lorigine conservé, canyon retirant
de la matière et couronne volcanique. Ce sont des contrôles géométriques ; les
profils 128/256/512 et les quatre reliefs demandent encore une appréciation
visuelle en jeu. Voir [Validation](testing.md) pour les commandes et preuves.
+101 -4
View File
@@ -14,7 +14,10 @@ faire avant de clôturer tous leurs critères. WG-04 à WG-07 restent ouverts,
avec les incréments de l’île initiale décrits ci-dessous. WG-08 est validé ;
WG-09 est livré en alpha.8, avec mesures moteur et mise à jour Prism vérifiées.
WG-10 est livré en alpha.9, avec mesures moteur et synchronisation Prism vérifiées.
WG-11 ajoute les tailles alpha.10 pour 5, 20 ou 100 joueurs, validées sur la graine 0.
WG-11 est livré en alpha.10 : tailles 5/20/100 validées sur la graine 0,
release publiée et même instance Prism synchronisée.
WG-12 est livré en alpha.11 : Petit/Grand, génération accélérée et même instance
Prism synchronisée deux fois ; voir [Distribution](packwiz.md).
Résultat visé : un client et un serveur Fabric compatibles peuvent ouvrir un monde Sanctuary, plusieurs joueurs y arrivent sur une même île sûre et les chunks extérieurs restent vides. La génération est reproductible et ses limites sont documentées.
@@ -311,7 +314,7 @@ réussissent, avec 260 fichiers personnels et réglages conservés. Ce ticket
local nest pas une issue distante publiée ; lessai visuel et l’équilibrage
restent ouverts.
### WG-11 — Tailles d’île selon la capacité — alpha.10 validée
### WG-11 — Tailles d’île selon la capacité — alpha.10 livrée
**Branche :** `codex/player-capacity-presets`.
@@ -350,12 +353,106 @@ avec 20 joueurs par défaut, tout en conservant lambiance et les anciennes pa
**État au 9 septembre 2026 :** implémentation et validation moteur terminées
sur la graine `0` pour les profils 5, 20 et 100. Les onze tests requis passent
dans chaque monde, avec les fluides pendant 1 800 ticks et la persistance réelle
des réglages sur disque. Le build et les artefacts locaux sont vérifiés ;
la publication et les synchronisations sont suivies dans [Distribution](packwiz.md).
des réglages sur disque. Le build et les artefacts sont vérifiés ; la release
et le canal sont publiés. Deux synchronisations isolées puis deux dans la même
instance Prism réussissent, avec 363 fichiers personnels et réglages conservés.
Les références figurent dans [Distribution](packwiz.md).
Les anciens mondes restent inchangés. Les observations ne couvrent pas toute
l’île de 100 joueurs, et le coût initial dun plan régional reste notable.
Ce ticket local nest pas une issue distante publiée.
### WG-12 — Petit, Grand et génération plus rapide — alpha.11 livrée
**Branche :** `codex/worldgen-performance`.
**Problème :** les grands formats éloignent trop les constructions. Les essais
signalent aussi plusieurs minutes de chargement puis des chunks difficiles à
utiliser. Le ticket rapproche les deux choix de départ et optimise les calculs,
avec des preuves distinctes pour la taille et la vitesse.
**Critères dacceptation :**
- Proposer seulement **Petit** et **Grand** dans la création, avec **Grand par
défaut**, et libellés FR/EN. Petit garde le diamètre nominal de 512 blocs ;
Grand vise environ 724 blocs, soit une aire nominale deux fois supérieure.
La hauteur reste de 384 blocs. Les nombres 5/10 ne servent que de repères
internes de surface, sans capacité de joueurs garantie.
- Associer Petit à `sanctuary:sanctuary_5` / `sanctuary:population_5` et Grand
à `sanctuary:sanctuary` / nouveau réglage `sanctuary:population_10`.
Garder les anciens réglages `population_20` et `population_100` chargeables,
tout en cachant leurs formats dans les choix publics de création.
- Ne pas modifier le relief dune sauvegarde existante, son identifiant ou
son format. Les changements du défaut de création ne convertissent pas
un monde. Aucune sauvegarde personnelle nest ouverte pendant les essais.
- Pour loptimisation, **conserver les résultats à paramètres identiques** :
densités, plans et blocs utiles, ordre déterministe et protections de leau,
des terrasses et de la lave. Le nouveau relief Grand a une clé distincte ;
la réduction de surface ne compte pas comme gain de calcul à géométrie égale.
- Mesurer séparément démarrage initial, planification et génération dun lot
explicite de chunks. Fixer machine, Java, mémoire, graine, paramètres,
coordonnées et état des caches pour comparer alpha.10 et alpha.11.
- Viser **120 secondes au plus de démarrage initial** pour les choix publics
et **au moins 30 % de gain** sur les phases comparées à géométrie identique.
Un temps de planificateur seul ne prouve pas lentrée dans un monde jouable.
Préciser les bornes de chronométrage et ne pas promettre ces seuils pour
tout matériel ou pour une charge de cent joueurs.
- Valider Petit et Grand, plus un témoin de compatibilité et de performance
sur lancien profil 20. Les anciennes mesures 100 restent historiques ;
ce format nest plus un choix public ni le centre du plan de validation.
Examiner le premier accès, le cache réutilisé et les chunks dexploration.
- Conserver les contrôles moteur de fluides et de réentrée sans marqueur,
puis exécuter `check build assemblePack` sur les sources finales. Garder
visibles les limites et toute cible de performance non atteinte.
- Après validation seulement : artefacts immuables, canal packwiz stable,
deux synchronisations isolées puis deux dans la même instance Prism. Les
mondes, réglages et mods personnels, même désactivés, doivent être préservés.
**État :** livré. Artefacts immuables publiés, canal stable avancé, deux
synchronisations isolées et deux dans la même instance Prism, avec 364 fichiers
personnels et réglages suivis conservés. Tailles et code validés. `check build assemblePack` passe, ainsi
que douze tests moteur sur Petit et Grand (graine 0, 1 800 ticks), les contrôles
de persistance et l’équivalence bit à bit. Le benchmark ancien20 comparable
baisse de 84,65 % au départ et de 86,38 % en exploration. Grand atteint
62,67 secondes pour création + 289 chunks FULL. Cette mesure serveur ne
valide pas le rendu ni un temps de chargement universel. Voir
[Validation](testing.md), [résultats](benchmarks/alpha11-generation.json) et
[Distribution](packwiz.md) pour le reçu de publication et de synchronisation.
Ce ticket local nest pas une issue distante publiée.
### WG-13 — Laboratoire dexpansion à branches — alpha.12
**Branche :** `codex/expansion-lab`.
**But :** tester en opérateur de nouvelles îles, des climats alternatifs, les
reliefs hauts et les structures vanilla avant de développer leur progression.
**Critères dacceptation :**
- Nouveau preset laboratoire ; Petit/Grand et anciennes parties conservés.
- Commandes pour préparer, créer, suivre, reprendre et visiter une île ;
opérateurs uniquement, raccourcis et retours FR/EN.
- Parent et direction indépendants du climat, avec tailles nominales bornées
64/128/256/512 et reliefs versionnés. Une branche peut changer de climat.
- Application du relief haut aussi à l’île initiale expérimentale ; dessous
historique et sources anciennes préservés.
- Structures vanilla sélectionnées par biome avec contrôles demprise/support ;
preuve moteur dune structure réellement placée et de son butin natif.
- Journal atomique et repris après interruption, demandes répétées sans doublon,
refus des chunks existants y compris vides, conservation dun bloc témoin.
- Contrat de réservation explicite avant toute activation. Aucun ancien monde
converti ; aucune suppression de chunks ni commande de régénération.
- `check build assemblePack` et tests moteur sur monde jetable, avec limites
documentées. Les recherches, prix, boîtes postales et le Void restent planifiés.
**État :** implémenté et vérifié localement : build/pack, douze tests historiques,
expansions et rechargement moteur de graine 42. Release et MRpack publiés,
canal packwiz avancé et même instance Prism synchronisée deux fois ; 466 fichiers
personnels et réglages suivis conservés. Validation visuelle et multijoueur
encore ouverte. Contrat et portée des preuves
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.
## 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.
+654
View File
@@ -0,0 +1,654 @@
{
"ticket": "WG-12",
"version": "0.1.0-alpha.11",
"date": "2026-09-09",
"protocol": {
"machine": "Apple M1, 8 GiB RAM, macOS",
"minecraft": "26.3-pre-2",
"fabric_loader": "0.19.5",
"fabric_api": "0.160.0+26.3",
"baseline_source_commit": "a26b72f",
"production_files_changed_in_baseline": 0,
"runs_per_configuration": 1,
"world": "fresh ignored development world per run",
"workload": "289 synchronous FULL chunks in concentric rings around natural spawn, then 80 FULL chunks in an eastward corridor. Minecraft world creation timed separately from startup log.",
"scope": "Server generation up to FULL. Does not establish ticking readiness, postprocessing completion, client rendering, FPS, multiplayer capacity or a guaranteed user loading time.",
"baseline_scope_correction": "The raw alpha.10 report incorrectly says postprocessing is included; FULL alone does not guarantee this. Workload code is identical; alpha.11 corrects only that explanatory string.",
"build_time_excluded": true,
"comparison": "Only legacy20 versus legacy20 measures the algorithm gain. Grand10 changes geometry; its exploration timing cannot be used for that comparison.",
"frozen_runtime_files": 272,
"frozen_runtime_manifest_sha256": "1c2b433417a5d9e6a219e9ee19781574538d2167c774e14ed2bf254d277d4482",
"baseline_runtime_manifest_sha256": "d4860072d1896f0dfd784a5876f1253367b7818a2023f1910ba1612b6f0e7103",
"benchmark_source_sha256": "408711c97d65de63af4e0a1eac90e6edb5bf2df2b70c43772a3fdef7347d75d1"
},
"runs": {
"alpha10_legacy20": {
"version": "0.1.0-alpha.10",
"players": 20,
"seed": 0,
"spawn": [
0,
252,
0
],
"java": "25.0.3",
"processors": 8,
"max_heap_bytes": 2147483648,
"spawn_area_ms": 310664.163542,
"spawn_area_full_chunks": 289,
"spawn_area_nonempty_chunks": 289,
"rings": [
{
"elapsed_ms": 2.609,
"cumulative_chunks": 1,
"radius": 0
},
{
"elapsed_ms": 5468.709333,
"cumulative_chunks": 9,
"radius": 1
},
{
"elapsed_ms": 17601.560917,
"cumulative_chunks": 25,
"radius": 2
},
{
"elapsed_ms": 27397.566458,
"cumulative_chunks": 49,
"radius": 3
},
{
"elapsed_ms": 36562.853333,
"cumulative_chunks": 81,
"radius": 4
},
{
"elapsed_ms": 44462.775334,
"cumulative_chunks": 121,
"radius": 5
},
{
"elapsed_ms": 52423.80275,
"cumulative_chunks": 169,
"radius": 6
},
{
"elapsed_ms": 60416.705916,
"cumulative_chunks": 225,
"radius": 7
},
{
"elapsed_ms": 66303.128125,
"cumulative_chunks": 289,
"radius": 8
}
],
"spawn_area_chunk_latency_ms": {
"median": 993.3335,
"p95": 1392.158958,
"max": 2560.16575
},
"exploration_ms": 103141.834334,
"exploration_full_chunks": 80,
"exploration_steps": [
{
"elapsed_ms": 5146.88675,
"chunk_x": 9
},
{
"elapsed_ms": 5087.495583,
"chunk_x": 10
},
{
"elapsed_ms": 5229.782834,
"chunk_x": 11
},
{
"elapsed_ms": 5046.559,
"chunk_x": 12
},
{
"elapsed_ms": 4810.5415,
"chunk_x": 13
},
{
"elapsed_ms": 28755.95025,
"chunk_x": 14
},
{
"elapsed_ms": 4763.415834,
"chunk_x": 15
},
{
"elapsed_ms": 4772.209166,
"chunk_x": 16
},
{
"elapsed_ms": 4826.460208,
"chunk_x": 17
},
{
"elapsed_ms": 4857.054416,
"chunk_x": 18
},
{
"elapsed_ms": 4947.188167,
"chunk_x": 19
},
{
"elapsed_ms": 5168.714916,
"chunk_x": 20
},
{
"elapsed_ms": 4897.048167,
"chunk_x": 21
},
{
"elapsed_ms": 5286.23025,
"chunk_x": 22
},
{
"elapsed_ms": 4865.694458,
"chunk_x": 23
},
{
"elapsed_ms": 4661.793875,
"chunk_x": 24
}
],
"exploration_chunk_latency_ms": {
"median": 925.461875,
"p95": 1273.829625,
"max": 25131.068417
},
"regions_cached": 2,
"passed": true,
"world_creation_ms": 96766,
"world_creation_plus_spawn_area_ms": 407430.163542,
"raw_report_sha256": "d70c4ac49f37470d4db15b9200e26da8ac9d4d3b1b008e7ec03e67deafcc0f93",
"log_sha256": "5b1df55f7a167003c089e80fe1fb824d087c137e3662bac84af3ae0b325b8392"
},
"alpha11_legacy20": {
"version": "0.1.0-alpha.11",
"players": 20,
"seed": 0,
"spawn": [
0,
252,
0
],
"java": "25.0.3",
"processors": 8,
"max_heap_bytes": 2147483648,
"spawn_area_ms": 43647.054375,
"spawn_area_full_chunks": 289,
"spawn_area_nonempty_chunks": 289,
"rings": [
{
"cumulative_chunks": 1,
"elapsed_ms": 1.965667,
"radius": 0
},
{
"cumulative_chunks": 9,
"elapsed_ms": 870.4725,
"radius": 1
},
{
"cumulative_chunks": 25,
"elapsed_ms": 2972.902334,
"radius": 2
},
{
"cumulative_chunks": 49,
"elapsed_ms": 4480.355667,
"radius": 3
},
{
"cumulative_chunks": 81,
"elapsed_ms": 5120.230375,
"radius": 4
},
{
"cumulative_chunks": 121,
"elapsed_ms": 6290.425208,
"radius": 5
},
{
"cumulative_chunks": 169,
"elapsed_ms": 7381.384417,
"radius": 6
},
{
"cumulative_chunks": 225,
"elapsed_ms": 7839.425333,
"radius": 7
},
{
"cumulative_chunks": 289,
"elapsed_ms": 8677.269209,
"radius": 8
}
],
"spawn_area_chunk_latency_ms": {
"p95": 280.6,
"median": 135.289625,
"max": 699.612708
},
"exploration_ms": 14051.250167,
"exploration_full_chunks": 80,
"exploration_steps": [
{
"chunk_x": 9,
"elapsed_ms": 750.152875
},
{
"chunk_x": 10,
"elapsed_ms": 680.650584
},
{
"chunk_x": 11,
"elapsed_ms": 729.180958
},
{
"chunk_x": 12,
"elapsed_ms": 725.223
},
{
"chunk_x": 13,
"elapsed_ms": 540.200125
},
{
"chunk_x": 14,
"elapsed_ms": 5554.177667
},
{
"chunk_x": 15,
"elapsed_ms": 473.982208
},
{
"chunk_x": 16,
"elapsed_ms": 471.05575
},
{
"chunk_x": 17,
"elapsed_ms": 513.109667
},
{
"chunk_x": 18,
"elapsed_ms": 533.284292
},
{
"chunk_x": 19,
"elapsed_ms": 503.983917
},
{
"chunk_x": 20,
"elapsed_ms": 513.126292
},
{
"chunk_x": 21,
"elapsed_ms": 508.7555
},
{
"chunk_x": 22,
"elapsed_ms": 587.331416
},
{
"chunk_x": 23,
"elapsed_ms": 467.628834
},
{
"chunk_x": 24,
"elapsed_ms": 494.771375
}
],
"exploration_chunk_latency_ms": {
"p95": 186.482333,
"median": 103.648708,
"max": 5153.6295
},
"regions_cached": 2,
"passed": true,
"world_creation_ms": 18888,
"world_creation_plus_spawn_area_ms": 62535.054375,
"raw_report_sha256": "4799180bd61ec4ffc93d6a1a7ccb5440eb880f7dc08a345429b77db10cc17ab0",
"log_sha256": "a9347297608528e719d6780f3dd2f2588bb516e06ab771ca6da6ffdda404f7a4"
},
"alpha11_grand10": {
"version": "0.1.0-alpha.11",
"players": 10,
"seed": 0,
"spawn": [
0,
252,
0
],
"java": "25.0.3",
"processors": 8,
"max_heap_bytes": 2147483648,
"spawn_area_ms": 44291.159875,
"spawn_area_full_chunks": 289,
"spawn_area_nonempty_chunks": 289,
"rings": [
{
"radius": 0,
"cumulative_chunks": 1,
"elapsed_ms": 42.387459
},
{
"radius": 1,
"cumulative_chunks": 9,
"elapsed_ms": 1474.290584
},
{
"radius": 2,
"cumulative_chunks": 25,
"elapsed_ms": 3180.279791
},
{
"radius": 3,
"cumulative_chunks": 49,
"elapsed_ms": 4474.062208
},
{
"radius": 4,
"cumulative_chunks": 81,
"elapsed_ms": 5043.704042
},
{
"radius": 5,
"cumulative_chunks": 121,
"elapsed_ms": 6367.417708
},
{
"radius": 6,
"cumulative_chunks": 169,
"elapsed_ms": 7285.252542
},
{
"radius": 7,
"cumulative_chunks": 225,
"elapsed_ms": 7824.965875
},
{
"radius": 8,
"cumulative_chunks": 289,
"elapsed_ms": 8577.641208
}
],
"spawn_area_chunk_latency_ms": {
"max": 665.612875,
"p95": 274.9215,
"median": 136.067375
},
"exploration_ms": 7684.81575,
"exploration_full_chunks": 80,
"exploration_steps": [
{
"elapsed_ms": 739.863,
"chunk_x": 9
},
{
"elapsed_ms": 724.49625,
"chunk_x": 10
},
{
"elapsed_ms": 720.754584,
"chunk_x": 11
},
{
"elapsed_ms": 695.608958,
"chunk_x": 12
},
{
"elapsed_ms": 522.1695,
"chunk_x": 13
},
{
"elapsed_ms": 1112.798042,
"chunk_x": 14
},
{
"elapsed_ms": 430.820667,
"chunk_x": 15
},
{
"elapsed_ms": 416.082834,
"chunk_x": 16
},
{
"elapsed_ms": 400.90425,
"chunk_x": 17
},
{
"elapsed_ms": 414.387708,
"chunk_x": 18
},
{
"elapsed_ms": 394.878458,
"chunk_x": 19
},
{
"elapsed_ms": 393.643834,
"chunk_x": 20
},
{
"elapsed_ms": 391.553625,
"chunk_x": 21
},
{
"elapsed_ms": 279.306334,
"chunk_x": 22
},
{
"elapsed_ms": 25.634541,
"chunk_x": 23
},
{
"elapsed_ms": 18.210167,
"chunk_x": 24
}
],
"exploration_chunk_latency_ms": {
"max": 760.323917,
"p95": 198.353916,
"median": 85.88425
},
"regions_cached": 2,
"passed": true,
"world_creation_ms": 18376,
"world_creation_plus_spawn_area_ms": 62667.159875,
"raw_report_sha256": "5284e0ef88217f01d51f9040bf13f652de8276dc3f9844dc14569848b227d05a",
"log_sha256": "3a4c84bb6ea1614f8fc9c85f472612750149996e2f7658c83afe398b43afe5f6"
}
},
"same_geometry_reduction_percent": {
"world_creation_plus_spawn_area_ms": 84.65134396742975,
"spawn_area_ms": 85.95040577665497,
"exploration_ms": 86.37676917641545
},
"targets": {
"minimum_reduction_percent": 30,
"grand_creation_plus_spawn_area_max_ms": 120000,
"same_geometry_reduction_passed": true,
"grand_generation_proxy_passed": true
},
"density_equivalence": {
"comparison": "Float.floatToRawIntBits; no tolerance",
"reference_commit": "a26b72f",
"scope": "Real compiled scalar/bulk densities, 4 capacities x 3 seeds (5/20/100 compatibility and new10 using the same parameterized algorithm), signed coordinates, region/coast/height boundaries, context switches and concurrency; no block changes",
"reference_source_sha256": "fcc5a8a39b7c4d181d74ce4f9183a1ca93646137955ac64bab9d22e6db0752a1",
"profiles": [
{
"seed": 0,
"bitwise_comparisons": 255678,
"players": 5,
"optimized_path_supported": true,
"reference_density_digest": "28ec59cba36174fd"
},
{
"seed": 42,
"bitwise_comparisons": 255678,
"players": 5,
"optimized_path_supported": true,
"reference_density_digest": "efd1e8084800ed5c"
},
{
"seed": 8675309,
"bitwise_comparisons": 255678,
"players": 5,
"optimized_path_supported": true,
"reference_density_digest": "91bea1db755d9aca"
},
{
"seed": 0,
"bitwise_comparisons": 255678,
"players": 10,
"optimized_path_supported": true,
"reference_density_digest": "b7bab667baf73361"
},
{
"seed": 42,
"bitwise_comparisons": 255678,
"players": 10,
"optimized_path_supported": true,
"reference_density_digest": "42aeaf160360e41c"
},
{
"seed": 8675309,
"bitwise_comparisons": 255678,
"players": 10,
"optimized_path_supported": true,
"reference_density_digest": "cb85f95333e040c0"
},
{
"seed": 0,
"bitwise_comparisons": 255678,
"players": 20,
"optimized_path_supported": true,
"reference_density_digest": "175524360755dd88"
},
{
"seed": 42,
"bitwise_comparisons": 255678,
"players": 20,
"optimized_path_supported": true,
"reference_density_digest": "4cc8aec467a2d164"
},
{
"seed": 8675309,
"bitwise_comparisons": 255678,
"players": 20,
"optimized_path_supported": true,
"reference_density_digest": "5a3f16e426e5a64e"
},
{
"seed": 0,
"bitwise_comparisons": 255678,
"players": 100,
"optimized_path_supported": true,
"reference_density_digest": "83cda17336772fa7"
},
{
"seed": 42,
"bitwise_comparisons": 255678,
"players": 100,
"optimized_path_supported": true,
"reference_density_digest": "e7aa7f52022cc6aa"
},
{
"seed": 8675309,
"bitwise_comparisons": 255678,
"players": 100,
"optimized_path_supported": true,
"reference_density_digest": "de4b8f52d249a9bd"
}
],
"concurrent_workers": 3,
"concurrent_comparisons": 115668,
"passed": true,
"adversarial": {
"aligned_scalar_path_preserved": true,
"passed": true,
"bitwise_comparisons": 122528,
"concurrent_workers": 3,
"repeated_cell_corner_batches": 1
}
},
"persistence": {
"default_players": 10,
"profiles": [
{
"restored_settings": "sanctuary:population_5",
"players": 5,
"disk_round_trip": true,
"preset": "sanctuary:sanctuary_5"
},
{
"restored_settings": "sanctuary:population_10",
"players": 10,
"disk_round_trip": true,
"preset": "sanctuary:sanctuary"
},
{
"restored_settings": "sanctuary:population_20",
"players": 20,
"disk_round_trip": true,
"preset": "sanctuary:sanctuary_20"
},
{
"restored_settings": "sanctuary:population_100",
"players": 100,
"disk_round_trip": true,
"preset": "sanctuary:sanctuary_100"
}
],
"passed": true,
"visible_presets": [
"sanctuary:sanctuary_5",
"sanctuary:sanctuary"
],
"seed": 0,
"legacy_settings_preserved": "sanctuary:sanctuary_cavern",
"omitted_density_capacity_preserved": 20
},
"limits": [
"Single seed and one timed run per configuration; repeat to estimate variance.",
"A new cold region still has a synchronous planning cost; observed legacy20 maximum exploration chunk latency is about 5.15 s.",
"No personal saved world was opened or regenerated."
],
"engine_checks": {
"5": {
"mod_version": "0.1.0-alpha.11",
"seed": 0,
"settings": "sanctuary:population_5",
"inspected_full_chunks": 110,
"retained_water_blocks": 5656,
"fluid_ticks": 1800,
"tested_springs": 1,
"tested_spills": 1,
"tested_lava_falls": 1,
"postprocessing_reentry_witnesses": 3,
"passed": true,
"scope": "Selected basins, terraces, springs and ecology patches in explicit regions; no whole-island inventory or multiplayer load test",
"raw_report_sha256": "cfaa0a532b50399ab28808f51a871363b198a67d8c096ac13f7a011bd87ff9d0"
},
"10": {
"mod_version": "0.1.0-alpha.11",
"seed": 0,
"settings": "sanctuary:population_10",
"inspected_full_chunks": 125,
"retained_water_blocks": 5923,
"fluid_ticks": 1800,
"tested_springs": 1,
"tested_spills": 1,
"tested_lava_falls": 1,
"postprocessing_reentry_witnesses": 3,
"passed": true,
"scope": "Selected basins, terraces, springs and ecology patches in explicit regions; no whole-island inventory or multiplayer load test",
"raw_report_sha256": "aaed5b38821197c479a8acb7f2a201c64f290666c039d0b15047340e403e028c"
}
}
}
+314
View File
@@ -0,0 +1,314 @@
{
"version": "0.1.0-alpha.12",
"date": "2026-09-09",
"minecraft": "26.3-pre-2",
"java": 25,
"generation": 12,
"source_base_commit": "f816ffc6402fe31789f9677c2ee94328b3181329",
"scope": "Local development tests only; no personal worlds, client rendering, live player teleport or multiplayer load.",
"normalization": "world_directory fields replaced with <development_game_test_world>; all other diagnostic fields retained.",
"artifact": {
"path": "mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.12.jar",
"sha256": "f2ac2462a03a39ef330cc52143f9753ac5908b606cfe1dd1388ad5caae15e6e9",
"staged_pack_jar_identical": true,
"contains_gametest_classes": false
},
"build": {
"command": "./gradlew check build assemblePack",
"passed": true,
"legacy_profile": 10,
"legacy_seed": 0,
"required_engine_tests": 12,
"fluid_ticks": 1800,
"log_sha256": "dfedc75558596ce4413bd0466ec03645b2fa6853165510e9b8db0406d0958eb8"
},
"first_process_diagnostic_sha256": "90872c801cb04e3f0c78b47e0d38a0b45dd5d3e6d1e33ccc53c4359e4b407238",
"reload_diagnostic_sha256": "1efe055e1179ce60741a0d8546618807fc136c0de77f9aff6a979cf88b2b1cdd",
"fresh_world": {
"seed": 42,
"process_id": 2892,
"generator_version": 12,
"islands": [
{
"upper_biome": "minecraft:jungle",
"climate": "tropical",
"landing": [
0,
202,
-800
],
"landing_biome": "minecraft:jungle",
"parent": "sanctuary",
"id": "alpha",
"centre": [
0,
-800
],
"diameter": 64
},
{
"upper_biome": "minecraft:desert",
"climate": "arid",
"landing": [
544,
198,
-800
],
"landing_biome": "minecraft:desert",
"parent": "alpha",
"id": "beta",
"centre": [
544,
-800
],
"diameter": 64
}
],
"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
],
"journal_reloaded_regions": 3,
"visited_void_refusal": {
"chunk": [
-50,
50
],
"reason": "Expansion intersects a loaded or unloading chunk: [-61, 39]"
},
"native_structure": {
"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",
"container": [
28,
247,
73
],
"loot_table": "minecraft:barrels/abandoned_camp_barrel"
},
"restart_snapshot": {
"world_directory": "<development_game_test_world>",
"gold_witness": [
544,
204,
-800
],
"journal_sha256": "d7de79838744624e98ce26997ddc1daecb7bd3334bd9bf0e1ad31aa5eb7e27c3",
"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": -800,
"seed": 390175152143173586,
"relief": "natural",
"status": "ready",
"generation": 12
},
{
"id": "beta",
"parent": "alpha",
"direction": "east",
"climate": "arid",
"diameter": 64,
"center_x": 544,
"center_z": -800,
"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"
},
"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": [
0,
202,
-800
],
"id": "alpha",
"stored_column_sha256": "0debd009e711898fe64573f31a77f03f03d680be7398918b126004fcee3cfb1c",
"resolved_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
"base_column_sha256": "9269110205d64e1f72d9c44591a825d66fff3a4278fb835e5b4f2627b01a1db5",
"base_height": 202
},
{
"stored_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"point": [
544,
198,
-800
],
"id": "beta",
"stored_column_sha256": "73f7b445475a80b951f05d0c5fb6fd2eca2cf7519cd6ac5d64c7fe9dac53fa23",
"resolved_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"base_column_sha256": "4df3dd476f41a8c6b0e0273b9ebe5cbba4dec6cb22001c553c15d4f6b255c555",
"base_height": 198
}
],
"world_flushed_before_shutdown": true
},
"native_structure_starts_seen": 1,
"structure_start_chunks_inspected": 106,
"ticks": 1390,
"elapsed_ms": 43123.278916,
"passed": true
},
"reloaded_world": {
"seed": 42,
"process_id": 2975,
"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.",
"previous_process_id": 2892,
"world_directory": "<development_game_test_world>",
"gold_witness": [
544,
204,
-800
],
"samples": [
{
"point": [
-16,
242,
-16
],
"id": "sanctuary",
"stored_column_sha256": "a291039b2ed502c828b4f1ec97fe111f49f62e69b5589e05116d9b1931f461a4",
"resolved_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
"base_column_sha256": "646af3da3a327ab15d229b7fe2c328cc10eae7fce31d6b24c60f3be643bc1095",
"base_height": 242,
"stored_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485"
},
{
"point": [
0,
202,
-800
],
"id": "alpha",
"stored_column_sha256": "0debd009e711898fe64573f31a77f03f03d680be7398918b126004fcee3cfb1c",
"resolved_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
"base_column_sha256": "9269110205d64e1f72d9c44591a825d66fff3a4278fb835e5b4f2627b01a1db5",
"base_height": 202,
"stored_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b"
},
{
"point": [
544,
198,
-800
],
"id": "beta",
"stored_column_sha256": "73f7b445475a80b951f05d0c5fb6fd2eca2cf7519cd6ac5d64c7fe9dac53fa23",
"resolved_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"base_column_sha256": "4df3dd476f41a8c6b0e0273b9ebe5cbba4dec6cb22001c553c15d4f6b255c555",
"base_height": 198,
"stored_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60"
}
],
"regions": 3,
"native_container": [
28,
247,
73
],
"saved_generator_codec_decoded": true,
"passed": true
},
"earlier_seed_0_attempt": {
"scope": "Earlier development run before final cache and error-message fixes. Expansion phases completed; positive loot witness failed.",
"native_candidate_sites": 11,
"no_surface": 9,
"insufficient_foundation": 2,
"accepted_structures": 0,
"structure_proof_passed": false
},
"unverified": [
"Forced crash during chunk preparation",
"Normal client world selection and rendering",
"Real player teleports and multiplayer",
"Visual quality of every climate/relief and 128/256/512 formats",
"Every village and temple variant"
]
}
+32 -24
View File
@@ -1,30 +1,32 @@
# Climats, expansion et ressources
Ce document fixe les décisions de conception pour la suite de Sanctuary. Le
registre persistant des continents, leur déblocage et leur génération dans les
huit directions restent à implémenter. Le relevé de ressources décrit plus bas
Ce document fixe les décisions de conception pour la suite de Sanctuary.
Lalpha.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.
## Capacité de l’île initiale — alpha.10
## Taille de l’île initiale — alpha.11
Le choix de départ propose 5, 20 ou 100 joueurs, avec 20 par défaut. Laire
nominale est proportionnelle à la capacité : base de 512 blocs de diamètre
pour cinq joueurs, puis 1 024 pour vingt et environ 2 290 pour cent.
La hauteur reste de 384 blocs. Le contour réel et les stocks dépendent de
la génération ; laire nominale ne garantit pas une surface constructible
ou une quantité de minerai par joueur.
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 laire 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 laire constructible.
Cette taille est choisie pour un nouveau monde et ne remplace pas le futur
déblocage collectif de continents. Elle ne redimensionne aucune sauvegarde
existante et ne change pas la limite de connexions du serveur. Les contrôles
de génération des trois tailles passent sur la graine `0`, dans les emprises
consignées dans [Validation](testing.md), sans simuler cent joueurs simultanés.
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 lalpha.10 conservées plus bas
restent historiques ; les contrôles alpha.11 sont dans [Validation](testing.md).
## Les huit directions climatiques
## Les huit directions et les branches climatiques
Sanctuary Island constitue le point de départ **tempéré, légèrement humide**.
Le nord conduit vers le froid, le sud vers le chaud, l'ouest vers le sec et
l'est vers l'humide. Les diagonales combinent ces tendances.
La boussole climatique est une convention facultative : nord froid, sud chaud,
ouest sec et est humide. Les diagonales combinent ces tendances. **La direction
géographique ne contraint plus le climat.** Les joueurs pourront choisir de
prolonger une voie climatique ou de bifurquer vers dautres découvertes.
| Direction | Axe Minecraft depuis Sanctuary | Tendance climatique |
| --- | --- | --- |
@@ -37,7 +39,11 @@ l'est vers l'humide. Les diagonales combinent ces tendances.
| Ouest | `X` | Sec |
| Nord-ouest | `X, Z` | Froid et sec |
Ces directions définissent des familles de climats, pas huit biomes uniformes.
Ces associations sont les raccourcis par défaut, pas une loi du monde.
Une branche conserve son parent, sa direction et son climat explicite. Plusieurs
couronnes peuvent donc proposer des biomes nouveaux au nord comme au sud.
Le point dorigine reste enregistré même si un joueur déplace son lit ou le spawn.
Les familles de climats ne sont pas des biomes uniformes.
Le relief, l'altitude, les variations locales et la graine doivent conserver
des géographies distinctes à l'intérieur de chaque continent. Les amplitudes
de température et d'humidité, leur évolution avec la distance et les seuils
@@ -150,11 +156,13 @@ n'est inclus dans ces comptes de blocs.
```
Les modes sont transmis à la propriété JVM de test
`sanctuary.test.resourceSurvey`. La propriété Gradle `-PsanctuaryTestPlayers=5`,
`20` ou `100` choisit le nouveau profil ; sa valeur par défaut est `20`.
Le mode `island` calcule lenveloppe de la capacité active, avec 16 blocs de
marge de décoration et un halo de préparation dun chunk. Les tailles ci-dessous
sont les bornes calculées du travail demandé, pas des relevés déjà exécutés :
`sanctuary.test.resourceSurvey`. La propriété Gradle `-PsanctuaryTestPlayers`
choisit le profil interne. Lalpha.11 utilise `5` pour Petit et `10` pour Grand,
ce dernier par défaut ; `20` et `100` servent à la compatibilité historique.
Le mode `island` calcule lenveloppe du profil actif, avec 16 blocs de marge de
décoration et un halo de préparation dun chunk. Les valeurs ci-dessous sont
les bornes calculées lors de lalpha.10, pas des relevés exécutés ni un nombre
de chunks déjà validé pour le nouveau Grand :
| Profil | Chunks comptés par `island` | Chunks FULL préparés avec halo |
| --- | --- | --- |
+58 -19
View File
@@ -13,35 +13,62 @@ 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.
## Livrer une mise à jour
## Alpha.12 publiée — 9 septembre 2026
Lalpha.10 est validée localement sur les trois capacités et ses artefacts
sont vérifiés. Les commandes ci-dessous ciblent cette version ; elles ne
constituent pas une preuve de publication. Le canal stable reste sur la
dernière livraison vérifiée jusqu’à la publication et aux synchronisations.
Dernière livraison vérifiée le 9 septembre 2026 :
[Sanctuary 0.1.0-alpha.9](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.9),
depuis le commit source `40b6555`. Le JAR, le `.mrpack` et le ZIP damorçage
Prism sont publiés et leurs téléchargements publics vérifiés. Le canal stable
pointe sur cette version. SHA-256 du JAR :
[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)
et le ZIP damorçage Prism ont été téléchargés publiquement et vérifiés contre
les artefacts locaux. SHA-256 du JAR :
```text
150149852bf5afd0bb3096e6cd8953e69b23634859c10f294088a44aca538aff
f2ac2462a03a39ef330cc52143f9753ac5908b606cfe1dd1388ad5caae15e6e9
```
Le véritable installateur packwiz a réussi deux passages dans un dossier de
développement neuf, puis deux dans la même instance Sanctuary Beta. Le second
passage conserve les hashes des fichiers gérés. Un seul JAR Sanctuary alpha.12
est actif ; les **466 fichiers personnels et réglages suivis** conservent
leurs hashes, y compris les sauvegardes, mods tiers et configurations. Aucun
monde na été ouvert, converti ou régénéré par cette livraison.
La sauvegarde ciblée est dans `sanctuary-backups/before-0.1.0-alpha.12/` de
linstance existante. Les reçus locaux ignorés sont
`build/alpha12-isolated-validation.json` et `build/alpha12-prism-validation.json`.
Le [guide](alpha12-laboratory.md) décrit le nouveau preset **Sanctuary — Laboratoire** :
créer un nouveau monde de ce type pour essayer les expansions. Les anciennes
parties et les presets Petit/Grand gardent leur génération.
## Livrer une mise à jour
Livraison précédente, vérifiée le 9 septembre 2026 :
[Sanctuary 0.1.0-alpha.11](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.11),
depuis le commit source `f30a350`. Le JAR, le `.mrpack` et le ZIP damorçage
Prism sont publiés et leurs téléchargements publics vérifiés. Le canal stable
pointait alors sur cette version. SHA-256 du JAR :
```text
f075ad8a0b97cd53e4891273adbee5f79f04ab3984442950d5d85f58d2ded589
```
Le véritable installateur packwiz a réussi deux fois dans un dossier de
développement neuf, puis deux fois dans linstance Sanctuary Beta existante.
Le second passage laisse les fichiers gérés identiques. Un seul JAR Sanctuary
alpha.9 est actif ; les 260 fichiers personnels et réglages suivis conservent
leurs hashes. Lancien JAR et les manifestes locaux sont sauvegardés hors de
`mods/`. Aucun monde existant na été ouvert, converti ou régénéré.
alpha.11 est actif ; les 364 fichiers personnels et réglages suivis conservent
leurs hashes, y compris les fichiers des mods tiers et leurs configurations.
Lancien JAR et les manifestes locaux sont sauvegardés hors de `mods/`, dans
`sanctuary-backups/before-0.1.0-alpha.11/` de cette même instance.
Aucun monde existant na été ouvert, converti ou régénéré par la livraison.
Les reçus locaux ignorés sont `build/alpha11-isolated-validation.json` et
`build/alpha11-prism-validation.json` ; les listes de hashes avant et après
figurent dans le dossier de sauvegarde. Les preuves du build, des deux tailles
et de performance figurent dans [Validation](testing.md).
Après les vérifications, le commit et le push de la branche du ticket, le script
de publication réalise les étapes de release et de canal ci-dessous :
```sh
python3 scripts/publish_pack.py --notes-file chemin/vers/notes.md --asset build/Sanctuary-0.1.0-alpha.10.mrpack
python3 scripts/publish_pack.py --notes-file chemin/vers/notes.md --asset build/Sanctuary-0.1.0-alpha.12.mrpack
```
Les options sont facultatives. Ce script **publie** sur le Git configuré dans
@@ -60,7 +87,7 @@ La procédure complète, également utilisable manuellement :
3. Préparer les métadonnées de la livraison, avec l'URL exacte du futur JAR :
```sh
python3 scripts/pack.py release https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.10/sanctuary-0.1.0-alpha.10.jar
python3 scripts/pack.py release https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.12/sanctuary-0.1.0-alpha.12.jar
```
4. Pousser le commit source vérifié. Créer une release Gitea correspondant à ce
@@ -172,8 +199,20 @@ Lalpha.10 propose des choix de taille pour 5, 20 et 100 joueurs, avec
anciens réglages, notamment `sanctuary:sanctuary_cavern` de lalpha.9.
La mise à jour de la même instance Prism nagrandit aucune île
existante, ne modifie pas les paramètres de génération enregistrés et ne
nécessite pas de réimporter le ZIP damorçage. Les sauvegardes, captures,
packs graphiques et réglages personnels restent à comparer lors de la livraison.
nécessite pas de réimporter le ZIP damorçage. Les fichiers de sauvegarde,
captures, packs graphiques et réglages personnels suivis ont conservé leurs
hashes lors des deux synchronisations de livraison.
Lalpha.11 propose deux choix : **Petit** et **Grand**, Grand par défaut.
Le preset `sanctuary:sanctuary` crée un nouveau monde `population_10` ;
Petit garde `sanctuary:sanctuary_5` / `population_5`. Les anciens mondes
20/100 restent chargeables à leur taille enregistrée, alors que leurs formats
sont cachés de la création. Les formats de sauvegarde sont inchangés.
Loptimisation conserve les résultats pour un même réglage. La livraison a
préservé les fichiers personnels suivis, les mods tiers et leurs configurations.
Linstance na pas été réimportée et aucun mod tiers na été retiré. Les
sauvegardes personnelles sont restées fermées pendant les essais et la synchronisation.
Références officielles : [installation packwiz](https://packwiz.infra.link/tutorials/installing/packwiz-installer/),
[commandes Prism](https://prismlauncher.org/wiki/help-pages/custom-commands/),
+130 -4
View File
@@ -19,6 +19,49 @@ 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.
## Laboratoire alpha.12 — validation locale
`check build assemblePack` passe sur Java 25 / Minecraft 26.3-pre-2, avec
les douze tests moteur Grand de graine 0 et 1 800 ticks de fluides. Les smokes
`expansionJournalSmoke` et `expansionReliefSmoke` sont inclus dans `check`.
Le premier contrôle atomicité, corruption, immutabilité, relecture et réservation
interrompue ; le second couvre cinq graines, quatre diamètres et quatre reliefs.
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
```
Le premier passage crée le monde jetable. Le second saute volontairement
`cleanGameTestWorld` et ouvre cette même sauvegarde dans un nouveau processus
Java. Il échoue si les preuves du premier passage manquent ; il ne les fabrique
pas après rechargement. La propriété reload est réservée à cette paire de tests.
Les deux passages réussissent sur la graine 42. Le premier ouvre une tropicale
au nord puis une branche aride, vérifie les vrais chunks FULL, les biomes,
les commandes opérateur, les refus de doublons incohérents et du vide déjà
chargé, et la conservation dun bloc témoin. Il observe un camp Vanilla placé
avec un tonneau et sa table de butin native. Le second retrouve le journal,
un bloc dor écrit avant larrêt, les colonnes de blocs/biomes/densités et le
tonneau. Il décode également le codec sauvegardé, mais le framework reconstruit
les réglages actifs depuis le preset : aucun parcours client normal ou joueur
connecté nest simulé. Le crash forcé au milieu dune préparation reste à essayer.
La graine 0 a fourni les deux expansions mais aucune structure acceptable :
neuf sites étaient vides et deux avaient une fondation trop mince. Le scénario
exigeant une preuve positive de butin y échoue ; cela ne justifie aucun ajout
forcé de terrain ni relâchement des fondations. Les validations visuelles des
villages, temples, reliefs et grandes tailles restent ouvertes.
Les preuves exportées figurent dans [le rapport alpha.12](benchmarks/alpha12-expansion.json).
Les diagnostics complets de chaque exécution restent dans
`mods/sanctuary/build/run/gameTest/diagnostics/expansion-alpha12[-reload]-seed-42.json`.
Les commandes et le contrat serveur sont dans [Laboratoire](alpha12-laboratory.md).
Aucun monde personnel na servi aux essais.
## Tests de forme
La tâche `:sanctuary:worldgenSmoke`, incluse dans `check`, vérifie les invariants de
@@ -43,7 +86,8 @@ labsence de remplissage de secours lorsque la géométrie ne convient pas.
`runGameTest` démarre le serveur de test headless officiel de Fabric dans
`mods/sanctuary/build/run/gameTest/`. La tâche `cleanGameTestWorld` supprime uniquement
son monde jetable avant chaque exécution afin de générer des chunks neufs.
son monde jetable avant chaque exécution afin de générer des chunks neufs, sauf
lors du second passage explicite `sanctuaryExpansionReload` décrit ci-dessus.
Il ne déploie rien dans un serveur ou une installation de jeu personnels.
La configuration garde `eula=false` : aucun fichier d'acceptation n'est écrit par
Loom. Fabric dispose d'un chemin de démarrage propre à ses tests automatisés.
@@ -54,9 +98,10 @@ Deux adaptations y sont nécessaires, vérifiées contre les classes Minecraft
- `GameTestServer` sélectionne normalement `minecraft:flat_all_dimensions`.
Le mixin de test sélectionne directement le preset de production
`sanctuary:sanctuary` pour 20 joueurs par défaut.
`-PsanctuaryTestPlayers=5` ou `100` sélectionne sa variante de production,
sans recopier ses JSON.
`sanctuary:sanctuary`, sans recopier ses JSON. Lalpha.11 utilise le défaut
Grand (`-PsanctuaryTestPlayers=10`) et Petit (`5`). Les profils historiques
restent accessibles aux diagnostics de compatibilité, séparément de la liste
publique de création.
- Le framework désactive normalement les structures. Le test les active dans
`WorldOptions` pour examiner les chunks après toutes les étapes de génération.
La seed du serveur de test est `0` par défaut ; `-PsanctuaryTestSeed` est transmis
@@ -193,6 +238,87 @@ Pour cet essai visuel en monde de développement créatif, comparer la même vue
Y=63 ni assombrissement d'altitude au passage de Y=32. Les brouillards de l'eau,
de la météo et des effets d'aveuglement ou d'obscurité doivent rester présents.
## Petit, Grand et optimisation alpha.11 — 9 septembre 2026
`check build assemblePack` réussit sur les sources finales avec Grand, puis
la suite moteur réussit également sur Petit. Douze tests requis passent dans
chaque monde neuf, graine `0`. Les mondes personnels restent fermés.
| Choix | Chunks FULL inspectés | Eau retenue | Ticks de fluides |
| --- | --- | --- | --- |
| Petit (`population_5`) | 110 | 5 656 | 1 800 |
| Grand (`population_10`, défaut) | 125 | 5 923 | 1 800 |
Les deux essais vérifient les sources, déversoirs, coulées de lave et témoins
de réentrée après post-traitement. La persistance réelle sur disque préserve
les quatre références 5/10/20/100, le défaut Grand, les deux seuls choix publics
et lancien défaut 20 lorsque le champ `players` est absent dune densité sauvée.
Loracle alpha.10 effectue **3 183 804 comparaisons bit à bit** de densités
compilées réelles : références 5/10/20/100, graines `0`, `42`, `8675309`,
volumes, coordonnées signées, frontières et accès concurrents. Pour 10,
loracle utilise lancien algorithme paramétré à cette nouvelle taille ;
aucun ancien preset 10 nest supposé. Les tests synthétiques ajoutent
122 528 comparaisons sur les arrondis, collisions et changements de contexte.
Les expressions de datapacks non reconnues gardent le sampler vanilla.
Un test de concurrence vérifie quun plan prêt reste lisible pendant un
calcul lent, sans publier de plan incomplet ni conserver un échec en cache.
### Mesure à géométrie identique et nouveau Grand
Machine : Apple M1, 8 Gio de RAM, Java 25.0.3, heap 2 Gio, huit processeurs
visibles, Minecraft 26.3-pre-2 / Fabric 0.19.5. **Une exécution par configuration**,
graine `0`, nouveau monde à chaque fois, spawn `(0,252,0)`. La référence
alpha.10 est le commit `a26b72f` avec uniquement le harnais de mesure ajouté :
aucun fichier de production modifié. Aucun autre serveur ou build simultané.
| Phase serveur | Alpha.10, ancien 20 | Alpha.11, ancien 20 | Alpha.11, Grand 10 |
| --- | --- | --- | --- |
| Création du monde et spawn | 96,766 s | 18,888 s | 18,376 s |
| 289 chunks autour du spawn | 310,664 s | 43,647 s | 44,291 s |
| Somme création + zone | 407,430 s | 62,535 s | 62,667 s |
| 80 chunks dexploration | 103,142 s | 14,051 s | 7,685 s |
À géométrie 20 identique, la somme initiale baisse de **84,65 %** et
lexploration de **86,38 %**. Le gain minimal de 30 % est dépassé sur ce
protocole. Grand respecte le proxy de 120 secondes pour créer le monde et
produire la zone demandée. Sa surface différente ne compte pas comme preuve
du gain algorithmique. La latence maximale dun chunk dexploration sur
lancien 20 passe de 25,13 à 5,15 secondes : une région froide conserve donc
un coût ponctuel. Le cache ne rend pas toute exploration instantanée.
Le benchmark demande 289 chunks `FULL` par anneaux jusquau rayon huit,
puis 80 dans un corridor vers lest. Il distingue la création relevée dans
`Time elapsed` des phases mesurées dans le test. **FULL ne garantit pas le
statut de simulation ni la fin du post-traitement.** Le libellé du rapport
brut alpha.10 affirmait par erreur inclure ce dernier ; seul ce texte a été
corrigé dans le harnais alpha.11, sans changer les demandes de chunks.
Ni rendu client, ni réseau, ni FPS, ni délai dentrée visuellement confirmé,
ni capacité multijoueur ne sont mesurés. Une graine et une répétition ne
mesurent pas la variance sur toutes les îles ou machines.
Les [résultats structurés et hashes](benchmarks/alpha11-generation.json)
conservent les phases, anneaux, latences, périmètres et preuves moteur.
Les journaux bruts locaux sont `build/alpha11-baseline-20.log`,
`build/alpha11-optimized-20.log`, `build/alpha11-grand-10.log`,
`build/alpha11-check-build.log` et `build/alpha11-small-5.log`.
Reproduire le benchmark sur cette version (utiliser `10` pour Grand) :
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryBenchmark=true -PsanctuaryTestPlayers=20 -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
```
Loption sélectionne le harnais de performance à la place de la suite moteur,
jamais dans le JAR livré. Le build obligatoire reste une exécution normale :
```sh
./gradlew check build assemblePack -PsanctuaryTestPlayers=10 -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
./gradlew :sanctuary:runGameTest -PsanctuaryTestPlayers=5 -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
```
Les tableaux alpha.10 suivants restent historiques.
## Contrôles et validation alpha.10 — 9 septembre 2026
Les profils `sanctuary:population_5`, `population_20` et `population_100`
+32 -14
View File
@@ -50,15 +50,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.
La capacité prévue du groupe choisit la taille initiale : **5, 20 ou
100 joueurs**, avec **20 par défaut**. Laire nominale doit évoluer
proportionnellement à cette capacité, en prenant l’île de 512 blocs de
diamètre comme référence pour cinq joueurs. Cela donne 1 024 blocs pour
vingt joueurs et environ 2 290 pour cent joueurs, sur une hauteur conservée
de 384 blocs. Ce choix appartient à la création du monde ; il nagrandit pas
une sauvegarde existante ni ne suit automatiquement les connexions.
Il sagit dun dimensionnement du terrain, sans quota de ressources imposé
ou garantie de performances. Lalpha.10 met ces trois tailles à disposition ; leurs contrôles moteur portent sur la graine `0` et des emprises explicites.
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.
Lalpha.11 implémente ces deux choix et loptimisation des chargements. Les
anciens formats 20/100 sont masqués à la création mais restent chargeables
avec leurs paramètres enregistrés ; aucune sauvegarde nest réduite.
Les mesures serveur et leurs limites sont consignées dans [Validation](testing.md).
Ce dimensionnement ne remplace pas lexpansion collective ni ne garantit
une aire habitable ou un stock de ressources par joueur.
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,
@@ -130,11 +134,21 @@ facultatives selon le relief ; leur existence doit être démontrée en jeu.
L’état de chaque incrément et ses limites
figurent dans [Génération](worldgen.md).
Le premier champ de recherche des expansions associe les **huit directions**
autour de Sanctuary à des signatures de température et d'humidité. En regardant
la carte avec le nord en haut : **nord froid, sud chaud, ouest sec et est humide**.
Les diagonales combinent ces tendances. La recherche porte sur des climats et
leurs distributions, sans imposer la même forme de continent à chaque direction.
La progression des expansions se fait par **choix en branchement**. La direction
géographique, le climat et larchétype sont des choix distincts. La convention
**nord froid, sud chaud, ouest sec et est humide** reste une voie possible, avec
ses diagonales. Le joueur peut aussi ouvrir une branche alternative et composer
des couronnes de nouvelles îles aux climats différents. Une île enregistre son
parent ; le serveur conserve ainsi lhistoire 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.
Lalpha.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 dessai. Le passage en bêta interviendra
après clarification du périmètre de la vision et de la progression à développer.
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,
@@ -150,6 +164,10 @@ L'ambition est de reprendre puis d'étendre la centaine de biomes du catalogue T
### Structures et territoires
- **Structures vanilla** : admises dans les nouvelles générations lorsque leur
biome, leur emprise et leurs fondations conviennent au terrain flottant.
Lautorisation ne garantit pas leur présence sur chaque île. Le laboratoire
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.
- **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.
+58 -24
View File
@@ -5,22 +5,59 @@ entourée de vide sans répétition d'îles à grande distance. C'est une premi
tranche de génération pour Minecraft **26.3-pre-2 / Fabric**, pas encore le
système d'expansion collective.
Lalpha.10 propose trois tailles d’île pour 5, 20 ou 100 joueurs, avec
20 joueurs par défaut. Les trois profils passent les contrôles moteur sur
la graine `0`, dans des emprises explicitement inspectées. Les générations
historiques conservent leurs réglages, notamment `sanctuary:sanctuary_cavern`
pour lalpha.9 et `sanctuary:sanctuary_rift` pour lalpha.8. Les observations
de chaque version restent séparées dans [Validation](testing.md).
Lalpha.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).
## Laboratoire dexpansion — alpha.12
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.
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 nest 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.
## Petit et Grand — alpha.11
| 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 nagrandit ni ne réduit une sauvegarde.
Les générations antérieures restent distinctes, dont `sanctuary_cavern` et
`sanctuary_rift`.
Loptimisation réutilise les coins dinterpolation 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 dune 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, dhydrologie et les anciens réglages restent inchangés.
## Créer un monde
En solo : créer un **nouveau** monde et choisir le type de monde **Sanctuary**
dans les options de génération. Les types de monde vanilla restent disponibles.
Lalpha.10 utilise ce choix pour 20 joueurs par défaut, avec des variantes
5 et 100 joueurs. Les identifiants sont `sanctuary:sanctuary` (20),
`sanctuary:sanctuary_5` (5) et `sanctuary:sanctuary_100` (100).
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 du mod et de Fabric API :
Sur un nouveau serveur Fabric disposant de cette version et de Fabric API,
le choix Grand utilise :
```properties
level-type=sanctuary:sanctuary
@@ -28,14 +65,10 @@ level-name=sanctuary
level-seed=0
```
Pour créer une variante alpha.10, remplacer seulement
`level-type` par `sanctuary:sanctuary_5` ou `sanctuary:sanctuary_100`, avec un
nouveau `level-name`. Ces exemples concernent une cation de monde, pas le
changement de taille dune sauvegarde existante.
La seed est libre. Modifier `level-type` ne convertit pas un monde existant :
utiliser un nouveau `level-name` pour tester une autre génération. Les fichiers
de test et les sauvegardes personnelles doivent rester distincts.
Pour Petit, utiliser `level-type=sanctuary:sanctuary_5`. Choisir un nouveau
`level-name` pour tester lautre taille. La seed est libre ; ces exemples ne
convertissent aucun monde déjà enregistré et nautorisent aucune régénération.
Les fichiers de test et les sauvegardes personnelles restent distincts.
## Socle de génération conservé
@@ -86,11 +119,12 @@ chaque seed. Laccès à chaque progression vanilla et les ressources renouvel
nécessitent des vérifications concrètes. Les stocks réels sont relevés après la
génération, sans corriger une île pour atteindre un quota.
## Capacité de l’île — génération v10
## Capacité de l’île — génération v10 historique
Lalpha.10 propose trois tailles accessibles à la création dun monde.
Le choix **Sanctuary** vise 20 joueurs par défaut, accompagné de variantes
pour 5 et 100 joueurs. Les presets et paramètres de bruit sont associés ainsi :
Lors de lalpha.10, trois tailles étaient accessibles à la création dun monde.
Le choix **Sanctuary** visait 20 joueurs par défaut, accompagné de variantes
pour 5 et 100 joueurs. Le tableau suivant conserve cette association historique ;
lalpha.11 utilise le nouveau défaut Grand indiqué plus haut :
| Preset de création | Paramètres enregistrés dans le monde |
| --- | --- |
+2 -2
View File
@@ -8,6 +8,6 @@ loader_version=0.19.5
loom_version=1.17.20
fabric_api_version=0.160.0+26.3
mod_version=0.1.0-alpha.10
pack_version=0.1.0-alpha.10
mod_version=0.1.0-alpha.12
pack_version=0.1.0-alpha.12
maven_group=fr.koka.sanctuary
+40 -2
View File
@@ -26,16 +26,31 @@ if (clientRenderTests) {
}
}
// Every integration run must exercise fresh generation, never stale saved chunks.
// Fresh generation by default; the explicit laboratory reload verifies the same saved test world.
tasks.register('cleanGameTestWorld', Delete) {
onlyIf { !providers.gradleProperty('sanctuaryExpansionReload').map { it.toBoolean() }.getOrElse(false) }
delete(layout.buildDirectory.dir('run/gameTest/world'))
}
tasks.named('runGameTest') {
dependsOn('cleanGameTestWorld')
systemProperty('sanctuary.test.seed', providers.gradleProperty('sanctuaryTestSeed').getOrElse('0'))
systemProperty('sanctuary.test.players', providers.gradleProperty('sanctuaryTestPlayers').getOrElse('20'))
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.expansion', providers.gradleProperty('sanctuaryExpansionTests').getOrElse('false'))
systemProperty('sanctuary.test.expansionReload', providers.gradleProperty('sanctuaryExpansionReload').getOrElse('false'))
}
tasks.named('processGametestResources') {
def benchmark = providers.gradleProperty('sanctuaryBenchmark').map { it.toBoolean() }.getOrElse(false)
inputs.property('benchmark', benchmark)
def expansion = providers.gradleProperty('sanctuaryExpansionTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('expansion', expansion)
filesMatching('fabric.mod.json') {
expand(gametest_entrypoint: expansion ? 'fr.koka.sanctuary.gametest.ExpansionWorldGameTests' : benchmark
? 'fr.koka.sanctuary.gametest.WorldgenPerformanceGameTests'
: 'fr.koka.sanctuary.gametest.SanctuaryWorldGameTests')
}
}
dependencies {
@@ -222,3 +237,26 @@ tasks.register('populationRegionPlanSmoke', JavaExec) {
mainClass = 'fr.koka.sanctuary.worldgen.PopulationRegionPlanSmoke'
}
tasks.named('check') { dependsOn('populationRegionPlanSmoke') }
tasks.register('populationPlanCacheSmoke', JavaExec) {
group = 'verification'
description = 'Check that completed regions remain readable while new regions are planned.'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.PopulationPlanCacheSmoke'
}
tasks.named('check') { dependsOn('populationPlanCacheSmoke') }
tasks.register('expansionJournalSmoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.expansion.ExpansionJournalSmoke'
}
tasks.register('expansionReliefSmoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.expansion.ExpansionReliefSmoke'
}
tasks.named('check') { dependsOn('expansionJournalSmoke', 'expansionReliefSmoke') }
@@ -0,0 +1,70 @@
// Frozen test oracle from a26b72f PopulationIslandDensity.java.
// Original SHA-256: fcc5a8a39b7c4d181d74ce4f9183a1ca93646137955ac64bab9d22e6db0752a1
// Only package/imports/class name differ; do not optimize this reference.
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.worldgen.*;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka.sanctuary.SanctuaryMod;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.util.Interval;
import net.minecraft.world.level.levelgen.densityfunction.*;
/** Capacity changes the envelope; all natural noise is still sampled at world coordinates. */
public record Alpha10PopulationDensityReference(DensityFunction terrain, DensityFunction distortion,
DensityFunction sculpt, DensityFunction detail, DensityFunction underside,
DensityFunction riftWarp, DensityFunction riftDetail, IslandCapacity capacity) implements DensityFunction {
public static final MapCodec<Alpha10PopulationDensityReference> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
DensityFunction.CODEC.fieldOf("terrain").forGetter(Alpha10PopulationDensityReference::terrain),
DensityFunction.CODEC.fieldOf("distortion").forGetter(Alpha10PopulationDensityReference::distortion),
DensityFunction.CODEC.fieldOf("sculpt").forGetter(Alpha10PopulationDensityReference::sculpt),
DensityFunction.CODEC.fieldOf("detail").forGetter(Alpha10PopulationDensityReference::detail),
DensityFunction.CODEC.fieldOf("underside").forGetter(Alpha10PopulationDensityReference::underside),
DensityFunction.CODEC.fieldOf("rift_warp").forGetter(Alpha10PopulationDensityReference::riftWarp),
DensityFunction.CODEC.fieldOf("rift_detail").forGetter(Alpha10PopulationDensityReference::riftDetail),
IslandCapacity.CODEC.optionalFieldOf("players", IslandCapacity.TWENTY).forGetter(Alpha10PopulationDensityReference::capacity)
).apply(instance, Alpha10PopulationDensityReference::new));
@Override public DensitySampler compileSampler(CompileContext context) {
var raw = terrain.compileSampler(context); var edge = distortion.compileSampler(context);
var coarse = sculpt.compileSampler(context); var fine = detail.compileSampler(context);
var bottom = underside.compileSampler(context); var warp = riftWarp.compileSampler(context);
var fractureDetail = riftDetail.compileSampler(context);
long seed = context.createRandom(SanctuaryMod.id("population_rifts_alpha10")).nextLong();
var layouts = new ConcurrentHashMap<IslandCapacity.Region, RiftShape.Layout>();
return new DensitySampler() {
@Override public float sampleValue(SamplerContext sample, int x, int y, int z) {
if (Math.hypot((double) x, z) >= capacity.terrainLimit() || y <= 0 || y >= 384) return -1;
int localY = y - 64;
float natural = PopulationIslandShape.raisedDensity(x, y, z, raw.sampleValue(sample, x, localY, z),
edge.sampleValue(sample, x, 0, z), coarse.sampleValue(sample, x, localY, z),
fine.sampleValue(sample, x, localY, z), bottom.sampleValue(sample, x, localY, z), 64, 384, capacity);
if (natural <= 0) return natural;
var region = capacity.regionAt(x, z);
var layout = layouts.computeIfAbsent(region, r -> RiftShape.layout(regionSeed(seed, r)));
int rx = x - region.originX(), rz = z - region.originZ();
if (!RiftShape.mayAffect(layout, rx, rz)) return natural;
return RiftShape.density(layout, rx, y, rz, natural,
warp.sampleValue(sample, x, y, z), fractureDetail.sampleValue(sample, x, y, z));
}
@Override public void sampleVolume(SamplerContext sample, DensityBuffer buffer, DensityVolume volume) {
DensitySampler.sampleVolumeNaive(sample, buffer, volume, this);
}
};
}
public static long regionSeed(long seed, IslandCapacity.Region region) {
long value = seed ^ ((long) region.x() << 32) ^ (region.z() & 0xffffffffL) ^ 0xA10CA7E1L;
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
return value ^ (value >>> 31);
}
@Override public DensityFunction rewriteChildren(DfRewriteRule rule) {
return new Alpha10PopulationDensityReference(rule.rewrite(terrain), rule.rewrite(distortion), rule.rewrite(sculpt),
rule.rewrite(detail), rule.rewrite(underside), rule.rewrite(riftWarp), rule.rewrite(riftDetail), capacity);
}
@Override public Interval range() { return Interval.of(-1, 1); }
@Override public int domainAxes() { return ALL_AXES; }
@Override public MapCodec<Alpha10PopulationDensityReference> codec() { return CODEC; }
}
@@ -0,0 +1,84 @@
package fr.koka.sanctuary.gametest;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import fr.koka.sanctuary.expansion.ExpansionRuntime;
import java.io.IOException;
import net.minecraft.commands.CommandSource;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ChunkLevel;
import net.minecraft.server.permissions.LevelBasedPermissionSet;
import net.minecraft.server.permissions.PermissionLevel;
import net.minecraft.server.permissions.PermissionSet;
/** Tests real registered command permissions using sources; no real or simulated creative player is created. */
public final class ExpansionCommandChecks {
private ExpansionCommandChecks() { }
public static void verify(GameTestHelper helper) throws IOException {
var level = helper.getLevel();
var server = level.getServer();
var session = ExpansionRuntime.session(level);
var before = session.islands();
var dispatcher = server.getCommands().getDispatcher();
var console = server.createCommandSourceStack().withLevel(level).withSource(CommandSource.NULL).withSuppressedOutput();
var levelZero = console.withPermission(LevelBasedPermissionSet.forLevel(PermissionLevel.ALL));
var noPermissions = console.withPermission(PermissionSet.NO_PERMISSIONS);
var levelOne = console.withPermission(LevelBasedPermissionSet.MODERATOR);
var operatorTwo = console.withPermission(LevelBasedPermissionSet.GAMEMASTER);
var admin = console.withPermission(LevelBasedPermissionSet.ADMIN);
helper.assertTrue(console.getEntity() == null, "Command permission checks use console-derived sources, not a creative-player simulation");
helper.assertTrue(ChunkLevel.RADIUS_AROUND_FULL_CHUNK * 16 <= ExpansionIsland.GENERATION_HALO,
"The declared expansion halo must include the exact engine FULL generation dependency radius");
var sanctuary = dispatcher.getRoot().getChild("sanctuary");
helper.assertTrue(sanctuary != null && sanctuary.getChild("expansion") != null,
"The production Sanctuary expansion command tree must be registered");
helper.assertTrue(!sanctuary.canUse(levelZero) && !sanctuary.canUse(noPermissions) && !sanctuary.canUse(levelOne),
"Expansion commands must reject level 0, absent permissions and OP1 regardless of any game-mode assumption");
helper.assertTrue(sanctuary.canUse(operatorTwo) && sanctuary.canUse(admin) && sanctuary.canUse(console),
"Expansion commands must admit OP2 and higher, including the dedicated server console");
String create = "sanctuary expansion create command_denied sanctuary north boreal 64 natural";
expectDenied(helper, dispatcher, levelZero, create, "level 0");
expectDenied(helper, dispatcher, noPermissions, create, "no permissions");
expectDenied(helper, dispatcher, levelOne, create, "OP1");
helper.assertTrue(session.islands().equals(before), "Denied command sources must not add or change expansion regions");
String preview = "sanctuary expansion preview command_preview sanctuary north boreal 64 natural";
try {
var parsed = dispatcher.parse(preview, console);
helper.assertTrue(!parsed.getReader().canRead() && parsed.getExceptions().isEmpty(),
"The console must parse a complete preview with the known Sanctuary parent");
helper.assertTrue(dispatcher.execute(parsed) == 1, "The console preview must execute successfully through the registered dispatcher");
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");
// 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); }
catch (CommandSyntaxException expected) { outsideRangeRejected = true; }
helper.assertTrue(outsideRangeRejected && session.islands().equals(before),
"The parser must reject a diameter above 512 before changing the registry");
} catch (CommandSyntaxException exception) {
throw new IOException("Registered expansion command checks failed", exception);
}
}
private static void expectDenied(GameTestHelper helper, CommandDispatcher<CommandSourceStack> dispatcher,
CommandSourceStack source, String command, String label) {
var parsed = dispatcher.parse(command, source);
helper.assertTrue(parsed.getContext().getNodes().isEmpty() && parsed.getReader().canRead(),
"The " + label + " source must not parse the restricted expansion command tree");
boolean rejected = false;
try { dispatcher.execute(parsed); }
catch (CommandSyntaxException expected) { rejected = true; }
helper.assertTrue(rejected, "The " + label + " source must not execute an expansion command");
}
}
@@ -0,0 +1,434 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.expansion.ExpansionBiomeSource;
import fr.koka.sanctuary.expansion.ExpansionChunkGenerator;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import fr.koka.sanctuary.expansion.ExpansionJournal;
import fr.koka.sanctuary.expansion.ExpansionRuntime;
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.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
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.server.level.ServerLevel;
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.LevelChunk;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.Heightmap;
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 {
@GameTest(maxTicks = 6000)
public void islandsBranchPersistAndProtectVisitedChunks(GameTestHelper helper) {
helper.assertTrue(Boolean.getBoolean("sanctuary.test.expansion"), "Expansion GameTest must be explicitly enabled");
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof ExpansionChunkGenerator,
"Expansion GameTest must load the new laboratory preset");
if (Boolean.getBoolean("sanctuary.test.expansionReload")) {
verifyProcessReload(helper);
return;
}
new Scenario(helper).advance();
}
private static void verifyProcessReload(GameTestHelper helper) {
ServerLevel level = helper.getLevel();
var generator = (ExpansionChunkGenerator) level.getChunkSource().getGenerator();
var session = ExpansionRuntime.session(level);
Map<String, Object> report = new LinkedHashMap<>();
report.put("seed", level.getSeed());
report.put("process_id", ProcessHandle.current().pid());
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 "
+ "world-selection test or a multiplayer/teleportation test.");
try {
JsonObject previous = JsonParser.parseString(Files.readString(diagnostic(level, false))).getAsJsonObject();
helper.assertTrue(previous.get("passed").getAsBoolean(), "Reload requires a successful first integration run");
helper.assertTrue(previous.has("restart_snapshot"), "The first process must save a restart witness before shutting down");
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(),
"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");
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")),
"The loaded journal must contain exactly the ready root, alpha and beta");
helper.assertTrue(session.island("alpha").parent().equals("sanctuary") && session.island("beta").parent().equals("alpha"),
"Reload must preserve the expansion branch");
var encodedIslands = ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow();
helper.assertTrue(encodedIslands.equals(saved.get("islands")), "Every persisted region field must survive the process restart");
helper.assertTrue(sha256(Files.readString(journalFile(level))).equals(saved.get("journal_sha256").getAsString()),
"Loading a ready journal must preserve its exact saved contents");
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
var liveCodec = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow();
helper.assertTrue(liveCodec.equals(saved.get("generator_codec")), "Generator settings and biome holder identities must remain stable");
var decoded = ChunkGenerator.CODEC.parse(ops, saved.get("generator_codec")).getOrThrow();
helper.assertTrue(decoded instanceof ExpansionChunkGenerator, "The saved generator codec must decode to the laboratory generator");
var restored = (ExpansionChunkGenerator) decoded;
restored.initialize(level, session.islands());
BlockPos witness = position(saved.getAsJsonArray("gold_witness"));
full(helper, level, witness.getX() >> 4, witness.getZ() >> 4);
helper.assertTrue(level.getBlockState(witness).is(Blocks.GOLD_BLOCK),
"The first process's saved gold block must exist before the reload test writes anything");
List<Map<String, Object>> samples = new ArrayList<>();
for (var element : saved.getAsJsonArray("samples")) {
JsonObject original = element.getAsJsonObject();
var island = session.island(original.get("id").getAsString());
BlockPos point = position(original.getAsJsonArray("point"));
Map<String, Object> current = sample(helper, generator, island, point);
helper.assertTrue(new GsonBuilder().create().toJsonTree(current).equals(original),
"Stored blocks, stored biomes and generated base column must survive restart: " + island.id());
helper.assertTrue(new GsonBuilder().create().toJsonTree(sample(helper, restored, island, point)).equals(original),
"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");
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();
} catch (Exception exception) {
report.put("passed", false);
report.put("failure", exception.toString());
try { writeDiagnostic(level, true, report); } catch (IOException failure) { exception.addSuppressed(failure); }
SanctuaryMod.LOGGER.error("Expansion process reload scenario failed", exception);
helper.fail(exception.toString());
}
}
private static final class Scenario {
private final GameTestHelper helper;
private final ServerLevel level;
private final ExpansionChunkGenerator generator;
private final ExpansionRuntime.Session session;
private final Map<String, Object> report = new LinkedHashMap<>();
private final List<Map<String, Object>> observedIslands = new ArrayList<>();
private final Map<String, BlockPos> samplePoints = new LinkedHashMap<>();
private final List<ChunkPos> structureSearch = new ArrayList<>();
private final long started = System.nanoTime();
private ExpansionIsland alpha;
private ExpansionIsland beta;
private BlockPos marker;
private BlockState previousMarker;
private int phase;
private int structureIndex;
private int validStarts;
Scenario(GameTestHelper helper) {
this.helper = helper;
this.level = helper.getLevel();
this.generator = (ExpansionChunkGenerator) level.getChunkSource().getGenerator();
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("islands", observedIslands);
report.put("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.");
}
void advance() {
try {
helper.assertTrue(helper.getTick() < 5800, "Expansion scenario deadline: " + session.progress());
switch (phase) {
case 0 -> start();
case 1 -> {
if (!session.island("alpha").status().equals("ready")) break;
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");
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");
verifyJournal();
phase = 2;
}
case 2 -> {
if (!session.island("beta").status().equals("ready")) break;
helper.assertTrue(session.island("beta").equals(beta.ready()), "Branch activation must preserve its planned identity");
observeIsland("beta", Set.of("desert", "badlands", "eroded_badlands", "wooded_badlands"));
verifyMarker();
verifyJournal();
rejectVisitedVoid();
prepareStructureSearch();
phase = 3;
}
case 3 -> {
if (observeNativeStructure()) {
verifyMarker();
verifyJournal();
level.setBlock(marker, previousMarker, 3);
prepareRestartWitness();
report.put("islands", observedIslands);
report.put("native_structure_starts_seen", validStarts);
report.put("structure_start_chunks_inspected", structureIndex);
report.put("ticks", helper.getTick());
report.put("elapsed_ms", (System.nanoTime() - started) / 1_000_000.0);
report.put("passed", true);
writeReport();
helper.succeed();
return;
}
}
default -> throw new IllegalStateException("Unknown expansion test phase");
}
if (helper.getTick() % 200 == 0) SanctuaryMod.LOGGER.info("Expansion scenario phase {}: {}", phase, session.progress());
helper.runAtTickTime(helper.getTick() + 10, this::advance);
} catch (Exception exception) {
report.put("passed", false);
report.put("failure", exception.toString());
report.put("phase", phase);
report.put("runtime_progress", session.progress());
try { writeReport(); } catch (IOException failure) { exception.addSuppressed(failure); }
SanctuaryMod.LOGGER.error("Expansion integration scenario failed", exception);
helper.fail(exception.toString());
}
}
private void start() throws IOException {
helper.assertTrue(session.islands().size() == 1, "The test must start from a new expansion journal");
helper.assertTrue(level.structureManager().shouldGenerateStructures(), "The laboratory GameTest must enable actual vanilla structures in WorldOptions");
ExpansionCommandChecks.verify(helper);
report.put("registered_command_permission_checks", true);
helper.assertTrue(generator.source().possibleBiomes().size() >= ExpansionBiomeSource.REQUIRED_BIOMES.size(),
"Future biome holders must be declared before the first expansion");
BlockPos landing = session.landing("sanctuary");
helper.assertTrue(ExpansionRuntime.safeLanding(level, landing), "The initial laboratory island must provide a real safe landing");
full(landing.getX() >> 4, landing.getZ() >> 4);
marker = landing.above(6);
previousMarker = level.getBlockState(marker);
helper.assertTrue(level.setBlock(marker, Blocks.DIAMOND_BLOCK.defaultBlockState(), 3), "Place the parent player-edit witness");
report.put("parent_witness", coordinates(marker));
report.put("root_landing", coordinates(landing));
samplePoints.put("sanctuary", landing);
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.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;
try { session.create("alpha", "sanctuary", "north", "arid", 64, "natural"); }
catch (IllegalArgumentException expected) { conflict = expected.getMessage().equals("id_conflict"); }
helper.assertTrue(conflict, "A conflicting duplicate island id must be refused");
verifyJournal();
phase = 1;
}
private void observeIsland(String id, Set<String> expectedSurfaceBiomes) {
ExpansionIsland island = session.island(id);
BlockPos landing = session.landing(id);
helper.assertTrue(island.contains(landing.getX(), landing.getZ()) && ExpansionRuntime.safeLanding(level, landing),
"READY must offer actual safe terrain inside " + id);
LevelChunk chunk = full(landing.getX() >> 4, landing.getZ() >> 4);
helper.assertTrue(Arrays.stream(chunk.getSections()).anyMatch(section -> !section.hasOnlyAir()), "READY cannot be only a registry entry: " + id);
// The 240 sample is the stored upper climate in a FULL chunk, independent of which safe ledge was found.
String climateBiome = chunk.getNoiseBiome(landing.getX() >> 2, 60, landing.getZ() >> 2).unwrapKey().orElseThrow().identifier().toString();
helper.assertTrue(expectedSurfaceBiomes.contains(climateBiome.substring("minecraft:".length())),
"The FULL chunk must contain its requested upper climate: " + id + " / " + climateBiome);
String landingBiome = level.getBiome(landing).unwrapKey().orElseThrow().identifier().toString();
samplePoints.put(id, landing);
observedIslands.add(Map.of("id", id, "parent", island.parent(), "climate", island.climate(),
"centre", List.of(island.centerX(), island.centerZ()), "diameter", island.diameter(),
"landing", coordinates(landing), "landing_biome", landingBiome, "upper_biome", climateBiome));
}
private void verifyMarker() {
helper.assertTrue(level.getBlockState(marker).is(Blocks.DIAMOND_BLOCK), "An expansion must preserve the parent player-edit witness");
}
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());
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());
}
private void prepareRestartWitness() throws IOException {
BlockPos gold = samplePoints.get("beta").above(6);
while (!level.getBlockState(gold).isAir() && gold.getY() < level.getMaxY() - 1) gold = gold.above();
helper.assertTrue(gold.getY() < level.getMaxY() - 1 && level.getBlockState(gold).isAir(), "The restart witness needs empty space above beta");
helper.assertTrue(level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3), "Write the restart witness in the first process");
var snapshot = new LinkedHashMap<String, Object>();
snapshot.put("world_directory", worldDirectory(level));
snapshot.put("gold_witness", coordinates(gold));
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());
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);
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush the first process's world and marker before writing restart evidence");
snapshot.put("world_flushed_before_shutdown", true);
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;
// STARTS only, centre outwards. Only an accepted structure's region is later requested at FULL.
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));
}
}
private boolean observeNativeStructure() {
int end = Math.min(structureSearch.size(), structureIndex + 16);
while (structureIndex < end) {
ChunkPos position = structureSearch.get(structureIndex++);
var chunk = level.getChunkSource().getChunk(position.x(), position.z(), ChunkStatus.STRUCTURE_STARTS, true);
helper.assertTrue(chunk != null, "A structure-start request must complete");
for (StructureStart start : chunk.getAllStarts().values()) {
if (!start.isValid()) continue;
validStarts++;
String name = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getKey(start.getStructure()).toString();
if (!name.startsWith("minecraft:")) continue;
Map<String, Object> evidence = materializeAndFindLoot(start, name);
if (!evidence.isEmpty()) {
report.put("native_structure", evidence);
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;
}
private Map<String, Object> materializeAndFindLoot(StructureStart start, String name) {
var bounds = start.getBoundingBox();
List<LevelChunk> chunks = new ArrayList<>();
for (int x = bounds.minX() >> 4; x <= bounds.maxX() >> 4; x++) {
for (int z = bounds.minZ() >> 4; z <= bounds.maxZ() >> 4; z++) chunks.add(full(x, z));
}
for (LevelChunk chunk : chunks) {
for (var entry : chunk.getBlockEntities().entrySet()) {
if (!(entry.getValue() instanceof RandomizableContainerBlockEntity container) || container.getLootTable() == null) continue;
BlockPos pos = entry.getKey();
if (start.getPieces().stream().noneMatch(piece -> piece.getBoundingBox().isInside(pos))) continue;
String loot = container.getLootTable().identifier().toString();
if (!loot.startsWith("minecraft:")) continue;
return Map.of("structure", name, "loot_table", loot, "container", coordinates(pos),
"placed_block", level.getBlockState(pos).toString(), "full_chunks", chunks.size(),
"mode", "natural candidate accepted by production ExpansionStructures, native decoration");
}
}
return Map.of();
}
private LevelChunk full(int x, int z) {
var chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, true);
helper.assertTrue(chunk instanceof LevelChunk, "Expected real FULL chunk at " + new ChunkPos(x, z));
return (LevelChunk) chunk;
}
private void writeReport() throws IOException {
writeDiagnostic(level, false, report);
}
}
private static Map<String, Object> sample(GameTestHelper helper, ExpansionChunkGenerator generator, ExpansionIsland island, BlockPos point) {
ServerLevel level = helper.getLevel();
LevelChunk chunk = full(helper, level, point.getX() >> 4, point.getZ() >> 4);
var random = generator.randomState(island);
var base = generator.getBaseColumn(point.getX(), point.getZ(), level, random);
StringBuilder storedBlocks = new StringBuilder(), baseBlocks = new StringBuilder(), biomes = new StringBuilder(), resolvedBiomes = new StringBuilder();
var resolver = generator.source().createUncachedResolver(random);
for (int y = level.getMinY(); y <= level.getMaxY(); y++) {
storedBlocks.append(chunk.getBlockState(new BlockPos(point.getX(), y, point.getZ()))).append('\n');
baseBlocks.append(base.getBlock(y)).append('\n');
if ((y & 3) == 0) {
biomes.append(chunk.getNoiseBiome(point.getX() >> 2, y >> 2, point.getZ() >> 2).getRegisteredName()).append('\n');
resolvedBiomes.append(resolver.getNoiseBiome(point.getX() >> 2, y >> 2, point.getZ() >> 2).getRegisteredName()).append('\n');
}
}
return Map.of("id", island.id(), "point", coordinates(point), "stored_column_sha256", sha256(storedBlocks.toString()),
"base_column_sha256", sha256(baseBlocks.toString()), "stored_biomes_sha256", sha256(biomes.toString()),
"resolved_biomes_sha256", sha256(resolvedBiomes.toString()), "base_height",
generator.getBaseHeight(point.getX(), point.getZ(), Heightmap.Types.WORLD_SURFACE_WG, level, random));
}
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, "Expected saved FULL chunk at " + new ChunkPos(x, z));
return (LevelChunk) chunk;
}
private static String sha256(String text) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); }
}
private static Path journalFile(ServerLevel level) {
return level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-expansion-v1").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");
}
private static void writeDiagnostic(ServerLevel level, boolean reload, Map<String, Object> report) throws IOException {
Path output = diagnostic(level, reload);
Files.createDirectories(output.getParent());
Files.writeString(output, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
}
private static BlockPos position(JsonArray coordinates) { return new BlockPos(coordinates.get(0).getAsInt(), coordinates.get(1).getAsInt(), coordinates.get(2).getAsInt()); }
private static List<Integer> coordinates(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
}
@@ -0,0 +1,140 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.ExactInterpolationChecks;
import fr.koka.sanctuary.worldgen.ExactInterpolationSampler;
import fr.koka.sanctuary.worldgen.IslandCapacity;
import fr.koka.sanctuary.worldgen.PopulationIslandDensity;
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.Random;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.densityfunction.DensityBuffer;
import net.minecraft.world.level.levelgen.densityfunction.DensitySampler;
import net.minecraft.world.level.levelgen.densityfunction.DensityVolume;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
/** Compares production against the frozen alpha.10 implementation; no chunks are created. */
final class PopulationDensityEquivalenceDiagnostics {
private record Pair(int players, long seed, DensitySampler reference, DensitySampler actual) {}
static void verify(GameTestHelper helper) throws Exception {
Map<String, Object> adversarial = ExactInterpolationChecks.verify();
var registries = helper.getLevel().registryAccess();
var noises = registries.lookupOrThrow(Registries.NOISE);
var densities = registries.lookupOrThrow(Registries.DENSITY_FUNCTION);
var settings = registries.lookupOrThrow(Registries.NOISE_SETTINGS);
List<Map<String, Object>> reports = new ArrayList<>();
List<Pair> pairs = new ArrayList<>();
for (int players : new int[]{5, 10, 20, 100}) {
var key = SanctuaryMod.id("population_" + players);
var registered = densities.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, key)).value();
helper.assertTrue(registered instanceof PopulationIslandDensity, "Compare the real registered Population density");
var density = (PopulationIslandDensity) registered;
helper.assertTrue(ExactInterpolationSampler.supportsTerrain(density.terrain()),
"The shipped coordinate-only terrain must actually use the exact cache, not the fallback");
var reference = new Alpha10PopulationDensityReference(density.terrain(), density.distortion(), density.sculpt(),
density.detail(), density.underside(), density.riftWarp(), density.riftDetail(), density.capacity());
for (long seed : new long[]{0, 42, 8675309}) {
var state = RandomState.create(noises, seed,
settings.getOrThrow(ResourceKey.create(Registries.NOISE_SETTINGS, key)).value());
var pair = new Pair(players, seed, state.getSampler(reference), state.getSampler(density));
pairs.add(pair);
SamplerContext context = SamplerContext.builder().enableCaches().build();
long comparisons = 0, digest = 0xcbf29ce484222325L;
for (var volume : volumes(IslandCapacity.fromPlayers(players))) {
DensityBuffer expected = DensityBuffer.createUnpooled(volume.size());
DensityBuffer actual = DensityBuffer.createUnpooled(volume.size());
pair.reference().sampleVolume(SamplerContext.EMPTY_UNCACHED, expected, volume);
pair.actual().sampleVolume(context, actual, volume);
for (int z = 0; z < volume.sizeZ(); z++) for (int x = 0; x < volume.sizeX(); x++) for (int y = 0; y < volume.sizeY(); y++) {
int index = volume.indexUnchecked(x, y, z);
int wx = volume.blockX(x), wy = volume.blockY(y), wz = volume.blockZ(z);
same(pair, expected.get(index), actual.get(index), wx, wy, wz, "bulk");
same(pair, expected.get(index), pair.actual().sampleValue(context, wx, wy, wz), wx, wy, wz, "scalar after bulk");
comparisons += 2;
digest = (digest ^ Float.floatToRawIntBits(expected.get(index))) * 0x100000001b3L;
}
}
Random order = new Random(seed ^ players);
int limit = density.capacity().terrainLimit();
for (int i = 0; i < 512; i++) {
int x = order.nextInt(limit * 2 + 33) - limit - 16;
int y = order.nextInt(400) - 8;
int z = order.nextInt(limit * 2 + 33) - limit - 16;
float expected = pair.reference().sampleValue(SamplerContext.EMPTY_UNCACHED, x, y, z);
same(pair, expected, pair.actual().sampleValue(SamplerContext.EMPTY_UNCACHED, x, y, z), x, y, z, "uncached context");
same(pair, expected, pair.actual().sampleValue(context, x, y, z), x, y, z, "context switch");
comparisons += 2;
}
reports.add(Map.of("players", players, "seed", seed, "bitwise_comparisons", comparisons,
"reference_density_digest", Long.toUnsignedString(digest, 16), "optimized_path_supported", true));
SanctuaryMod.LOGGER.info("Exact density equivalence: players {}, seed {}, {} bitwise comparisons", players, seed, comparisons);
}
}
long concurrentComparisons = 0;
try (var executor = Executors.newFixedThreadPool(3)) {
List<Callable<Long>> tasks = new ArrayList<>();
for (int worker = 0; worker < 3; worker++) {
int id = worker;
tasks.add(() -> {
long count = 0;
SamplerContext local = SamplerContext.builder().enableCaches().build();
// All workers share the same samplers and alternate worlds/seeds.
// Only their scratch buffers and sampler contexts are thread-local.
for (int round = 0; round < 3; round++) for (var pair : pairs) {
var volume = new DensityVolume(9, 17, 7, -13 + id, 89 + round, -7 - id);
DensityBuffer actual = DensityBuffer.createUnpooled(volume.size());
pair.actual().sampleVolume(local, actual, volume);
for (int z = 0; z < volume.sizeZ(); z++) for (int x = 0; x < volume.sizeX(); x++) for (int y = 0; y < volume.sizeY(); y++) {
int wx = volume.blockX(x), wy = volume.blockY(y), wz = volume.blockZ(z);
same(pair, pair.reference().sampleValue(local, wx, wy, wz),
actual.get(volume.indexUnchecked(x, y, z)), wx, wy, wz, "concurrent bulk");
count++;
}
}
return count;
});
}
for (var result : executor.invokeAll(tasks)) concurrentComparisons += result.get();
}
Path report = Path.of("diagnostics", "population-alpha10-density-equivalence.json");
Files.createDirectories(report.getParent());
Files.writeString(report, new GsonBuilder().setPrettyPrinting().create().toJson(Map.of(
"reference_commit", "a26b72f", "reference_source_sha256", "fcc5a8a39b7c4d181d74ce4f9183a1ca93646137955ac64bab9d22e6db0752a1",
"comparison", "Float.floatToRawIntBits; no tolerance", "profiles", reports, "adversarial", adversarial,
"concurrent_comparisons", concurrentComparisons, "concurrent_workers", 3, "passed", true,
"scope", "Real compiled scalar/bulk densities, 4 capacities x 3 seeds (5/20/100 compatibility and new10 using the same parameterized algorithm), signed coordinates, region/coast/height boundaries, context switches and concurrency; no block changes")) + "\n");
}
private static List<DensityVolume> volumes(IslandCapacity capacity) {
int limit = capacity.terrainLimit(), middle = (int) capacity.radius() / 2;
return List.of(
new DensityVolume(16, 384, 16, -8, 0, -8),
new DensityVolume(19, 11, 17, -263, 61, -259),
new DensityVolume(17, 17, 17, 249, 124, 249),
new DensityVolume(13, 21, 11, 761, 167, -9),
new DensityVolume(13, 21, 11, -775, 103, 503),
new DensityVolume(17, 49, 5, limit - 12, 96, -2),
new DensityVolume(17, 49, 5, -limit - 4, 96, -2),
new DensityVolume(12, 33, 12, middle - 7, 90, -middle / 2 - 5),
new DensityVolume(5, 13, 5, -2, -4, -2),
new DensityVolume(5, 13, 5, -2, 378, -2),
new DensityVolume(9, 13, 7, -257, 61, -9, 3, 5, 2));
}
private static void same(Pair pair, float expected, float actual, int x, int y, int z, String phase) {
if (Float.floatToRawIntBits(expected) != Float.floatToRawIntBits(actual))
throw new AssertionError("Density changed from alpha10 in " + phase + ": players=" + pair.players() + ", seed=" + pair.seed()
+ ", position=" + x + "," + y + "," + z + ", expected=" + Float.toHexString(expected) + ", actual=" + Float.toHexString(actual));
}
}
@@ -39,7 +39,7 @@ public final class PopulationDiagnostics {
var level = helper.getLevel();
var generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
var capacity = IslandCapacity.forGenerator(generator);
helper.assertTrue(capacity != null && capacity.players() == Integer.getInteger("sanctuary.test.players", 20),
helper.assertTrue(capacity != null && 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)
@@ -77,6 +77,7 @@ public final class PopulationDiagnostics {
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;
List<int[]> landCoordinates = new ArrayList<>();
Set<Integer> bottomHeights = new TreeSet<>();
StringBuilder map = new StringBuilder("x,z,bottom_y,top_y\n");
for (int x = -extent; x <= extent; x += step) for (int z = -extent; z <= extent; z += step) {
@@ -93,6 +94,7 @@ public final class PopulationDiagnostics {
}
if (top >= 0) {
solidColumns++;
landCoordinates.add(new int[]{x, z});
bottomHeights.add(bottom);
if (Math.hypot(x, z) > IslandShape.TERRAIN_LIMIT + 16) outerColumns++;
helper.assertTrue(Math.hypot(x, z) < capacity.terrainLimit(), "Terrain stays inside its finite declared boundary");
@@ -101,8 +103,20 @@ public final class PopulationDiagnostics {
}
helper.assertTrue(solidColumns > 100 && emptyColumns > 100 && bottomHeights.size() >= 4,
"The sampled island must contain natural masses, holes and a varied underside");
helper.assertTrue(capacity.players() == 5 ? outerColumns == 0 : outerColumns > 100,
"The larger capacities must actually contain land beyond the old island boundary");
int additionalColumns = 0;
if (capacity == IslandCapacity.TEN) {
var small = level.registryAccess().lookupOrThrow(Registries.DENSITY_FUNCTION).getOrThrow(
ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("population_5"))).value();
for (int[] coordinate : landCoordinates) {
boolean presentInSmall = false;
for (int y = 8; y < 384 && !presentInSmall; y += 8)
presentInSmall = random.sampleBlockValueUncached(small, coordinate[0], y, coordinate[1]) > 0;
if (!presentInSmall) additionalColumns++;
}
helper.assertTrue(additionalColumns > 0,
"Large must add real land columns compared with Small on the same seed and sample grid");
} else helper.assertTrue(capacity.players() == 5 ? outerColumns == 0 : outerColumns > 100,
"The historical large capacities must contain land beyond the old island boundary");
Path directory = Path.of("diagnostics");
Files.createDirectories(directory);
String prefix = "population-" + capacity.players() + "-seed-" + level.getSeed();
@@ -110,7 +124,8 @@ public final class PopulationDiagnostics {
Files.writeString(directory.resolve(prefix + "-density.json"), json(Map.of("players", capacity.players(),
"seed", level.getSeed(), "step", step, "solid_columns", solidColumns, "empty_columns", emptyColumns,
"solid_columns_beyond_old_boundary", outerColumns, "underside_heights", bottomHeights,
"scope", "sparse actual density samples; no resource extrapolation", "passed", true)));
"scope", "sparse actual density samples; no resource extrapolation", "passed", true,
"additional_columns_vs_small", capacity == IslandCapacity.TEN ? additionalColumns : -1)));
SanctuaryMod.LOGGER.info("Capacity {} density seed {}: {} land columns, {} beyond the old envelope",
capacity.players(), level.getSeed(), solidColumns, outerColumns);
}
@@ -253,7 +268,7 @@ public final class PopulationDiagnostics {
"ecology_patch_x",bestX,"ecology_patch_z",bestZ)));
}
helper.assertTrue(!expectedWater.isEmpty(), "The test must inspect retained production water");
if (capacity.players() > 5) helper.assertTrue(regions.stream().skip(1).anyMatch(r -> !r.water().cells().isEmpty()),
if (capacity.players() > 10) helper.assertTrue(regions.stream().skip(1).anyMatch(r -> !r.water().cells().isEmpty()),
"Hydrology must occur on newly available terrain, not only around the old central island");
SanctuaryMod.LOGGER.info("Capacity {}: preparing {} bounded FULL chunks",capacity.players(),requested.size());
for (long packed : requested) {
@@ -1,12 +1,16 @@
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.IslandCapacity;
import fr.koka.sanctuary.worldgen.PopulationIslandDensity;
import fr.koka.sanctuary.worldgen.SanctuarySpawn;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.RegistryOps;
import net.minecraft.tags.WorldPresetTags;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.WorldDimensions;
import net.minecraft.world.level.levelgen.WorldGenSettings;
@@ -19,6 +23,8 @@ 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;
/** Uses the real 26.3 saved-data writer and reader; never opens the user's saves. */
final class PopulationPersistenceDiagnostics {
@@ -28,14 +34,28 @@ 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.TWENTY,
"The public Sanctuary preset defaults to 20 planned players");
helper.assertTrue(IslandCapacity.forGenerator(standard.createWorldDimensions().overworld()) == IslandCapacity.TEN,
"The public Sanctuary preset defaults to the new large 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(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");
var oldDensity = (PopulationIslandDensity) access.lookupOrThrow(Registries.DENSITY_FUNCTION)
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("population_20"))).value();
var ops = RegistryOps.create(JsonOps.INSTANCE, access);
var serialized = PopulationIslandDensity.CODEC.codec().encodeStart(ops, oldDensity).getOrThrow().getAsJsonObject();
serialized.remove("players");
helper.assertTrue(PopulationIslandDensity.CODEC.codec().parse(ops, serialized).getOrThrow().capacity() == IslandCapacity.TWENTY,
"An omitted alpha10 density field must keep its historical default, independently of the new world selector");
List<Map<String, Object>> results = new ArrayList<>();
Path testRoot = Path.of("diagnostics", "population-persistence-seed-" + level.getSeed()).toAbsolutePath();
Files.createDirectories(testRoot);
LevelStorageSource storage = LevelStorageSource.createDefault(testRoot);
for (int players : new int[]{5, 20, 100}) {
String presetId = players == 20 ? "sanctuary" : "sanctuary_" + players;
for (int players : new int[]{5, 10, 20, 100}) {
String presetId = players == 10 ? "sanctuary" : "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),
@@ -68,7 +88,8 @@ final class PopulationPersistenceDiagnostics {
"An existing alpha.9 settings reference must not acquire the new default capacity");
}
Files.writeString(testRoot.resolve("round-trip.json"), new GsonBuilder().setPrettyPrinting().create().toJson(Map.of(
"seed", level.getSeed(), "default_players", 20, "profiles", results,
"legacy_settings_preserved", "sanctuary:sanctuary_cavern", "passed", true)) + "\n");
"seed", level.getSeed(), "default_players", 10, "profiles", results,
"legacy_settings_preserved", "sanctuary:sanctuary_cavern", "visible_presets", visible,
"omitted_density_capacity_preserved", 20, "passed", true)) + "\n");
}
}
@@ -32,6 +32,12 @@ import net.minecraft.world.level.storage.LevelData;
public final class SanctuaryWorldGameTests {
public static LevelData.RespawnData initialSpawn;
@GameTest(maxTicks = 3000)
public void optimizedDensityPreservesSavedTerrain(GameTestHelper helper) throws Exception {
PopulationDensityEquivalenceDiagnostics.verify(helper);
helper.succeed();
}
@GameTest(maxTicks = 200)
public void populationChoiceSurvivesSavingTheWorld(GameTestHelper helper) throws java.io.IOException {
PopulationPersistenceDiagnostics.verify(helper);
@@ -0,0 +1,107 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.IslandCapacity;
import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
/** Opt-in, repeatable server generation workload; it does not measure client rendering or FPS. */
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 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());
report.put("spawn", List.of(spawn.getX(), spawn.getY(), spawn.getZ()));
report.put("java", System.getProperty("java.version"));
report.put("processors", Runtime.getRuntime().availableProcessors());
report.put("max_heap_bytes", Runtime.getRuntime().maxMemory());
List<Map<String, Object>> rings = new ArrayList<>();
List<Long> chunkTimes = new ArrayList<>();
int count = 0, land = 0;
long areaStart = System.nanoTime();
// Centre outwards, 17x17 FULL chunks: a fixed eight-chunk radius around natural spawn.
for (int radius = 0; radius <= 8; radius++) {
long start = System.nanoTime();
for (int x = -radius; x <= radius; x++) for (int z = -radius; z <= radius; z++) {
if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue;
long before = System.nanoTime();
var chunk = full(helper, level, cx + x, cz + z);
chunkTimes.add(System.nanoTime() - before);
count++;
if (java.util.Arrays.stream(chunk.getSections()).anyMatch(s -> !s.hasOnlyAir())) land++;
}
long elapsed = System.nanoTime() - start;
rings.add(Map.of("radius", radius, "elapsed_ms", millis(elapsed), "cumulative_chunks", count));
SanctuaryMod.LOGGER.info("Generation benchmark {} seed {} ring {}: {} FULL chunks, {} ms",
capacity.players(), level.getSeed(), radius, count, millis(elapsed));
}
report.put("spawn_area_ms", millis(System.nanoTime() - areaStart));
report.put("spawn_area_full_chunks", count); report.put("spawn_area_nonempty_chunks", land);
report.put("rings", rings); report.put("spawn_area_chunk_latency_ms", latency(chunkTimes));
// Continue east through a regional boundary, with a five-chunk-wide corridor.
// This exposes stalls during exploration instead of timing only the initial region.
List<Map<String, Object>> steps = new ArrayList<>();
chunkTimes.clear();
long exploreStart = System.nanoTime();
for (int x = 9; x <= 24; x++) {
long start = System.nanoTime();
for (int z = -2; z <= 2; z++) {
long before = System.nanoTime();
full(helper, level, cx + x, cz + z);
chunkTimes.add(System.nanoTime() - before);
}
long elapsed = System.nanoTime() - start;
steps.add(Map.of("chunk_x", cx + x, "elapsed_ms", millis(elapsed)));
SanctuaryMod.LOGGER.info("Generation benchmark {} seed {} exploration column {}: {} ms",
capacity.players(), level.getSeed(), cx + x, millis(elapsed));
}
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("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");
Files.createDirectories(output.getParent());
Files.writeString(output, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
helper.succeed();
}
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, "Benchmark requires completed chunk " + new ChunkPos(x, z));
return (LevelChunk) chunk;
}
private static double millis(long nanos) { return nanos / 1_000_000.0; }
private static Map<String, Double> latency(List<Long> times) {
var sorted = times.stream().sorted().toList();
return Map.of("median", millis(sorted.get(sorted.size() / 2)),
"p95", millis(sorted.get((int) Math.ceil(sorted.size() * .95) - 1)),
"max", millis(sorted.getLast()));
}
}
@@ -17,6 +17,18 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Test-only: run the real Sanctuary datapack instead of the framework's flat fixture. */
@Mixin(GameTestServer.class)
public abstract class SanctuaryGameTestServerMixin {
// The framework otherwise runs thousands of empty ticks per second, exhausting an async
// 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")) {
long until = System.nanoTime() + 20_000_000L;
long remaining;
// Worker completion unparks the server; keep the pacing deadline despite those signals.
while ((remaining = until - System.nanoTime()) > 0) java.util.concurrent.locks.LockSupport.parkNanos(remaining);
}
}
@ModifyArg(method = "<clinit>", at = @At(value = "INVOKE", target =
"Lnet/minecraft/world/level/levelgen/WorldOptions;<init>(JZZ)V"), index = 0)
private static long sanctuary$testSeed(long original) {
@@ -32,12 +44,14 @@ 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() {
int players = Integer.getInteger("sanctuary.test.players", 20);
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 20 -> "sanctuary";
case 10 -> "sanctuary";
case 20 -> "sanctuary_20";
case 100 -> "sanctuary_100";
default -> throw new IllegalArgumentException("Test capacity must be 5, 20 or 100");
default -> throw new IllegalArgumentException("Test geometry must be 5, 10, 20 or 100");
};
return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(preset));
}
@@ -46,6 +60,7 @@ public abstract class SanctuaryGameTestServerMixin {
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());
}
@@ -0,0 +1,186 @@
package fr.koka.sanctuary.worldgen;
import com.mojang.serialization.MapCodec;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import net.minecraft.core.Holder;
import net.minecraft.resources.Identifier;
import net.minecraft.util.Interval;
import net.minecraft.util.RandomSource;
import net.minecraft.util.context.ContextKey;
import net.minecraft.util.context.ContextMap;
import net.minecraft.world.level.levelgen.densityfunction.*;
import net.minecraft.world.level.levelgen.densityfunction.op.InterpolatedFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.UnaryFunction;
import net.minecraft.world.level.levelgen.synth.Noise;
import net.minecraft.world.level.levelgen.synth.NormalNoise;
/** Adversarial checks against the actual vanilla scalar implementation, requiring no chunks. */
public final class ExactInterpolationChecks {
private static final ContextKey<Float> SHIFT = ContextKey.vanilla("sanctuary_test_interpolation_shift");
private static final ContextKey<float[]> MUTABLE = ContextKey.vanilla("sanctuary_test_mutable_interpolation_shift");
private static final float[] NO_MUTATION = {0};
private static final DensityFunction.CompileContext COMPILE = new DensityFunction.CompileContext() {
@Override public Noise createNoiseSampler(Holder<NormalNoise> noise) { throw new AssertionError("No noise needed"); }
@Override public RandomSource createRandom(Identifier id) { throw new AssertionError("No random needed"); }
@Override public RandomSource createEndIslandRandom() { throw new AssertionError("No End needed"); }
};
private ExactInterpolationChecks() {}
public static Map<String, Object> verify() throws Exception {
long checked = 0;
for (int[] sizes : new int[][]{{8, 4}, {3, 5}, {1, 1}}) {
var input = new Probe(31);
DensityFunction expression = expression(input, sizes[0], sizes[1]);
var reference = expression.compileSampler(COMPILE);
var cached = cached(input, sizes[0], sizes[1]);
SamplerContext first = context(0), second = context(0.0625f);
var points = new ArrayList<int[]>();
for (int x = -13; x <= 13; x++) for (int y = -7; y <= 11; y++) for (int z = -9; z <= 9; z++)
points.add(new int[]{x, y, z});
Collections.shuffle(points, new Random(73));
for (var context : List.of(first, second, first)) for (var p : points) {
same(reference.sampleValue(context, p[0], p[1], p[2]), cached.sampleValue(context, p[0], p[1], p[2]), "scalar/context");
checked++;
}
var volume = new DensityVolume(13, 17, 11, -19, -9, -17, 3, 2, 5);
DensityBuffer actual = DensityBuffer.createUnpooled(volume.size());
cached.sampleVolume(first, actual, volume);
for (int z = 0; z < volume.sizeZ(); z++) for (int x = 0; x < volume.sizeX(); x++) for (int y = 0; y < volume.sizeY(); y++) {
same(reference.sampleValue(first, volume.blockX(x), volume.blockY(y), volume.blockZ(z)),
actual.get(volume.indexUnchecked(x, y, z)), "non-unit volume");
checked++;
}
}
// A scalar and a bulk input intentionally differ in Probe. This catches accidental
// replacement of the aligned scalar branch with one of the cached lattice corners.
var counted = new Probe(91);
var cached = cached(counted, 8, 4);
var stable = context(0);
for (int repeat = 0; repeat < 2; repeat++)
for (int x = 1; x < 8; x++) for (int y = 1; y < 4; y++) for (int z = 1; z < 8; z++) cached.sampleValue(stable, x, y, z);
require(counted.volumeCalls.get() == 1, "A reused cell must sample its eight corners only once");
int alignedBefore = counted.scalarCalls.get();
cached.sampleValue(stable, 0, 0, 0);
require(counted.scalarCalls.get() == alignedBefore + 1, "Aligned nodes retain their separate scalar input path");
var reference = expression(counted, 8, 4).compileSampler(COMPILE);
// More distinct cells than the bounded cache holds; collisions and replacement must
// remain invisible to both forward and reverse traversals.
for (int direction : new int[]{1, -1}) for (int index = 0; index < 9000; index++) {
int i = direction > 0 ? index : 8999 - index;
int x = (i % 113 - 56) * 8 + 1, y = ((i / 113) % 97 - 48) * 4 + 1, z = (i % 67 - 33) * 8 + 3;
same(reference.sampleValue(stable, x, y, z), cached.sampleValue(stable, x, y, z), "cache replacement");
checked++;
}
var other = cached(new Probe(999), 8, 4);
var otherReference = expression(new Probe(999), 8, 4).compileSampler(COMPILE);
for (int i = 0; i < 128; i++) {
same(reference.sampleValue(stable, i - 67, 21, -3), cached.sampleValue(stable, i - 67, 21, -3), "sampler identity A");
same(otherReference.sampleValue(stable, i - 67, 21, -3), other.sampleValue(stable, i - 67, 21, -3), "sampler identity B");
checked += 2;
}
try (var executor = Executors.newFixedThreadPool(3)) {
List<Callable<Integer>> tasks = new ArrayList<>();
for (int worker = 0; worker < 3; worker++) {
final int id = worker;
tasks.add(() -> {
var local = context(id * 0.03125f);
for (int i = 0; i < 3000; i++) {
int x = (i * 17 + id) % 251 - 125, y = i % 384, z = (i * 37 - id) % 257 - 128;
same(reference.sampleValue(local, x, y, z), cached.sampleValue(local, x, y, z), "concurrent shared sampler");
}
return 3000;
});
}
for (var result : executor.invokeAll(tasks)) checked += result.get();
}
var plain = new ProbeFunction(new Probe(42));
require(ExactInterpolationSampler.compileTerrain(plain, COMPILE) == plain.probe,
"Unrecognized expressions must preserve their original sampler");
require(!ExactInterpolationSampler.supportsTerrain(expression(new Probe(42), 8, 4)),
"An opaque/contextual input must not be cached merely because its outer expression matches");
float[] payload = {0};
var mutable = SamplerContext.builder().setUserFields(ContextMap.builder().set(MUTABLE, payload).build()).build();
var fallback = ExactInterpolationSampler.compileTerrain(expression(new Probe(42), 8, 4), COMPILE);
float before = fallback.sampleValue(mutable, 1, 2, 3);
payload[0] = 0.125f;
float after = fallback.sampleValue(mutable, 1, 2, 3);
require(Float.floatToRawIntBits(before) != Float.floatToRawIntBits(after),
"The fallback must observe mutable context payloads even when context identity stays the same");
var nestedInput = new Probe(817);
var nested = cached(nestedInput, 8, 4);
var nestedReference = expression(new Probe(817), 8, 4).compileSampler(COMPILE);
var outerInput = new Probe(83);
outerInput.nested = nested;
var outer = cached(outerInput, 8, 4);
var outerReference = expression(outerInput, 8, 4).compileSampler(COMPILE);
for (int i = 0; i < 128; i++) {
int x = i - 65;
same(outerReference.sampleValue(stable, x, 23, -7), outer.sampleValue(stable, x, 23, -7), "nested outer request");
same(nestedReference.sampleValue(stable, x, 23, -7), nested.sampleValue(stable, x, 23, -7), "nested cache identity");
checked += 2;
}
return Map.of("bitwise_comparisons", checked, "repeated_cell_corner_batches", 1,
"concurrent_workers", 3, "aligned_scalar_path_preserved", true, "passed", true);
}
private static DensityFunction expression(Probe probe, int xz, int y) {
return new UnaryFunction(UnaryFunction.Type.SQUEEZE, new InterpolatedFunction(new ProbeFunction(probe), xz, y));
}
private static DensitySampler cached(Probe probe, int xz, int y) {
return new UnaryFunction.SqueezeSampler(new ExactInterpolationSampler(probe, xz, y));
}
private static SamplerContext context(float shift) {
return SamplerContext.builder().setUserFields(ContextMap.builder().set(SHIFT, shift).build()).build();
}
private record ProbeFunction(Probe probe) implements DensityFunction {
@Override public DensitySampler compileSampler(CompileContext context) { return probe; }
@Override public DensityFunction rewriteChildren(DfRewriteRule rule) { return this; }
@Override public Interval range() { return Interval.of(-2, 2); }
@Override public int domainAxes() { return ALL_AXES; }
@Override public MapCodec<ProbeFunction> codec() { return MapCodec.unit(this); }
}
private static final class Probe implements DensitySampler {
final int seed;
final AtomicInteger scalarCalls = new AtomicInteger(), volumeCalls = new AtomicInteger();
DensitySampler nested;
Probe(int seed) { this.seed = seed; }
private float value(SamplerContext context, int x, int y, int z) {
int hash = (x * 73428767 ^ y * 912931 ^ z * 4382893 ^ seed);
hash ^= hash >>> 15;
float[] mutable = context.getFieldOrDefault(MUTABLE, NO_MUTATION);
return Float.intBitsToFloat(0x3e000000 | (hash & 0x007fffff)) - 0.19f
+ context.getFieldOrDefault(SHIFT, 0f) + mutable[0];
}
@Override public float sampleValue(SamplerContext context, int x, int y, int z) {
scalarCalls.incrementAndGet();
return value(context, x, y, z) + 0.03125f;
}
@Override public void sampleVolume(SamplerContext context, DensityBuffer buffer, DensityVolume volume) {
volumeCalls.incrementAndGet();
if (nested != null) nested.sampleValue(context, volume.minBlockX() + 1, volume.minBlockY() + 1, volume.minBlockZ() + 1);
for (int z = 0; z < volume.sizeZ(); z++) for (int x = 0; x < volume.sizeX(); x++) for (int y = 0; y < volume.sizeY(); y++)
buffer.set(volume.indexUnchecked(x, y, z), value(context, volume.blockX(x), volume.blockY(y), volume.blockZ(z)));
}
}
private static void same(float expected, float actual, String label) {
if (Float.floatToRawIntBits(expected) != Float.floatToRawIntBits(actual))
throw new AssertionError(label + ": expected " + Float.toHexString(expected) + ", actual " + Float.toHexString(actual));
}
private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); }
}
@@ -6,7 +6,7 @@
"environment": "*",
"license": "GPL-3.0-or-later",
"entrypoints": {
"fabric-gametest": ["fr.koka.sanctuary.gametest.SanctuaryWorldGameTests"],
"fabric-gametest": ["${gametest_entrypoint}"],
"fabric-client-gametest": ["fr.koka.sanctuary.gametest.SanctuaryClientRenderTests"]
},
"mixins": ["sanctuary-gametest.mixins.json"],
@@ -40,6 +40,11 @@ public final class SanctuaryMod implements ModInitializer {
@Override
public void onInitialize() {
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("expansion_lab"), fr.koka.sanctuary.expansion.ExpansionChunkGenerator.CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("expansion_lab"), fr.koka.sanctuary.expansion.ExpansionBiomeSource.CODEC);
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("expansion_island"), fr.koka.sanctuary.expansion.ExpansionDensity.CODEC);
fr.koka.sanctuary.expansion.ExpansionRuntime.register();
fr.koka.sanctuary.expansion.ExpansionCommands.register();
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("main_island"), MainIslandDensity.CODEC);
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("population_island"), PopulationIslandDensity.CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("population_island"), PopulationIslandBiomeSource.CODEC);
@@ -0,0 +1,67 @@
package fr.koka.sanctuary.expansion;
import fr.koka.sanctuary.mixin.ExpansionChunkMapAccessor;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ChunkMap;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
/** Conservative admission for fresh regions only; inspecting a position never requests generation. */
public final class ExpansionAdmission {
private static final int MAX_CHUNKS = 4_000;
private static final int READ_BATCH = 64;
private static final long DEADLINE_NANOS = TimeUnit.SECONDS.toNanos(30);
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");
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;
for (int start = 0; start < positions.size(); start += READ_BATCH) {
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)));
try {
for (int i = 0; i < reads.size(); i++) {
long remaining = deadline - 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));
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Expansion admission interrupted", e);
} catch (ExecutionException | TimeoutException e) {
throw new IOException("Expansion could not prove that every reserved chunk is absent", e);
}
}
for (ChunkPos position : positions) rejectHolder(map, position);
// Caller must commit and publish on this same server turn, without yielding or processing ticks.
}
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);
}
}
}
@@ -0,0 +1,138 @@
package fr.koka.sanctuary.expansion;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import net.minecraft.core.Holder;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeResolver;
import net.minecraft.world.level.biome.BiomeSource;
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 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",
"old_growth_spruce_taiga", "dark_forest", "swamp", "pale_garden", "jungle",
"sparse_jungle", "bamboo_jungle", "mangrove_swamp", "savanna", "savanna_plateau",
"desert", "badlands", "eroded_badlands", "wooded_badlands", "windswept_hills",
"windswept_gravelly_hills", "stony_peaks", "dappled_forest", "cherry_grove",
"lush_caves", "dripstone_caves", "sulfur_caves"
).map(path -> "minecraft:" + path).toList();
public static final MapCodec<ExpansionBiomeSource> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.biomes)
).apply(i, ExpansionBiomeSource::new));
private final List<Holder<Biome>> biomes;
private final Map<String, Holder<Biome>> byName;
private volatile List<ExpansionIsland> islands = List.of();
public ExpansionBiomeSource(List<Holder<Biome>> biomes) {
this.biomes = List.copyOf(biomes);
var names = new HashMap<String, Holder<Biome>>();
for (Holder<Biome> biome : biomes) {
String name = biome.unwrapKey().orElseThrow(() -> new IllegalArgumentException("Expansion biomes must be registered holders"))
.identifier().toString();
if (names.put(name, biome) != null) throw new IllegalArgumentException("Duplicate expansion biome: " + name);
}
for (String required : REQUIRED_BIOMES) {
if (!names.containsKey(required)) throw new IllegalArgumentException("Missing expansion biome: " + required);
}
this.byName = Map.copyOf(names);
}
public void setIslands(List<ExpansionIsland> islands) { this.islands = List.copyOf(islands); }
public List<ExpansionIsland> islands() { return islands; }
public @Nullable ExpansionIsland islandAt(int x, int z) {
return islandAt(islands, x, z);
}
private static @Nullable ExpansionIsland islandAt(List<ExpansionIsland> snapshot, int x, int z) {
for (ExpansionIsland island : snapshot) if (island.contains(x, z)) return island;
return null;
}
@Override protected MapCodec<ExpansionBiomeSource> codec() { return CODEC; }
@Override protected Stream<Holder<Biome>> collectPossibleBiomes() { return biomes.stream(); }
@Override
public BiomeResolver createResolver(Climate.Sampler sampler) {
// General-purpose resolvers may outlive an activation. Resolve against the current snapshot.
return (quartX, quartY, quartZ) -> resolve(islands, quartX * 4, quartY * 4, quartZ * 4);
}
@Override
public BiomeResolver createResolverForChunk(Climate.Sampler sampler, int minQuartX, int minQuartY,
int minQuartZ, int quartSizeX, int quartSizeY, int quartSizeZ) {
List<ExpansionIsland> snapshot = islands;
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) {
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);
double localX = (x - island.centerX()) / scale, localZ = (z - island.centerZ()) / scale;
double variation = noise(island.seed(), localX, localZ);
double moisture = noise(island.seed() ^ 0xA12C11A7EL, localX * 0.8, localZ * 0.8);
double elevation = y - variation * 14 - moisture * 8;
// Small pockets preserve the surface profile on exposed lower ledges.
if (y < 168 && elevation < 162) {
if ((island.relief().equals("volcano") && variation > -0.4)
|| (variation > 0.22 && variation < 0.46 && moisture < -0.18)) return biome("sulfur_caves");
if (moisture > 0.14 && variation > -0.38) return biome("lush_caves");
if (moisture < -0.18 && variation < 0.2) return biome("dripstone_caves");
}
String selected = switch (island.climate()) {
case "temperate" -> 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";
case "cold_humid" -> elevation > 250 ? "grove" : variation < -0.16 ? "taiga" : "old_growth_spruce_taiga";
case "humid" -> moisture > 0.4 && variation < 0 ? "pale_garden"
: elevation < 222 && variation < 0.14 ? "swamp" : "dark_forest";
case "tropical" -> elevation < 218 && moisture > 0.15 ? "mangrove_swamp"
: variation > 0.3 ? "bamboo_jungle" : variation < -0.34 ? "sparse_jungle" : "jungle";
case "warm" -> elevation > 244 ? "savanna_plateau" : moisture > 0.46 ? "sparse_jungle" : "savanna";
case "arid" -> variation < -0.2 ? "desert" : variation > 0.4 ? "eroded_badlands"
: moisture > 0.36 ? "wooded_badlands" : "badlands";
case "dry" -> elevation > 250 ? "stony_peaks" : variation > 0.28 ? "windswept_hills"
: moisture < -0.38 ? "windswept_gravelly_hills" : "plains";
case "cold_dry" -> elevation > 250 ? "frozen_peaks" : variation > 0.12 ? "snowy_plains" : "windswept_gravelly_hills";
case "autumn" -> Math.abs(variation) < 0.12 ? "meadow" : variation < -0.52 ? "birch_forest" : "dappled_forest";
case "cherry" -> elevation > 258 ? "grove" : variation < -0.22 ? "meadow" : "cherry_grove";
default -> throw new IllegalStateException("Unvalidated expansion climate: " + island.climate());
};
return biome(selected);
}
private Holder<Biome> biome(String path) { return byName.get("minecraft:" + path); }
private static double noise(long seed, double x, double z) {
int floorX = (int) Math.floor(x), floorZ = (int) Math.floor(z);
double tx = fade(x - floorX), tz = fade(z - floorZ);
double north = mix(lattice(seed, floorX, floorZ), lattice(seed, floorX + 1, floorZ), tx);
double south = mix(lattice(seed, floorX, floorZ + 1), lattice(seed, floorX + 1, floorZ + 1), tx);
return mix(north, south, tz);
}
private static double lattice(long seed, int x, int z) {
long value = seed ^ (x * 0x9E3779B97F4A7C15L) ^ (z * 0xC2B2AE3D27D4EB4FL);
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
value ^= value >>> 31;
return (value >>> 11) * 0x1.0p-52 - 1.0;
}
private static double fade(double value) { return value * value * (3 - 2 * value); }
private static double mix(double start, double end, double amount) { return start + (end - start) * amount; }
}
@@ -0,0 +1,146 @@
package fr.koka.sanctuary.expansion;
import com.mojang.serialization.Codec;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.NoiseColumn;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
import net.minecraft.world.level.levelgen.NoiseRouter;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.blending.Blender;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
/** 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 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;
private final Map<ExpansionIsland, Context> contexts = new ConcurrentHashMap<>();
private record Context(NoiseBasedChunkGenerator generator, RandomState random) {}
public ExpansionChunkGenerator(ExpansionBiomeSource source, int initialDiameter) {
super(source);
if (initialDiameter != 512 && initialDiameter != 724) throw new IllegalArgumentException("Laboratory diameter must be 512 or 724");
this.source = source;
this.initialDiameter = initialDiameter;
}
public ExpansionBiomeSource source() { return source; }
public int initialDiameter() { return initialDiameter; }
@Override protected MapCodec<ExpansionChunkGenerator> codec() { return CODEC; }
public void initialize(ServerLevel level, List<ExpansionIsland> islands) {
registries = level.registryAccess();
source.setIslands(islands);
}
public void setIslands(List<ExpansionIsland> islands) { source.setIslands(islands); }
public void discardUnpublishedContext(ExpansionIsland island) {
var key = island.ready();
if (source.islands().stream().noneMatch(saved -> saved.ready().equals(key))) contexts.remove(key);
}
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) {
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";
var settingsRegistry = registries.lookupOrThrow(Registries.NOISE_SETTINGS);
var base = settingsRegistry.getValue(Identifier.fromNamespaceAndPath("sanctuary", baseKey));
var vanilla = settingsRegistry.getValue(Identifier.withDefaultNamespace("overworld"));
if (base == null || vanilla == null) throw new IllegalStateException("Missing laboratory noise settings");
var router = base.noiseRouter();
var density = new ExpansionDensity(router.finalDensity(), island);
var translated = new NoiseRouter(router.temperature(), router.vegetation(), router.continents(), router.erosion(),
router.depth(), router.ridges(), router.chunkSurfaceLevel(), density);
var settings = new NoiseGeneratorSettings(base.noiseSettings(), base.defaultBlock(), Blocks.AIR.defaultBlockState(),
translated, vanilla.materialRule(), List.of(), -64, false, Optional.empty(), base.useLegacyRandomSource(), base.debugFunctions());
var delegate = new NoiseBasedChunkGenerator(source, Holder.direct(settings));
var random = RandomState.create(registries.lookupOrThrow(Registries.NOISE), island.seed(), settings);
return new Context(delegate, random);
});
}
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) {
var island = islandForChunk(chunk.getPos().x(), chunk.getPos().z());
if (island == null) return super.createBiomes(ignored, blender, structures, chunk);
var context = context(island);
return context.generator().createBiomes(context.random(), blender, structures, chunk);
}
@Override public CompletableFuture<ChunkAccess> buildTerrain(ChunkAccess chunk, Blender blender, RandomState ignored,
StructureManager structures, BiomeManager manager, WorldGenRegion region, Set<Holder<Biome>> biomes) {
var island = islandForChunk(chunk.getPos().x(), chunk.getPos().z());
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);
return generated;
});
}
@Override public void createStructures(RegistryAccess registries, ChunkGeneratorStructureState state, StructureManager structures,
ChunkAccess chunk, StructureTemplateManager templates, ResourceKey<Level> dimension) {
ExpansionStructures.generate(this, registries, state, structures, chunk, templates, dimension);
}
@Override public void spawnOriginalMobs(WorldGenRegion region) {
var pos = region.getCenter();
var island = islandForChunk(pos.x(), pos.z());
if (island != null) context(island).generator().spawnOriginalMobs(region);
}
@Override public int getGenDepth() { return 384; }
@Override public int getMinY() { return 0; }
@Override public int getSeaLevel() { return -64; }
@Override public int getSpawnHeight(LevelHeightAccessor level) { return 220; }
@Override public int getBaseHeight(int x, int z, Heightmap.Types type, LevelHeightAccessor level, RandomState ignored) {
var island = islandAt(x, z);
if (island == null) return getMinY();
var context = context(island);
return context.generator().getBaseHeight(x, z, type, level, context.random());
}
@Override public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor level, RandomState ignored) {
var island = islandAt(x, z);
if (island != null) {
var context = context(island);
return context.generator().getBaseColumn(x, z, level, context.random());
}
BlockState[] air = new BlockState[384];
java.util.Arrays.fill(air, Blocks.AIR.defaultBlockState());
return new NoiseColumn(0, air);
}
@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());
}
}
@@ -0,0 +1,129 @@
package fr.koka.sanctuary.expansion;
import com.mojang.brigadier.Command;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import java.util.List;
import java.util.Set;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.commands.SharedSuggestionProvider;
import net.minecraft.network.chat.Component;
/** OP tools for the experimental world, also usable from the dedicated server console. */
public final class ExpansionCommands {
private ExpansionCommands() {}
private static final String PREFIX = "sanctuary.expansion.";
public static void register() {
CommandRegistrationCallback.EVENT.register((dispatcher, registries, environment) -> {
var expansion = Commands.literal("expansion").executes(ExpansionCommands::help)
.then(Commands.literal("help").executes(ExpansionCommands::help))
.then(Commands.literal("list").executes(c -> run(c, () -> {
var session = ExpansionRuntime.session(c.getSource().getLevel());
for (var island : session.islands()) c.getSource().sendSuccess(() -> Component.translatable(PREFIX + "entry", island.id(), island.parent(),
label("direction", island.direction()), label("climate", island.climate()), island.diameter(), label("relief", island.relief()), island.centerX(), island.centerZ(),
Component.translatable(PREFIX + "state." + island.status())), false);
c.getSource().sendSuccess(() -> Component.translatable(PREFIX + "progress", session.progress()), false);
return session.islands().size();
})))
.then(Commands.literal("visit").then(islandArgument().executes(c -> run(c, () -> {
var player = c.getSource().getPlayerOrException();
if (player.isPassenger()) throw new IllegalArgumentException("dismount_first");
var landing = ExpansionRuntime.session(c.getSource().getLevel()).landing(word(c, "island"));
boolean moved = player.teleportTo(c.getSource().getLevel(), landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
Set.of(), player.getYRot(), player.getXRot(), true);
if (!moved) throw new IllegalArgumentException("teleport_failed");
player.fallDistance = 0;
c.getSource().sendSuccess(() -> Component.translatable(PREFIX + "visited", word(c, "island")), false);
return Command.SINGLE_SUCCESS;
}))))
.then(Commands.literal("resume").then(islandArgument().executes(c -> run(c, () -> {
ExpansionRuntime.session(c.getSource().getLevel()).resume(word(c, "island"));
c.getSource().sendSuccess(() -> Component.translatable(PREFIX + "resumed", word(c, "island")), false);
return Command.SINGLE_SUCCESS;
}))))
.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"))
.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"), 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"))))))));
dispatcher.register(Commands.literal("sanctuary").requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)).then(expansion));
});
}
private static com.mojang.brigadier.builder.RequiredArgumentBuilder<CommandSourceStack, String> islandArgument() {
return Commands.argument("island", StringArgumentType.word()).suggests((c, b) -> {
try { return SharedSuggestionProvider.suggest(ExpansionRuntime.session(c.getSource().getLevel()).islands().stream().map(ExpansionIsland::id), b); }
catch (IllegalArgumentException ignored) { return b.buildFuture(); }
});
}
private static LiteralArgumentBuilder<CommandSourceStack> createTree(String name, boolean preview) {
return Commands.literal(name).then(Commands.argument("id", StringArgumentType.word())
.then(Commands.argument("parent", StringArgumentType.word())
.suggests((c, b) -> {
try { return SharedSuggestionProvider.suggest(ExpansionRuntime.session(c.getSource().getLevel()).islands().stream().map(ExpansionIsland::id), b); }
catch (IllegalArgumentException ignored) { return b.buildFuture(); }
})
.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("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;
}))))))));
}
private static int quick(CommandContext<CommandSourceStack> c, String climate, int diameter, String relief) {
return run(c, () -> {
var session = ExpansionRuntime.session(c.getSource().getLevel());
int next = session.islands().size();
while (true) {
String possible = "island_" + next;
if (session.islands().stream().noneMatch(i -> i.id().equals(possible))) break;
next++;
}
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);
describe(c, island, false);
return Command.SINGLE_SUCCESS;
});
}
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);
}
private static int help(CommandContext<CommandSourceStack> context) {
context.getSource().sendSuccess(() -> Component.translatable(PREFIX + "help"), false);
return Command.SINGLE_SUCCESS;
}
private static String word(CommandContext<CommandSourceStack> context, String key) { return StringArgumentType.getString(context, key); }
private static Component label(String category, String id) { return Component.translatable(PREFIX + category + "." + id); }
@FunctionalInterface private interface Action { int run() throws Exception; }
private static int run(CommandContext<CommandSourceStack> context, Action action) {
try { return action.run(); }
catch (Exception exception) {
String reason = exception.getMessage() == null ? exception.getClass().getSimpleName() : exception.getMessage();
Component detail = reason.matches("[a-z_]+") ? Component.translatable(PREFIX + "error." + reason) : Component.literal(reason);
context.getSource().sendFailure(Component.translatable(PREFIX + "error", detail));
return 0;
}
}
}
@@ -0,0 +1,76 @@
package fr.koka.sanctuary.expansion;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.Arrays;
import net.minecraft.util.Interval;
import net.minecraft.world.level.levelgen.densityfunction.DensityBuffer;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
import net.minecraft.world.level.levelgen.densityfunction.DensitySampler;
import net.minecraft.world.level.levelgen.densityfunction.DensityVolume;
import net.minecraft.world.level.levelgen.densityfunction.DfRewriteRule;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
/** Versioned alpha.12 wrapper around a complete historical density, sampled in its own island frame. */
public record ExpansionDensity(DensityFunction source, ExpansionIsland island) implements DensityFunction {
public static final MapCodec<ExpansionDensity> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
DensityFunction.CODEC.fieldOf("source").forGetter(ExpansionDensity::source),
ExpansionIsland.CODEC.fieldOf("island").forGetter(ExpansionDensity::island)
).apply(i, ExpansionDensity::new));
// One bounded numeric/descriptor cache per worker. It holds no sampler, context or world.
private static final ThreadLocal<ColumnCache> COLUMNS = ThreadLocal.withInitial(ColumnCache::new);
@Override public DensitySampler compileSampler(CompileContext context) {
DensitySampler original = source.compileSampler(context);
return new DensitySampler() {
@Override public float sampleValue(SamplerContext sample, int x, int y, int z) {
if (y <= 0 || y >= 384 || !island.contains(x, z)) return -1.0F;
ExpansionRelief.Column column = COLUMNS.get().get(island, x, z);
double sy = ExpansionRelief.sourceY(island, column, y);
if (sy <= 0 || sy >= 384) return -1.0F;
int sx = ExpansionRelief.sourceX(island, x), sz = ExpansionRelief.sourceZ(island, z);
int lowerY = (int) Math.floor(sy);
float low = original.sampleValue(sample, sx, lowerY, sz);
double fraction = sy - lowerY;
// Below the root's upper transition this is precisely the old single sample.
// Fractional Y elsewhere is interpolated, avoiding one-block terraces from rounding.
float density = fraction == 0.0 ? low : (float) (low
+ (original.sampleValue(sample, sx, lowerY + 1, sz) - low) * fraction);
return ExpansionRelief.sculpt(island, column, y, Math.clamp(density, -1.0F, 1.0F));
}
@Override public void sampleVolume(SamplerContext sample, DensityBuffer buffer, DensityVolume volume) {
DensitySampler.sampleVolumeNaive(sample, buffer, volume, this);
}
};
}
@Override public DensityFunction rewriteChildren(DfRewriteRule rule) {
return new ExpansionDensity(rule.rewrite(source), island);
}
@Override public Interval range() { return Interval.of(-1.0F, 1.0F); }
@Override public int domainAxes() { return ALL_AXES; }
@Override public MapCodec<ExpansionDensity> codec() { return CODEC; }
private static final class ColumnCache {
private static final int SIZE = 256;
private ExpansionIsland selected;
private final int[] xs = new int[SIZE], zs = new int[SIZE];
private final ExpansionRelief.Column[] columns = new ExpansionRelief.Column[SIZE];
ExpansionRelief.Column get(ExpansionIsland island, int x, int z) {
if (selected != island) {
selected = island;
Arrays.fill(columns, null);
}
int hash = x * 0x9e3779b9 ^ Integer.rotateLeft(z * 0x85ebca6b, 13);
int slot = (hash ^ (hash >>> 16)) & (SIZE - 1);
if (columns[slot] == null || xs[slot] != x || zs[slot] != z) {
columns[slot] = ExpansionRelief.column(island, x, z);
xs[slot] = x; zs[slot] = z;
}
return columns[slot];
}
}
}
@@ -0,0 +1,75 @@
package fr.koka.sanctuary.expansion;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
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) {
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> RELIEFS = List.of("natural", "peaks", "canyon", "volcano");
public static final List<Integer> DIAMETERS = List.of(64, 128, 256, 512);
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(
Codec.STRING.fieldOf("id").forGetter(ExpansionIsland::id),
Codec.STRING.fieldOf("parent").forGetter(ExpansionIsland::parent),
Codec.STRING.fieldOf("direction").forGetter(ExpansionIsland::direction),
Codec.STRING.fieldOf("climate").forGetter(ExpansionIsland::climate),
Codec.INT.fieldOf("diameter").forGetter(ExpansionIsland::diameter),
Codec.INT.fieldOf("center_x").forGetter(ExpansionIsland::centerX),
Codec.INT.fieldOf("center_z").forGetter(ExpansionIsland::centerZ),
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)
).apply(i, ExpansionIsland::new));
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 (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");
}
public static ExpansionIsland origin(int diameter, long seed) {
return new ExpansionIsland("sanctuary", "sanctuary", "north", "temperate", diameter, 0, 0, seed, "natural", "ready", GENERATION);
}
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 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);
int z = Math.clamp(centerZ, chunkZ * 16, chunkZ * 16 + 15);
return Math.hypot((double) x - centerX, (double) z - centerZ) <= terrainRadius() + DECORATION_MARGIN;
}
public boolean conflicts(ExpansionIsland 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 static String directionalClimate(String direction) {
return switch (direction) {
case "north" -> "boreal"; case "northeast" -> "cold_humid"; case "east" -> "humid";
case "southeast" -> "tropical"; case "south" -> "warm"; case "southwest" -> "arid";
case "west" -> "dry"; case "northwest" -> "cold_dry";
default -> throw new IllegalArgumentException("Unknown direction");
};
}
public static long deriveSeed(long seed, String id) {
long h = seed ^ 0xA12E7A510L;
for (int c : id.toCharArray()) h = (h ^ c) * 0x100000001b3L;
h = (h ^ (h >>> 30)) * 0xbf58476d1ce4e5b9L;
h = (h ^ (h >>> 27)) * 0x94d049bb133111ebL;
return h ^ (h >>> 31);
}
}
@@ -0,0 +1,239 @@
package fr.koka.sanctuary.expansion;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import com.google.gson.Strictness;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import java.io.IOException;
import java.io.StringReader;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** A failed or missing existing journal is never replaced with a fresh expansion history. */
public final class ExpansionJournal {
public static final String FILE_NAME = "expansions.json";
public static final int MAX_ISLANDS = 64;
private static final int MAX_BYTES = 131_072;
private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create();
private static final Set<String> JOURNAL_FIELDS = Set.of("schema", "world_seed", "root_diameter", "islands");
private static final Set<String> ISLAND_FIELDS = Set.of("id", "parent", "direction", "climate", "diameter", "center_x", "center_z", "seed", "relief", "status", "generation");
private final Path directory;
private final Path file;
private final long seed;
private final int rootDiameter;
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 = directory.toAbsolutePath().normalize();
this.file = this.directory.resolve(FILE_NAME);
this.seed = seed;
this.rootDiameter = rootDiameter;
if (Files.exists(this.directory, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isDirectory(this.directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Expansion journal path is not a directory");
this.islands = read();
} else {
// 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));
persist(this.islands);
}
}
public synchronized List<ExpansionIsland> islands() { return islands; }
/** False after uncertain persistence or external damage; only a successful reload can recover. */
public synchronized boolean healthy() { return !failed; }
public synchronized void commit(List<ExpansionIsland> proposed) throws IOException {
if (failed) throw new IOException("Expansion journal failed earlier; reload and inspect it before another expansion");
List<ExpansionIsland> next = validate(proposed);
if (next.size() < islands.size()) throw new IOException("Existing expansion regions cannot be removed");
for (int i = 0; i < islands.size(); i++) {
ExpansionIsland previous = islands.get(i), replacement = next.get(i);
if (!previous.equals(replacement) && !(previous.status().equals("reserved") && previous.ready().equals(replacement))) {
throw new IOException("Existing expansion region cannot be changed: " + previous.id());
}
}
for (int i = islands.size(); i < next.size(); i++) {
if (!next.get(i).status().equals("reserved")) throw new IOException("New expansion region must first be reserved");
}
// Detect another writer, a removed file or external damage before overwriting any history.
try {
if (!read().equals(islands)) throw new IOException("Expansion journal changed outside this instance");
} catch (IOException e) {
failed = true;
throw e;
}
if (next.equals(islands)) return;
persist(next);
islands = next;
}
private void persist(List<ExpansionIsland> next) throws IOException {
Path temporary = null;
try {
byte[] bytes = (JSON.toJson(encode(next)) + "\n").getBytes(StandardCharsets.UTF_8);
temporary = Files.createTempFile(directory, ".expansions-", ".tmp");
try (FileChannel output = FileChannel.open(temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
ByteBuffer buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
// 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); }
if (!read().equals(next)) throw new IOException("Expansion journal verification disagrees with the committed plan");
} catch (IOException | RuntimeException e) {
failed = true;
if (e instanceof IOException io) throw io;
throw new IOException("Could not commit expansion journal", e);
} finally {
if (temporary != null) {
try { Files.deleteIfExists(temporary); } catch (IOException ignored) { /* The original failure remains authoritative. */ }
}
}
}
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");
try (JsonReader reader = new JsonReader(new StringReader(Files.readString(file, StandardCharsets.UTF_8)))) {
reader.setStrictness(Strictness.STRICT);
JsonObject root = readJson(reader, 0).getAsJsonObject();
if (reader.peek() != JsonToken.END_DOCUMENT) throw new IOException("Trailing data in expansion journal");
requireFields(root, JOURNAL_FIELDS);
if (integer(root, "schema") != 1 || number(root, "world_seed").longValueExact() != seed || integer(root, "root_diameter") != rootDiameter) {
throw new IOException("Expansion journal schema, world seed or root diameter does not match");
}
JsonArray entries = root.getAsJsonArray("islands");
List<ExpansionIsland> decoded = new ArrayList<>();
for (JsonElement element : entries) {
JsonObject entry = element.getAsJsonObject();
requireFields(entry, ISLAND_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")));
}
return validate(decoded);
} catch (RuntimeException e) {
throw new IOException("Invalid expansion journal", e);
}
}
private List<ExpansionIsland> validate(List<ExpansionIsland> proposed) throws IOException {
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");
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 (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());
for (ExpansionIsland other : previous.values()) if (island.conflicts(other)) throw new IOException("Expansion reservations overlap: " + island.id() + " / " + other.id());
previous.put(island.id(), island);
}
return checked;
}
private static boolean followsDirection(String direction, long dx, long dz) {
return switch (direction) {
case "north" -> dx == 0 && dz < 0;
case "northeast" -> dx > 0 && dz == -dx;
case "east" -> dx > 0 && dz == 0;
case "southeast" -> dx > 0 && dz == dx;
case "south" -> dx == 0 && dz > 0;
case "southwest" -> dx < 0 && dz == -dx;
case "west" -> dx < 0 && dz == 0;
case "northwest" -> dx < 0 && dz == dx;
default -> false;
};
}
private JsonObject encode(List<ExpansionIsland> entries) {
JsonObject root = new JsonObject();
root.addProperty("schema", 1); root.addProperty("world_seed", seed); root.addProperty("root_diameter", rootDiameter);
JsonArray array = new JsonArray();
for (ExpansionIsland island : entries) {
JsonObject entry = new JsonObject();
entry.addProperty("id", island.id()); entry.addProperty("parent", island.parent());
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);
}
root.add("islands", array);
return root;
}
private static JsonElement readJson(JsonReader reader, int depth) throws IOException {
if (depth > 4) throw new IOException("Expansion journal nesting exceeds its schema");
return switch (reader.peek()) {
case BEGIN_OBJECT -> {
reader.beginObject(); JsonObject object = new JsonObject();
while (reader.hasNext()) {
String name = reader.nextName();
if (object.has(name)) throw new IOException("Duplicate JSON field: " + name);
if (object.size() >= 16) throw new IOException("Too many journal fields");
object.add(name, readJson(reader, depth + 1));
}
reader.endObject(); yield object;
}
case BEGIN_ARRAY -> {
reader.beginArray(); JsonArray array = new JsonArray();
while (reader.hasNext()) {
if (array.size() >= MAX_ISLANDS) throw new IOException("Too many expansion regions");
array.add(readJson(reader, depth + 1));
}
reader.endArray(); yield array;
}
case STRING -> new JsonPrimitive(reader.nextString());
case NUMBER -> new JsonPrimitive(new BigDecimal(reader.nextString()));
case BOOLEAN -> new JsonPrimitive(reader.nextBoolean());
case NULL -> { reader.nextNull(); yield JsonNull.INSTANCE; }
default -> throw new IOException("Unexpected expansion journal token");
};
}
private static void requireFields(JsonObject value, Set<String> fields) throws IOException {
if (!value.keySet().equals(fields)) throw new IOException("Missing or unknown expansion journal fields");
}
private static String string(JsonObject value, String name) throws IOException {
JsonPrimitive primitive = value.getAsJsonPrimitive(name);
if (!primitive.isString()) throw new IOException("Expected string field: " + name);
return primitive.getAsString();
}
private static BigDecimal number(JsonObject value, String name) throws IOException {
JsonPrimitive primitive = value.getAsJsonPrimitive(name);
if (!primitive.isNumber()) throw new IOException("Expected numeric field: " + name);
return primitive.getAsBigDecimal();
}
private static int integer(JsonObject value, String name) throws IOException { return number(value, name).intValueExact(); }
}
@@ -0,0 +1,136 @@
package fr.koka.sanctuary.expansion;
/** Pure alpha.12 coordinate deformation. Existing generators and the lower root island are unchanged. */
public final class ExpansionRelief {
public static final double VERTICAL_ANCHOR = 170.0;
public static final double UPPER_START = 160.0;
public static final double UPPER_TRANSITION = 144.0;
public static final double CRATER_FLOOR = 188.0;
/** Weights describe geography only; no block, liquid or structure is placed here. */
public record Column(double uplift, double canyonWeight, double craterWeight) {}
private ExpansionRelief() {}
public static double horizontalScale(ExpansionIsland island) {
// The public 724 is rounded; the original population_10 frame is already sqrt(2)*512.
return island.origin() ? 1.0 : island.horizontalScale();
}
public static double verticalScale(ExpansionIsland island) {
return island.origin() ? 1.0 : Math.max(0.4, Math.sqrt(island.horizontalScale()));
}
public static int sourceX(ExpansionIsland island, int x) {
return (int) Math.round(((double) x - island.centerX()) / horizontalScale(island));
}
public static int sourceZ(ExpansionIsland island, int z) {
return (int) Math.round(((double) z - island.centerZ()) / horizontalScale(island));
}
/** Reference Y before upper deformation; the inverse projects a source altitude into the island. */
public static double referenceY(ExpansionIsland island, double y) {
return VERTICAL_ANCHOR + (y - VERTICAL_ANCHOR) / verticalScale(island);
}
public static double worldY(ExpansionIsland island, double referenceY) {
return VERTICAL_ANCHOR + (referenceY - VERTICAL_ANCHOR) * verticalScale(island);
}
public static double sourceY(ExpansionIsland island, Column column, double y) {
double reference = referenceY(island, y);
// |uplift| <= 48 and max smoothstep slope = 1.5/144, so this map is strictly
// increasing. The whole source field, including its upper envelope, moves together.
return reference - column.uplift() * smoothstep((reference - UPPER_START) / UPPER_TRANSITION);
}
/** Additional cuts leave the source void empty, and never produce an unbounded positive density. */
public static float sculpt(ExpansionIsland island, Column column, double y, float source) {
if (source <= 0) return source;
double reference = referenceY(island, y);
double canyon = 2.1 * column.canyonWeight() * smoothstep((reference - 120.0) / 34.0);
double crater = 2.1 * column.craterWeight() * smoothstep((reference - CRATER_FLOOR) / 14.0);
return (float) Math.clamp(source - Math.max(canyon, crater), -1.0, 1.0);
}
/** True only in the inner volcanic bowl; the material pass may use this without guessing from blocks. */
public static boolean inCrater(ExpansionIsland island, int x, int z) {
return island.relief().equals("volcano") && column(island, x, z).craterWeight() >= 0.95;
}
public static int craterFloorY(ExpansionIsland island) {
return (int) Math.floor(worldY(island, CRATER_FLOOR));
}
public static Column column(ExpansionIsland island, int x, int z) {
double dx = (double) x - island.centerX(), dz = (double) z - island.centerZ();
double radius = island.radius();
double radial = Math.hypot(dx, dz) / radius;
double edge = 1.0 - smoothstep((radial - 0.70) / 0.30);
if (edge <= 0) return new Column(0, 0, 0);
long seed = island.seed();
double wavelength = Math.max(16.0, radius * 0.40);
double broad = noise(seed, dx / wavelength, dz / wavelength);
double detail = noise(seed ^ 0xA12DE7A1L, dx / (wavelength * 0.43), dz / (wavelength * 0.43));
double uplift;
double canyon = 0.0, crater = 0.0;
switch (island.relief()) {
case "natural" -> {
double ridge = Math.pow(1.0 - Math.abs(broad), 4.0);
uplift = Math.clamp(12.0 * broad + 5.0 * detail + 10.0 * Math.max(0.0, ridge - 0.5), -20.0, 20.0);
}
case "peaks" -> {
double ridge = Math.pow(1.0 - Math.abs(broad), 3.0);
uplift = Math.clamp(10.0 * detail + 44.0 * ridge, -16.0, 48.0);
}
case "canyon" -> {
uplift = 8.0 * detail + 16.0 * Math.max(0.0, broad);
double angle = unit(seed ^ 0xCA1201L) * Math.PI * 2.0;
double along = dx * Math.cos(angle) + dz * Math.sin(angle);
double sideways = -dx * Math.sin(angle) + dz * Math.cos(angle);
double phase = unit(seed ^ 0xCA1202L) * Math.PI * 2.0;
double curve = radius * 0.085 * (Math.sin(along / (radius * 0.36) + phase) - Math.sin(phase));
double halfWidth = Math.clamp(radius * 0.035, 2.25, 12.0);
canyon = (1.0 - smoothstep((Math.abs(sideways - curve) - halfWidth) / (halfWidth * 0.75)))
* (1.0 - smoothstep((Math.abs(along) / radius - 0.62) / 0.20));
}
case "volcano" -> {
// A raised annulus surrounds a depressed centre; this profile changes the
// silhouette and cuts an actual crater, independently of its later palette.
double coneRadius = radial + 0.018 * detail;
double cone = Math.pow(Math.max(0.0, 1.0 - coneRadius / 0.68), 1.35);
double hollow = 1.0 - smoothstep((radial - 0.08) / 0.15);
uplift = Math.clamp(48.0 * cone * (1.0 - 0.90 * hollow) + 3.0 * detail, -6.0, 48.0);
crater = 1.0 - smoothstep((radial - 0.10) / 0.09);
}
default -> throw new IllegalArgumentException("Unknown relief " + island.relief());
}
return new Column(uplift * edge, canyon * edge, crater * edge);
}
/** Smooth coherent value noise: no mutable RNG, global map, chunk boundaries or order dependence. */
private static double noise(long seed, double x, double z) {
long ix = (long) Math.floor(x), iz = (long) Math.floor(z);
double tx = smoothstep(x - ix), tz = smoothstep(z - iz);
double a = lattice(seed, ix, iz), b = lattice(seed, ix + 1, iz);
double c = lattice(seed, ix, iz + 1), d = lattice(seed, ix + 1, iz + 1);
return (a + (b - a) * tx) + ((c + (d - c) * tx) - (a + (b - a) * tx)) * tz;
}
private static double lattice(long seed, long x, long z) {
return unit(seed ^ x * 0x9e3779b97f4a7c15L ^ z * 0x632be59bd9b4e019L) * 2.0 - 1.0;
}
private static double unit(long value) {
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
return ((value ^ (value >>> 31)) >>> 11) * 0x1.0p-53;
}
private static double smoothstep(double value) {
double t = Math.clamp(value, 0.0, 1.0);
return t * t * (3.0 - 2.0 * t);
}
}
@@ -0,0 +1,273 @@
package fr.koka.sanctuary.expansion;
import fr.koka.sanctuary.SanctuaryMod;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLevelEvents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.TicketType;
import net.minecraft.world.level.ChunkPos;
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. */
public final class ExpansionRuntime {
private static final Map<ServerLevel, Session> SESSIONS = new HashMap<>();
private static final TicketType PREPARATION = new TicketType(0L, 2 | 8);
private ExpansionRuntime() {}
public static void register() {
Registry.register(BuiltInRegistries.TICKET_TYPE, SanctuaryMod.id("expansion_preparation"), PREPARATION);
ServerLevelEvents.LOAD.register((server, level) -> {
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");
if (server.getWorldData().overworldData().isInitialized()
&& !java.nio.file.Files.isDirectory(directory, java.nio.file.LinkOption.NOFOLLOW_LINKS))
throw new IOException("Initialized laboratory world is missing its expansion journal directory");
var journal = new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter());
generator.initialize(level, journal.islands());
var session = new Session(level, generator, journal);
SESSIONS.put(level, session);
for (var island : journal.islands()) if (island.status().equals("reserved")) session.enqueue(island);
} catch (IOException e) {
throw new IllegalStateException("Cannot safely open the Sanctuary expansion journal", e);
}
});
ServerTickEvents.END_LEVEL_TICK.register(level -> {
Session session = SESSIONS.get(level);
if (session != null) session.tick();
});
ServerLevelEvents.UNLOAD.register((server, level) -> {
Session session = SESSIONS.remove(level);
if (session != null) session.releaseTicket();
});
ServerLifecycleEvents.SERVER_STOPPED.register(server -> SESSIONS.clear());
}
public static Session session(ServerLevel level) {
Session session = SESSIONS.get(level);
if (session == null) throw new IllegalArgumentException("laboratory_required");
return session;
}
public static final class Session {
private final ServerLevel level;
private final ExpansionChunkGenerator generator;
private final ExpansionJournal journal;
private final ArrayDeque<Job> jobs = new ArrayDeque<>();
private String failure = "";
private boolean journalUnavailableNotified;
private Session(ServerLevel level, ExpansionChunkGenerator generator, ExpansionJournal journal) {
this.level = level; this.generator = generator; this.journal = journal;
}
public List<ExpansionIsland> islands() { return journal.islands(); }
public ExpansionIsland island(String id) {
return islands().stream().filter(i -> i.id().equals(id)).findFirst().orElseThrow(() -> new IllegalArgumentException("unknown_island"));
}
public String progress() {
Job job = jobs.peek();
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");
}
public ExpansionIsland create(String id, String parent, String direction, String climate, int diameter, String relief) throws IOException {
requireHealthyJournal();
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");
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);
}
}
public void resume(String id) {
requireHealthyJournal();
var island = island(id);
if (!island.status().equals("reserved")) return;
failure = "";
enqueue(island);
}
private void enqueue(ExpansionIsland island) {
if (jobs.stream().noneMatch(j -> j.island.id().equals(island.id()))) jobs.add(new Job(island));
}
private void requireHealthyJournal() {
if (!journal.healthy()) {
stopForJournalFailure(null);
throw new IllegalStateException("journal_unavailable");
}
}
private void commitJournal(List<ExpansionIsland> next) throws IOException {
try {
journal.commit(next);
} catch (IOException exception) {
if (!journal.healthy()) {
stopForJournalFailure(exception);
throw new IllegalStateException("journal_unavailable", exception);
}
throw exception;
}
}
private void stopForJournalFailure(Throwable cause) {
releaseTicket();
jobs.clear(); // Saved reservations remain intact and are enqueued only after a verified reload.
failure = "journal_unavailable: reload required";
if (journalUnavailableNotified) return;
journalUnavailableNotified = true;
SanctuaryMod.LOGGER.error("Expansion journal unavailable; all preparation stopped and reload is required", cause);
for (var player : level.players()) if (player.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_GAMEMASTER))
player.sendSystemMessage(Component.translatable("sanctuary.expansion.journal_unavailable"));
}
/** Base-terrain prospecting is read-only: no chunk requests or writes. */
public BlockPos probe(ExpansionIsland island) {
var delegate = generator.delegate(island);
var random = generator.randomState(island);
int stride = Math.max(4, island.diameter() / 32);
int limit = Math.max(stride, (int) (island.radius() * 0.75));
for (int r = 0; r <= limit; r += stride) for (int x = -r; x <= r; x += stride) for (int z = -r; z <= r; z += stride) {
if (Math.max(Math.abs(x), Math.abs(z)) != r) continue;
int wx = island.centerX() + x, wz = island.centerZ() + z;
int y = delegate.getBaseHeight(wx, wz, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, level, random);
if (y > 24 && y < 365) return new BlockPos(wx, y, wz);
}
return null;
}
public BlockPos landing(String id) {
var island = island(id);
if (!island.status().equals("ready")) throw new IllegalArgumentException("not_ready");
int stride = Math.max(4, island.diameter() / 32);
int limit = (int) (island.radius() * 0.85);
for (int r = 0; r <= limit; r += stride) for (int x = -r; x <= r; x += stride) for (int z = -r; z <= r; z += stride) {
if (Math.max(Math.abs(x), Math.abs(z)) != r) continue;
int wx = island.centerX() + x, wz = island.centerZ() + z;
// Only this already-open island is loaded; no block is changed for teleportation.
level.getChunk(wx >> 4, wz >> 4);
int top = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, wx, wz);
for (int y = top; y > 24; y--) {
var pos = new BlockPos(wx, y, wz);
if (safeLanding(level, pos)) return pos;
}
}
throw new IllegalArgumentException("no_landing");
}
private void releaseTicket() {
Job job = jobs.peek();
if (job != null && job.ticket != null) {
level.getChunkSource().removeTicketWithRadius(PREPARATION, job.ticket, 0);
job.ticket = null;
}
}
private void tick() {
if (!journal.healthy()) {
stopForJournalFailure(null);
return;
}
Job job = jobs.peek();
if (job == null) return;
try {
if (job.pending != null) {
if (!job.pending.isDone()) return;
job.pending.join();
if (level.getChunkSource().getChunkNow(job.ticket.x(), job.ticket.z()) == null) throw new IllegalStateException("Chunk did not reach FULL");
releaseTicket();
job.pending = null;
job.index++;
}
if (job.index == job.chunks.size()) {
var next = islands().stream().map(i -> i.id().equals(job.island.id()) ? i.ready() : i).toList();
commitJournal(next);
generator.setIslands(journal.islands());
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))
player.sendSystemMessage(Component.translatable("sanctuary.expansion.ready", job.island.id(), job.index, job.island.id()));
return;
}
job.ticket = job.chunks.get(job.index);
job.pending = level.getChunkSource().addTicketAndLoadWithRadius(PREPARATION, job.ticket, 0);
} catch (Exception exception) {
if (!journal.healthy()) {
stopForJournalFailure(exception);
return;
}
releaseTicket();
failure = job.island.id() + ": " + exception.getMessage();
jobs.remove();
SanctuaryMod.LOGGER.error("Expansion preparation stopped; reservation retained for resume: {}", job.island.id(), exception);
}
}
}
private static final class Job {
final ExpansionIsland island;
final List<ChunkPos> chunks = new ArrayList<>();
int index;
ChunkPos ticket;
CompletableFuture<?> pending;
Job(ExpansionIsland island) {
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));
}
}
public static boolean safeLanding(ServerLevel level, BlockPos feet) {
var floor = level.getBlockState(feet.below());
return floor.isCollisionShapeFullBlock(level, feet.below())
&& floor.getFluidState().isEmpty()
&& !floor.is(net.minecraft.tags.BlockTags.LEAVES)
&& !floor.is(net.minecraft.world.level.block.Blocks.MAGMA_BLOCK)
&& !floor.is(net.minecraft.world.level.block.Blocks.CACTUS)
&& level.getBlockState(feet).isAir() && level.getBlockState(feet.above()).isAir();
}
}
@@ -0,0 +1,182 @@
package fr.koka.sanctuary.expansion;
import fr.koka.sanctuary.SanctuaryMod;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.core.Holder;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.NoiseColumn;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
/** Plan native starts before terrain and references; decoration and structure persistence remain vanilla. */
public final class ExpansionStructures {
private static final boolean DIAGNOSTICS = Boolean.getBoolean("sanctuary.test.expansion")
|| Boolean.getBoolean("sanctuary.expansion.structureDiagnostics");
private static final int CELL_CHUNKS = 8;
private static final List<String> VILLAGES = List.of("village_plains", "village_desert", "village_savanna", "village_snowy", "village_taiga");
private static final List<String> LANDMARKS = List.of("desert_pyramid", "jungle_pyramid", "swamp_hut");
private static final List<String> CAMPS = List.of(
"forest", "birch_forest", "old_growth_birch_forest", "old_growth_pine_taiga", "old_growth_spruce_taiga",
"pale_garden", "savanna", "dappled_forest", "snowy_taiga", "cherry_grove", "swamp", "sparse_jungle",
"meadow", "wooded_badlands", "flower_forest", "windswept_forest", "taiga", "bamboo_jungle"
);
private ExpansionStructures() {}
public static void generate(ExpansionChunkGenerator generator, RegistryAccess registries,
ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk,
StructureTemplateManager templates, ResourceKey<Level> level) {
if (!manager.shouldGenerateStructures()) return;
int blockX = chunk.getPos().getMiddleBlockX(), blockZ = chunk.getPos().getMiddleBlockZ();
ExpansionIsland island = generator.islandAt(blockX, blockZ);
if (island == null) return;
long roll = candidateRoll(island, chunk.getPos().x(), chunk.getPos().z());
if (roll == 0 || Long.remainderUnsigned(roll, 100) >= 72) return;
RandomState randomState = generator.randomState(island);
int surfaceY = generator.getBaseHeight(blockX, blockZ, Heightmap.Types.WORLD_SURFACE_WG, chunk, randomState) - 1;
if (surfaceY < 48) {
diagnostic(island, chunk, "no-surface", "height=" + surfaceY);
return;
}
var sampler = randomState.createClimateSampler(SamplerContext.builder().enableCaches().build());
Holder<Biome> biome = 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.
if (island.diameter() >= 256 && Long.remainderUnsigned(roll >>> 9, 100) < 36) {
for (String name : VILLAGES) addMatching(villages, registry.get(Identifier.fromNamespaceAndPath("minecraft", name)).orElse(null), biome);
}
List<Holder<Structure>> landmarks = new ArrayList<>();
for (String name : LANDMARKS) addMatching(landmarks, registry.get(Identifier.fromNamespaceAndPath("minecraft", name)).orElse(null), biome);
for (String suffix : CAMPS) addMatching(landmarks,
registry.get(Identifier.fromNamespaceAndPath("minecraft", "abandoned_camp_" + suffix)).orElse(null), biome);
if (villages.isEmpty() && landmarks.isEmpty()) {
diagnostic(island, chunk, "no-compatible-structure", "biome=" + biome.unwrapKey().orElseThrow().identifier() + " height=" + surfaceY);
return;
}
// An eligible but unsupported village must not suppress a smaller compatible camp.
// Both groups keep their original biome and foundation checks; one accepted start ends the site.
for (List<Holder<Structure>> candidates : List.of(villages, landmarks)) {
if (candidates.isEmpty()) continue;
int selectedIndex = (int) Long.remainderUnsigned(roll >>> 19, candidates.size());
for (int attempt = 0; attempt < candidates.size(); attempt++) {
Holder<Structure> selected = candidates.get((selectedIndex + attempt) % candidates.size());
Structure structure = selected.value();
StructureStart previous = manager.getStartForStructure(structure, chunk);
if (previous != null && previous.isValid()) return;
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);
if (!start.isValid()) {
diagnostic(island, chunk, "native-rejected", name + " biome=" + biome.unwrapKey().orElseThrow().identifier() + " height=" + surfaceY);
continue;
}
boolean temple = name.equals("minecraft:desert_pyramid") || name.equals("minecraft:jungle_pyramid");
String rejection = supportRejection(generator, island, chunk, randomState, start, temple);
if (rejection != null) {
diagnostic(island, chunk, "support-rejected", name + " " + rejection + " bounds=" + start.getBoundingBox());
continue;
}
manager.setStartForStructure(structure, start, chunk);
diagnostic(island, chunk, "accepted", name + " pieces=" + start.getPieces().size() + " bounds=" + start.getBoundingBox());
return;
}
}
}
private static void addMatching(List<Holder<Structure>> candidates, Holder<Structure> structure, Holder<Biome> biome) {
if (structure != null && structure.value().biomes().contains(biome)) candidates.add(structure);
}
/** One possible start in each 128-block square, salted by the persisted island seed. */
private static long candidateRoll(ExpansionIsland island, int chunkX, int chunkZ) {
int localX = chunkX - Math.floorDiv(island.centerX(), 16), localZ = chunkZ - Math.floorDiv(island.centerZ(), 16);
int cellX = Math.floorDiv(localX, CELL_CHUNKS), cellZ = Math.floorDiv(localZ, CELL_CHUNKS);
long roll = mix(island.seed() ^ 0xA1257A0C7L ^ (cellX * 0x9E3779B97F4A7C15L) ^ (cellZ * 0xC2B2AE3D27D4EB4FL));
if (Math.floorMod(localX, CELL_CHUNKS) != (int) Long.remainderUnsigned(roll >>> 3, CELL_CHUNKS)
|| Math.floorMod(localZ, CELL_CHUNKS) != (int) Long.remainderUnsigned(roll >>> 12, CELL_CHUNKS)) return 0;
return roll;
}
private static String supportRejection(ExpansionChunkGenerator generator, ExpansionIsland island, ChunkAccess chunk,
RandomState randomState, StructureStart start, boolean temple) {
// Native beard adaptation already expands this box by 12 blocks when applicable.
BoundingBox total = start.getBoundingBox();
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";
Map<Long, Ground> columns = new HashMap<>();
int depth = temple ? 24 : 12;
int allMin = Integer.MAX_VALUE, allMax = Integer.MIN_VALUE;
for (StructurePiece piece : start.getPieces()) {
BoundingBox box = piece.getBoundingBox();
int low = Integer.MAX_VALUE, high = Integer.MIN_VALUE;
for (int z = box.minZ() - 1; z <= box.maxZ() + 1; z++) {
for (int x = box.minX() - 1; x <= box.maxX() + 1; x++) {
if (!inside(island, x, z, 4)) return "footprint-outside-island at " + x + "," + z;
long key = ((long) x << 32) ^ (z & 0xffffffffL);
Ground ground = columns.get(key);
if (ground == null) {
ground = ground(generator, chunk, randomState, x, z);
columns.put(key, ground);
}
if (ground.top() < 48 || ground.thickness() < depth)
return "foundation at " + x + "," + z + " top=" + ground.top() + " thickness=" + ground.thickness() + " required=" + depth;
low = Math.min(low, ground.top());
high = Math.max(high, ground.top());
if (high - low > (temple ? 4 : 8)) return "piece-slope=" + (high - low) + " low=" + low + " high=" + high;
}
}
allMin = Math.min(allMin, low);
allMax = Math.max(allMax, high);
if (allMax - allMin > 20) return "site-slope=" + (allMax - allMin);
}
return null;
}
private static void diagnostic(ExpansionIsland island, ChunkAccess chunk, String reason, String details) {
if (DIAGNOSTICS) SanctuaryMod.LOGGER.info("Expansion structure {} seed {} chunk {},{} {}: {}",
island.id(), island.seed(), chunk.getPos().x(), chunk.getPos().z(), reason, details);
}
private static Ground ground(ExpansionChunkGenerator generator, ChunkAccess chunk, RandomState randomState, int x, int z) {
NoiseColumn column = generator.getBaseColumn(x, z, chunk, randomState);
int y = chunk.getMaxY();
while (y >= chunk.getMinY() && column.getBlock(y).isAir()) y--;
int top = y;
// A wet, unsupported or hollow foundation is rejected before any start is saved.
while (y >= chunk.getMinY() && top - y < 24 && column.getBlock(y).isSolid() && column.getBlock(y).getFluidState().isEmpty()) y--;
return new Ground(top, top - y);
}
private static boolean inside(ExpansionIsland island, int x, int z, int margin) {
return Math.hypot((double) x - island.centerX(), (double) z - island.centerZ()) < island.terrainRadius() - margin;
}
private static long mix(long value) {
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
return value ^ (value >>> 31);
}
private record Ground(int top, int thickness) {}
}
@@ -0,0 +1,127 @@
package fr.koka.sanctuary.expansion;
import java.util.EnumSet;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
/** Alpha.12 material pass on fresh terrain only. It never builds a floor, dam or extra island mass. */
public final class ExpansionTerrain {
private ExpansionTerrain() {}
/** Called once after the delegate finishes terrain and before decoration. No adjacent chunk is read. */
public static void apply(ExpansionIsland island, ChunkAccess chunk) {
boolean volcanic = island.relief().equals("volcano");
boolean wet = !volcanic && (island.origin() || island.climate().equals("humid")
|| island.climate().equals("tropical") || island.climate().equals("cold_humid"));
if (!volcanic && !wet) return;
// Find against the original stone/deepslate before the volcanic palette replaces it.
// This one seed-owned column is the only fluid candidate for the entire island.
BlockPos pocket = retainedPocket(island, chunk, volcanic);
if (volcanic) volcanicPalette(island, chunk);
if (pocket == null) return;
chunk.setBlockState(pocket, (volcanic ? Blocks.LAVA : Blocks.WATER).defaultBlockState(), 0);
chunk.markPosForPostProcessing(pocket);
Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG,
Heightmap.Types.OCEAN_FLOOR_WG));
}
/** A diagnostic can inspect this column; it is a candidate, never a promise of fluid. Y is unspecified. */
public static BlockPos pocketCandidate(ExpansionIsland island) {
long seed = island.seed() ^ 0xA12F101DL;
int dx, dz;
if (island.relief().equals("volcano")) {
// Even a 64-block island keeps (+/-2,+/-2) in the inner bowl. The maximum
// offset is eleven, keeping both the candidate and its walls inside a chunk.
int maximum = Math.clamp((int) Math.floor(island.radius() * 0.045), 2, 11);
dx = (2 + (int) Math.floor(unit(seed) * (maximum - 1))) * (unit(seed + 1) < 0.5 ? -1 : 1);
dz = (2 + (int) Math.floor(unit(seed + 2) * (maximum - 1))) * (unit(seed + 3) < 0.5 ? -1 : 1);
} else {
dx = (int) Math.round(island.radius() * (0.16 + 0.18 * unit(seed))) * (unit(seed + 1) < 0.5 ? -1 : 1);
dz = (int) Math.round(island.radius() * (0.16 + 0.18 * unit(seed + 2))) * (unit(seed + 3) < 0.5 ? -1 : 1);
}
int x = insideChunk(island.centerX() + dx), z = insideChunk(island.centerZ() + dz);
return new BlockPos(x, 0, z);
}
private static BlockPos retainedPocket(ExpansionIsland island, ChunkAccess chunk, boolean volcanic) {
BlockPos column = pocketCandidate(island);
int x = column.getX(), z = column.getZ();
if ((x >> 4) != chunk.getPos().x() || (z >> 4) != chunk.getPos().z()
|| !island.contains(x, z) || (volcanic && !ExpansionRelief.inCrater(island, x, z))) return null;
int bottom = Math.max(chunk.getMinY() + 1, (int) Math.ceil(ExpansionRelief.worldY(island, volcanic ? 176.0 : 160.0)));
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
for (int y = chunk.getMinY() + chunk.getHeight() - 2; y >= bottom; y--) {
position.set(x, y, z);
BlockState current = chunk.getBlockState(position);
if (current.isAir()) continue;
// Stop at the first real surface. Do not drill through soil, vegetation,
// overhangs or an earlier fluid to reach an otherwise eligible cave below.
if (!naturalRock(current) || !chunk.getBlockState(position.above()).isAir()) return null;
BlockPos candidate = position.immutable();
if (sealedSupport(chunk, candidate.below(), volcanic)
&& sealedSupport(chunk, candidate.north(), volcanic)
&& sealedSupport(chunk, candidate.south(), volcanic)
&& sealedSupport(chunk, candidate.west(), volcanic)
&& sealedSupport(chunk, candidate.east(), volcanic)) return candidate;
return null;
}
return null;
}
private static boolean sealedSupport(ChunkAccess chunk, BlockPos position, boolean volcanic) {
BlockState support = chunk.getBlockState(position);
// Full collision alone also accepts sand or ice, whose later falling/melting
// would breach a pocket. Restrict all five walls to stable natural rock.
boolean rock = naturalRock(support) || support.is(Blocks.ANDESITE) || support.is(Blocks.DIORITE)
|| support.is(Blocks.GRANITE) || support.is(Blocks.TUFF) || support.is(Blocks.CALCITE)
|| support.is(Blocks.BASALT) || support.is(Blocks.SMOOTH_BASALT) || support.is(Blocks.BLACKSTONE)
|| support.is(Blocks.OBSIDIAN) || support.is(Blocks.MAGMA_BLOCK);
return rock && support.getFluidState().isEmpty() && (!volcanic || !support.ignitedByLava())
&& support.isCollisionShapeFullBlock(chunk, position);
}
private static void volcanicPalette(ExpansionIsland island, ChunkAccess chunk) {
int minX = chunk.getPos().getMinBlockX(), minZ = chunk.getPos().getMinBlockZ();
int minY = Math.max(chunk.getMinY(), (int) Math.floor(ExpansionRelief.worldY(island, 160.0)));
int maxY = Math.min(chunk.getMinY() + chunk.getHeight() - 1,
(int) Math.ceil(ExpansionRelief.worldY(island, 330.0)));
int patchSize = Math.clamp(island.diameter() / 64, 2, 8);
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
for (int x = minX; x < minX + 16; x++) for (int z = minZ; z < minZ + 16; z++) {
double radial = Math.hypot((double) x - island.centerX(), (double) z - island.centerZ()) / island.radius();
if (radial >= 0.62 || !island.contains(x, z)) continue;
long column = island.seed() ^ Math.floorDiv(x - island.centerX(), patchSize) * 0x9e3779b97f4a7c15L
^ Math.floorDiv(z - island.centerZ(), patchSize) * 0x632be59bd9b4e019L;
for (int y = minY; y <= maxY; y++) {
position.set(x, y, z);
if (!naturalRock(chunk.getBlockState(position))) continue;
double pick = unit(column ^ Math.floorDiv(y, 4) * 0x94d049bb133111ebL);
boolean hot = radial < 0.28 && ExpansionRelief.referenceY(island, y) <= 230.0;
BlockState replacement = hot && pick < 0.055 ? Blocks.OBSIDIAN.defaultBlockState()
: hot && pick < 0.14 ? Blocks.MAGMA_BLOCK.defaultBlockState()
: pick < 0.44 ? Blocks.BLACKSTONE.defaultBlockState() : Blocks.BASALT.defaultBlockState();
chunk.setBlockState(position, replacement, 0);
}
}
}
private static boolean naturalRock(BlockState state) {
return state.is(Blocks.STONE) || state.is(Blocks.DEEPSLATE);
}
private static int insideChunk(int coordinate) {
return (coordinate & ~15) + Math.clamp(coordinate & 15, 1, 14);
}
private static double unit(long value) {
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
return ((value ^ (value >>> 31)) >>> 11) * 0x1.0p-53;
}
}
@@ -0,0 +1,18 @@
package fr.koka.sanctuary.mixin;
import fr.koka.sanctuary.expansion.ExpansionBiomeSource;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
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;
/** The laboratory keeps the source island's cavities; vanilla carvers must not remove approved foundations. */
@Mixin(NoiseBasedChunkGenerator.class)
public abstract class ExpansionCarverMixin {
@Inject(method = "generateCarvers", at = @At("HEAD"), cancellable = true)
private void sanctuary$keepExpansionCavities(CallbackInfo callback) {
var generator = (NoiseBasedChunkGenerator) (Object) this;
if (generator.getBiomeSource() instanceof ExpansionBiomeSource) callback.cancel();
}
}
@@ -0,0 +1,14 @@
package fr.koka.sanctuary.mixin;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
import net.minecraft.server.level.ChunkHolder;
import net.minecraft.server.level.ChunkMap;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
/** Admission must also reject chunks removed from the active map but not saved yet. */
@Mixin(ChunkMap.class)
public interface ExpansionChunkMapAccessor {
@Accessor("pendingUnloads")
Long2ObjectLinkedOpenHashMap<ChunkHolder> sanctuary$pendingUnloads();
}
@@ -15,6 +15,11 @@ public abstract class InitialSpawnMixin {
@Inject(method = "setInitialSpawn", at = @At("RETURN"))
private static void sanctuary$initialSpawn(ServerLevel level, ServerLevelData data, boolean bonusChest,
boolean debugWorld, LevelLoadListener listener, CallbackInfo ci) {
if (level.getChunkSource().getGenerator() instanceof fr.koka.sanctuary.expansion.ExpansionChunkGenerator) {
var pos = fr.koka.sanctuary.expansion.ExpansionRuntime.session(level).landing("sanctuary");
level.setRespawnData(net.minecraft.world.level.storage.LevelData.RespawnData.of(net.minecraft.world.level.Level.OVERWORLD, pos, 0, 0));
return;
}
SanctuarySpawn.initialize(level);
}
}
@@ -0,0 +1,152 @@
package fr.koka.sanctuary.worldgen;
import java.lang.ref.WeakReference;
import java.util.Arrays;
import net.minecraft.util.Mth;
import net.minecraft.world.level.levelgen.densityfunction.DensityBuffer;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
import net.minecraft.world.level.levelgen.densityfunction.DensitySampler;
import net.minecraft.world.level.levelgen.densityfunction.DensityVolume;
import net.minecraft.world.level.levelgen.densityfunction.DfRewriteRule;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
import net.minecraft.world.level.levelgen.densityfunction.generator.ConstantFunction;
import net.minecraft.world.level.levelgen.densityfunction.generator.GradientFunction;
import net.minecraft.world.level.levelgen.densityfunction.generator.NoiseFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.BinaryFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.ClampFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.InterpolatedFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.LerpFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.SliceFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.UnaryFunction;
import net.minecraft.world.level.levelgen.synth.BlendedNoise;
/** Reuses lattice corners without changing 26.3-pre-2's scalar interpolation arithmetic.
* Vanilla's bulk interpolation changes operation order and accumulates Y increments, so it
* cannot replace scalar sampling in a generator whose existing worlds must remain exact. */
public final class ExactInterpolationSampler implements DensitySampler {
private static final int CACHE_SIZE = 4096;
// One numeric cache per worker, not one cache per world. Both identities are weak:
// neither a sampler's captured noise graph nor a context's world fields are retained.
private static final ThreadLocal<CornerCache> CORNERS = ThreadLocal.withInitial(CornerCache::new);
private final DensitySampler input;
private final int cellSizeXz;
private final int cellSizeY;
ExactInterpolationSampler(DensitySampler input, int cellSizeXz, int cellSizeY) {
this.input = input;
this.cellSizeXz = cellSizeXz;
this.cellSizeY = cellSizeY;
}
/** Only the known Population terrain expression is optimized. Other data-pack expressions
* retain their own sampler, and the squeeze operation remains the actual vanilla class. */
public static DensitySampler compileTerrain(DensityFunction terrain, DensityFunction.CompileContext context) {
if (supportsTerrain(terrain)) {
var interpolated = (InterpolatedFunction) ((UnaryFunction) terrain).input();
return new UnaryFunction.SqueezeSampler(new ExactInterpolationSampler(
interpolated.input().compileSampler(context), interpolated.cellSizeXz(), interpolated.cellSizeY()));
}
return terrain.compileSampler(context);
}
/** Also used by the engine equivalence check to prove the shipped graphs take this path. */
public static boolean supportsTerrain(DensityFunction terrain) {
return terrain instanceof UnaryFunction unary && unary.type() == UnaryFunction.Type.SQUEEZE
&& unary.input() instanceof InterpolatedFunction interpolated && coordinateOnly(interpolated.input(), 0);
}
private static boolean coordinateOnly(DensityFunction function, int depth) {
if (depth > 64) return false;
function = DfRewriteRule.INLINE_REFERENCE.rewrite(function);
if (function instanceof ConstantFunction || function instanceof GradientFunction || function instanceof BlendedNoise) return true;
if (function instanceof NoiseFunction noise) return coordinateOnly(noise.shiftX(), depth + 1)
&& coordinateOnly(noise.shiftY(), depth + 1) && coordinateOnly(noise.shiftZ(), depth + 1);
if (function instanceof BinaryFunction binary) return coordinateOnly(binary.left(), depth + 1)
&& coordinateOnly(binary.right(), depth + 1);
if (function instanceof LerpFunction lerp) return coordinateOnly(lerp.alpha(), depth + 1)
&& coordinateOnly(lerp.first(), depth + 1) && coordinateOnly(lerp.second(), depth + 1);
if (function instanceof UnaryFunction unary) return coordinateOnly(unary.input(), depth + 1);
if (function instanceof ClampFunction clamp) return coordinateOnly(clamp.input(), depth + 1);
if (function instanceof SliceFunction slice) return coordinateOnly(slice.input(), depth + 1);
// Contextual blending, prepared caches and third-party density functions are deliberately
// opaque. Identity of a SamplerContext does not prove that its payload cannot mutate.
return false;
}
@Override public float sampleValue(SamplerContext context, int x, int y, int z) {
int offsetX = Math.floorMod(x, cellSizeXz);
int offsetY = Math.floorMod(y, cellSizeY);
int offsetZ = Math.floorMod(z, cellSizeXz);
// The vanilla aligned path is scalar; a lattice value from sampleVolume is not
// assumed to have the same bits as this separate input.sampleValue path.
if (offsetX == 0 && offsetY == 0 && offsetZ == 0) return input.sampleValue(context, x, y, z);
int originX = x - offsetX, originY = y - offsetY, originZ = z - offsetZ;
CornerCache cache = CORNERS.get();
cache.select(this, context);
int slot = cache.slot(originX, originY, originZ);
if (!cache.contains(slot, originX, originY, originZ)) {
DensityVolume lattice = new DensityVolume(2, 2, 2, originX, originY, originZ,
cellSizeXz, cellSizeY, cellSizeXz);
try (var buffer = context.acquireBuffer(lattice)) {
// Keep the precise 2x2x2 request used by InterpolatedFunction.sampleValue:
// batching a larger lattice can change the input noise's rounding too.
input.sampleVolume(context, buffer, lattice);
// A nested input sampler could itself use this thread's cache. Restore our
// identity after it returns, before publishing our corners into the cache.
cache.select(this, context);
int first = slot * 8;
for (int i = 0; i < 8; i++) cache.values[first + i] = buffer.get(i);
cache.store(slot, originX, originY, originZ);
}
}
int first = slot * 8;
// DensityVolume packs Y fastest, then X, then Z. lerp3 expects the corners in
// X, Y, Z order; retain vanilla's float divisions, not reciprocal multiplication.
return Mth.lerp3((float) offsetX / cellSizeXz, (float) offsetY / cellSizeY, (float) offsetZ / cellSizeXz,
cache.values[first], cache.values[first + 2], cache.values[first + 1], cache.values[first + 3],
cache.values[first + 4], cache.values[first + 6], cache.values[first + 5], cache.values[first + 7]);
}
@Override public void sampleVolume(SamplerContext context, DensityBuffer buffer, DensityVolume volume) {
DensitySampler.sampleVolumeNaive(context, buffer, volume, this);
}
/** Direct-mapped and bounded: 192 KiB of primitive arrays per participating thread.
* A collision only recomputes a cell; it never substitutes a different cell's corners. */
private static final class CornerCache {
private WeakReference<ExactInterpolationSampler> sampler = new WeakReference<>(null);
private WeakReference<SamplerContext> context = new WeakReference<>(null);
private final int[] xs = new int[CACHE_SIZE], ys = new int[CACHE_SIZE], zs = new int[CACHE_SIZE];
private final int[] generations = new int[CACHE_SIZE];
private final float[] values = new float[CACHE_SIZE * 8];
private int generation;
void select(ExactInterpolationSampler selected, SamplerContext selectedContext) {
if (sampler.get() == selected && context.get() == selectedContext) return;
sampler = new WeakReference<>(selected);
context = new WeakReference<>(selectedContext);
if (++generation == 0) {
Arrays.fill(generations, 0);
generation = 1;
}
}
int slot(int x, int y, int z) {
int hash = x * 0x9e3779b9 ^ Integer.rotateLeft(y * 0x85ebca6b, 11)
^ Integer.rotateLeft(z * 0xc2b2ae35, 22);
hash = (hash ^ (hash >>> 16)) * 0x7feb352d;
return (hash ^ (hash >>> 15)) & (CACHE_SIZE - 1);
}
boolean contains(int slot, int x, int y, int z) {
return generations[slot] == generation && xs[slot] == x && ys[slot] == y && zs[slot] == z;
}
void store(int slot, int x, int y, int z) {
xs[slot] = x; ys[slot] = y; zs[slot] = z;
generations[slot] = generation;
}
}
}
@@ -5,14 +5,14 @@ import com.mojang.serialization.DataResult;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
/** Versioned alpha.10 world geometry. Capacity describes area, not a login limit. */
/** Saved geometry references. Small/large use FIVE/TEN; TWENTY/HUNDRED remain loadable. */
public enum IslandCapacity {
FIVE(5, 256), TWENTY(20, 512), HUNDRED(100, 256 * Math.sqrt(20));
FIVE(5, 256), TEN(10, 256 * Math.sqrt(2)), TWENTY(20, 512), HUNDRED(100, 256 * Math.sqrt(20));
public static final int REGION_SIZE = 512;
public static final Codec<IslandCapacity> CODEC = Codec.INT.comapFlatMap(players -> {
for (var capacity : values()) if (capacity.players == players) return DataResult.success(capacity);
return DataResult.error(() -> "Island capacity must be 5, 20 or 100");
return DataResult.error(() -> "Unknown saved island size; supported references are 5, 10, 20 and 100");
}, IslandCapacity::players);
private final int players;
private final double radius;
@@ -22,12 +22,13 @@ public enum IslandCapacity {
public int terrainLimit() { return (int) Math.ceil(radius + 32); }
public static IslandCapacity fromPlayers(int players) {
for (var capacity : values()) if (capacity.players == players) return capacity;
throw new IllegalArgumentException("Island capacity must be 5, 20 or 100");
throw new IllegalArgumentException("Unknown saved island size; supported references are 5, 10, 20 and 100");
}
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_100_SETTINGS)) return HUNDRED;
return null;
@@ -10,52 +10,51 @@ import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
/** Alpha.10 plans only regions touched by generation or a requested protection volume. */
public final class PopulationHydrologyRuntime {
public record RegionPlan(IslandCapacity.Region region, PopulationHydrology.Plan water, PopulationLavaDeposit.Plan lava) {}
private static final Map<RandomState, Map<IslandCapacity.Region, RegionPlan>> WORLDS =
private static final Map<RandomState, PopulationPlanCache<IslandCapacity.Region, RegionPlan>> WORLDS =
Collections.synchronizedMap(new WeakHashMap<>());
private PopulationHydrologyRuntime() {}
public static boolean enabled(NoiseBasedChunkGenerator generator) { return IslandCapacity.forGenerator(generator) != null; }
public static IslandCapacity capacity(NoiseBasedChunkGenerator generator) {
return Objects.requireNonNull(IslandCapacity.forGenerator(generator), "Population generation is not enabled");
}
private static Map<IslandCapacity.Region, RegionPlan> regions(RandomState state) {
synchronized (WORLDS) { return WORLDS.computeIfAbsent(state, ignored -> new LinkedHashMap<>()); }
private static PopulationPlanCache<IslandCapacity.Region, RegionPlan> regions(RandomState state) {
synchronized (WORLDS) { return WORLDS.computeIfAbsent(state, ignored -> new PopulationPlanCache<>()); }
}
public static int cachedRegionCount(RandomState state) {
Map<IslandCapacity.Region, RegionPlan> regions;
PopulationPlanCache<IslandCapacity.Region, RegionPlan> regions;
synchronized (WORLDS) { regions = WORLDS.get(state); }
if (regions == null) return 0;
synchronized (regions) { return regions.size(); }
return regions.size();
}
public static RegionPlan regionPlan(NoiseBasedChunkGenerator generator, RandomState state, IslandCapacity.Region region) {
var capacity = capacity(generator);
var regions = regions(state);
// One build per world at a time bounds the temporary sign-mask memory. No recursive
// neighbouring plan is needed: each accepted assembly owns its complete protected extent.
synchronized (regions) {
RegionPlan existing = regions.get(region);
if (existing != null) return existing;
// Only cache misses wait for the bounded planner. Completed regions stay readable
// while another region is being computed; no neighbouring plan is built recursively.
return regions.getOrCreate(region, () -> {
long start = System.nanoTime();
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
var sampler = state.getSampler(density);
var memo = new SignMemo((x, y, z) -> {
long wx = (long) x + region.originX(), wz = (long) z + region.originZ();
if (Math.hypot((double) wx, wz) >= capacity.terrainLimit()) return -1;
return state.sampleBlockValueUncached(density, (int) wx, y, (int) wz);
return sampler.sampleValue(SamplerContext.EMPTY_UNCACHED, (int) wx, y, (int) wz);
});
long seed = PopulationIslandDensity.regionSeed(state.seed(), region);
var water = PopulationHydrology.create(seed, memo).admitted();
var lava = PopulationLavaDeposit.create(seed, memo, water).admitted();
var result = new RegionPlan(region, water.translated(region.originX(), region.originZ()),
lava.translated(region.originX(), region.originZ()));
regions.put(region, result);
SanctuaryMod.LOGGER.info("Population {} seed {} region {},{}: {} features, {} water columns, {} lava sources, {} density probes, {} ms",
capacity.players(), state.seed(), region.x(), region.z(), water.features().size(), water.cells().size(),
lava.lavaCells().size(), memo.samples, (System.nanoTime() - start) / 1_000_000);
return result;
}
});
}
public static RegionPlan regionPlan(ServerLevel level, IslandCapacity.Region region) {
return regionPlan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(), level.getChunkSource().randomState(), region);
@@ -23,7 +23,7 @@ public record PopulationIslandDensity(DensityFunction terrain, DensityFunction d
).apply(instance, PopulationIslandDensity::new));
@Override public DensitySampler compileSampler(CompileContext context) {
var raw = terrain.compileSampler(context); var edge = distortion.compileSampler(context);
var raw = ExactInterpolationSampler.compileTerrain(terrain, context); var edge = distortion.compileSampler(context);
var coarse = sculpt.compileSampler(context); var fine = detail.compileSampler(context);
var bottom = underside.compileSampler(context); var warp = riftWarp.compileSampler(context);
var fractureDetail = riftDetail.compileSampler(context);
@@ -0,0 +1,25 @@
package fr.koka.sanctuary.worldgen;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
/** Completed plans remain readable while one new region uses temporary planning memory. */
final class PopulationPlanCache<K, V> {
private final ConcurrentHashMap<K, V> completed = new ConcurrentHashMap<>();
V getOrCreate(K key, Supplier<V> create) {
V value = completed.get(key);
if (value != null) return value;
synchronized (this) {
value = completed.get(key);
if (value == null) {
value = Objects.requireNonNull(create.get());
completed.put(key, value);
}
return value;
}
}
int size() { return completed.size(); }
}
@@ -38,6 +38,8 @@ public final class SanctuarySpawn {
public static final ResourceKey<NoiseGeneratorSettings> POPULATION_5_SETTINGS =
ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("population_5"));
public static final ResourceKey<NoiseGeneratorSettings> POPULATION_10_SETTINGS =
ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("population_10"));
public static final ResourceKey<NoiseGeneratorSettings> POPULATION_20_SETTINGS =
ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("population_20"));
public static final ResourceKey<NoiseGeneratorSettings> POPULATION_100_SETTINGS =
@@ -56,6 +58,7 @@ public final class SanctuarySpawn {
|| generator.generatorSettings().is(RIFT_SETTINGS)
|| generator.generatorSettings().is(CAVERN_SETTINGS)
|| generator.generatorSettings().is(POPULATION_5_SETTINGS)
|| generator.generatorSettings().is(POPULATION_10_SETTINGS)
|| generator.generatorSettings().is(POPULATION_20_SETTINGS)
|| generator.generatorSettings().is(POPULATION_100_SETTINGS));
}
@@ -1,5 +1,5 @@
{
"generator.sanctuary.sanctuary": "Sanctuary (20 players)",
"generator.sanctuary.sanctuary": "Sanctuary (Large)",
"biome.sanctuary.starter_forest": "Sanctuary Forest",
"biome.sanctuary.starter_forest_hydrology": "Sanctuary Forest and Shores",
"biome.sanctuary.temperate_forest": "Sanctuary Temperate Forest",
@@ -38,7 +38,7 @@
"biome.sanctuary.cavern_sulfur_depths": "Sulfur Depths",
"biome.sanctuary.cavern_lush_caves": "Sanctuary Lush Caves",
"biome.sanctuary.cavern_dripstone_caves": "Sanctuary Dripstone Caves",
"generator.sanctuary.sanctuary_5": "Sanctuary (5 players)",
"generator.sanctuary.sanctuary_5": "Sanctuary (Small)",
"generator.sanctuary.sanctuary_100": "Sanctuary (100 players)",
"biome.sanctuary.population_oak_forest": "Sanctuary Oak Woodland",
"biome.sanctuary.population_birch_forest": "Sanctuary Birch Woodland",
@@ -49,5 +49,62 @@
"biome.sanctuary.population_bamboo_grove": "Bamboo Grove",
"biome.sanctuary.population_sulfur_depths": "Sulfur Depths",
"biome.sanctuary.population_lush_caves": "Sanctuary Lush Caves",
"biome.sanctuary.population_dripstone_caves": "Sanctuary Dripstone Caves"
"biome.sanctuary.population_dripstone_caves": "Sanctuary Dripstone Caves",
"generator.sanctuary.sanctuary_20": "Sanctuary (20 players — legacy size)",
"generator.sanctuary.expansion_lab": "Sanctuary — Laboratory",
"sanctuary.expansion.help": "Alpha.12 laboratory — /sanctuary expansion quick <direction> [climate] [diameter] [relief]; create or preview <id> <parent> <direction> <climate> <diameter> <relief>; list; visit <id>; resume <id>. Diameters: 64, 128, 256, 512.",
"sanctuary.expansion.entry": "%s ← %s · %s · %s · Ø%s · %s · X%s Z%s · %s",
"sanctuary.expansion.progress": "Preparation: %s",
"sanctuary.expansion.state.ready": "Ready",
"sanctuary.expansion.state.reserved": "Reserved, preparing",
"sanctuary.expansion.preview": "Proposal %s: X%s Z%s, %s, diameter %s, relief %s. Proposed bounds; occupancy and terrain have not been validated.",
"sanctuary.expansion.created": "Island %s recorded at X%s Z%s: %s, diameter %s, relief %s. Check /sanctuary expansion list.",
"sanctuary.expansion.ready": "Island %s ready: %s chunks. /sanctuary expansion visit %s",
"sanctuary.expansion.visited": "Arrived on %s.",
"sanctuary.expansion.resumed": "Resume requested for %s.",
"sanctuary.expansion.error": "Expansion refused: %s",
"sanctuary.expansion.error.laboratory_required": "Create a new Sanctuary — Laboratory world. Existing worlds are not converted.",
"sanctuary.expansion.error.unknown_island": "Unknown island.",
"sanctuary.expansion.error.reserved_id": "Reserved identifier.",
"sanctuary.expansion.error.invalid_direction": "Unknown direction.",
"sanctuary.expansion.error.no_space": "No available location in this direction.",
"sanctuary.expansion.error.id_conflict": "This identifier already belongs to a different island.",
"sanctuary.expansion.error.island_limit": "Laboratory limit: 64 islands including the origin.",
"sanctuary.expansion.error.no_ground": "No ground found in the proposed terrain. Choose another identifier or profile.",
"sanctuary.expansion.error.not_ready": "This island must finish preparing first.",
"sanctuary.expansion.error.no_landing": "No safe landing found.",
"sanctuary.expansion.error.dismount_first": "Dismount before travelling.",
"sanctuary.expansion.error.parent_not_ready": "The parent must be ready before creating a branch.",
"sanctuary.expansion.error.occupied": "This region already contains chunks. Choose another location.",
"sanctuary.expansion.error.teleport_failed": "Teleportation failed.",
"sanctuary.expansion.climate.temperate": "Temperate",
"sanctuary.expansion.climate.boreal": "Boreal",
"sanctuary.expansion.climate.glacial": "Glacial",
"sanctuary.expansion.climate.cold_humid": "Cold humid",
"sanctuary.expansion.climate.humid": "Humid",
"sanctuary.expansion.climate.tropical": "Tropical",
"sanctuary.expansion.climate.warm": "Warm",
"sanctuary.expansion.climate.arid": "Arid",
"sanctuary.expansion.climate.dry": "Dry",
"sanctuary.expansion.climate.cold_dry": "Cold dry",
"sanctuary.expansion.climate.autumn": "Autumn",
"sanctuary.expansion.climate.cherry": "Cherry",
"sanctuary.expansion.relief.natural": "Natural",
"sanctuary.expansion.relief.peaks": "Peaks",
"sanctuary.expansion.relief.canyon": "Canyon",
"sanctuary.expansion.relief.volcano": "Volcano",
"sanctuary.expansion.direction.north": "North",
"sanctuary.expansion.direction.northeast": "Northeast",
"sanctuary.expansion.direction.east": "East",
"sanctuary.expansion.direction.southeast": "Southeast",
"sanctuary.expansion.direction.south": "South",
"sanctuary.expansion.direction.southwest": "Southwest",
"sanctuary.expansion.direction.west": "West",
"sanctuary.expansion.direction.northwest": "Northwest",
"sanctuary.expansion.journal_unavailable": "The expansion journal is unavailable. Preparation has stopped; fix storage and reload the world before resuming.",
"sanctuary.expansion.error.journal_unavailable": "The expansion journal is unavailable. Preparation has stopped; fix storage and reload the world before resuming.",
"sanctuary.expansion.error.invalid_id": "An identifier must start with a lowercase letter and contain at most 32 lowercase letters, digits or underscores.",
"sanctuary.expansion.error.invalid_climate": "Unknown climate. Use autocomplete to choose a profile.",
"sanctuary.expansion.error.invalid_diameter": "Allowed diameters are 64, 128, 256 and 512 blocks.",
"sanctuary.expansion.error.invalid_relief": "Unknown relief: natural, peaks, canyon or volcano."
}
@@ -1,5 +1,5 @@
{
"generator.sanctuary.sanctuary": "Sanctuary (20 joueurs)",
"generator.sanctuary.sanctuary": "Sanctuary (Grand)",
"biome.sanctuary.starter_forest": "Forêt de Sanctuary",
"biome.sanctuary.starter_forest_hydrology": "Forêt et rives de Sanctuary",
"biome.sanctuary.temperate_forest": "Forêt tempérée de Sanctuary",
@@ -38,7 +38,7 @@
"biome.sanctuary.cavern_sulfur_depths": "Profondeurs soufrées",
"biome.sanctuary.cavern_lush_caves": "Grottes luxuriantes de Sanctuary",
"biome.sanctuary.cavern_dripstone_caves": "Grottes de spéléothèmes de Sanctuary",
"generator.sanctuary.sanctuary_5": "Sanctuary (5 joueurs)",
"generator.sanctuary.sanctuary_5": "Sanctuary (Petit)",
"generator.sanctuary.sanctuary_100": "Sanctuary (100 joueurs)",
"biome.sanctuary.population_oak_forest": "Chênaie de Sanctuary",
"biome.sanctuary.population_birch_forest": "Bétulaie de Sanctuary",
@@ -49,5 +49,62 @@
"biome.sanctuary.population_bamboo_grove": "Bosquet de bambous",
"biome.sanctuary.population_sulfur_depths": "Profondeurs soufrées",
"biome.sanctuary.population_lush_caves": "Grottes luxuriantes de Sanctuary",
"biome.sanctuary.population_dripstone_caves": "Grottes de spéléothèmes de Sanctuary"
"biome.sanctuary.population_dripstone_caves": "Grottes de spéléothèmes de Sanctuary",
"generator.sanctuary.sanctuary_20": "Sanctuary (20 joueurs — ancien format)",
"generator.sanctuary.expansion_lab": "Sanctuary — Laboratoire",
"sanctuary.expansion.help": "Laboratoire alpha.12 — /sanctuary expansion quick <direction> [climat] [diamètre] [relief] ; create ou preview <id> <parent> <direction> <climat> <diamètre> <relief> ; list ; visit <id> ; resume <id>. Diamètres : 64, 128, 256, 512.",
"sanctuary.expansion.entry": "%s ← %s · %s · %s · Ø%s · %s · X%s Z%s · %s",
"sanctuary.expansion.progress": "Préparation : %s",
"sanctuary.expansion.state.ready": "Prêt",
"sanctuary.expansion.state.reserved": "Réservé, préparation en cours",
"sanctuary.expansion.preview": "Projet %s : X%s Z%s, %s, diamètre %s, relief %s. Emprise proposée, occupation et terrain non validés.",
"sanctuary.expansion.created": "Île %s enregistrée à X%s Z%s : %s, diamètre %s, relief %s. Suivre avec /sanctuary expansion list.",
"sanctuary.expansion.ready": "Île %s prête : %s chunks. /sanctuary expansion visit %s",
"sanctuary.expansion.visited": "Arrivée sur %s.",
"sanctuary.expansion.resumed": "Reprise demandée pour %s.",
"sanctuary.expansion.error": "Expansion refusée : %s",
"sanctuary.expansion.error.laboratory_required": "Créer un nouveau monde Sanctuary — Laboratoire. Les anciennes parties ne sont pas converties.",
"sanctuary.expansion.error.unknown_island": "Île inconnue.",
"sanctuary.expansion.error.reserved_id": "Identifiant réservé.",
"sanctuary.expansion.error.invalid_direction": "Direction inconnue.",
"sanctuary.expansion.error.no_space": "Aucun emplacement libre dans cette direction.",
"sanctuary.expansion.error.id_conflict": "Cet identifiant désigne déjà une autre île.",
"sanctuary.expansion.error.island_limit": "Limite du laboratoire : 64 îles, origine comprise.",
"sanctuary.expansion.error.no_ground": "Aucun sol trouvé dans le terrain proposé. Choisir un autre identifiant ou profil.",
"sanctuary.expansion.error.not_ready": "La préparation de cette île doit être terminée.",
"sanctuary.expansion.error.no_landing": "Aucun point darrivée sûr trouvé.",
"sanctuary.expansion.error.dismount_first": "Descendre du véhicule avant le voyage.",
"sanctuary.expansion.error.parent_not_ready": "Le parent doit être prêt avant de créer une branche.",
"sanctuary.expansion.error.occupied": "La région contient déjà des chunks. Choisir un autre emplacement.",
"sanctuary.expansion.error.teleport_failed": "La téléportation a échoué.",
"sanctuary.expansion.climate.temperate": "Tempéré",
"sanctuary.expansion.climate.boreal": "Boréal",
"sanctuary.expansion.climate.glacial": "Glacial",
"sanctuary.expansion.climate.cold_humid": "Froid humide",
"sanctuary.expansion.climate.humid": "Humide",
"sanctuary.expansion.climate.tropical": "Tropical",
"sanctuary.expansion.climate.warm": "Chaud",
"sanctuary.expansion.climate.arid": "Aride",
"sanctuary.expansion.climate.dry": "Sec",
"sanctuary.expansion.climate.cold_dry": "Froid sec",
"sanctuary.expansion.climate.autumn": "Automnal",
"sanctuary.expansion.climate.cherry": "Cerisiers",
"sanctuary.expansion.relief.natural": "Naturel",
"sanctuary.expansion.relief.peaks": "Pics",
"sanctuary.expansion.relief.canyon": "Canyon",
"sanctuary.expansion.relief.volcano": "Volcan",
"sanctuary.expansion.direction.north": "Nord",
"sanctuary.expansion.direction.northeast": "Nord-est",
"sanctuary.expansion.direction.east": "Est",
"sanctuary.expansion.direction.southeast": "Sud-est",
"sanctuary.expansion.direction.south": "Sud",
"sanctuary.expansion.direction.southwest": "Sud-ouest",
"sanctuary.expansion.direction.west": "Ouest",
"sanctuary.expansion.direction.northwest": "Nord-ouest",
"sanctuary.expansion.journal_unavailable": "Le journal dexpansion est indisponible. La préparation est arrêtée ; corrigez le stockage puis rechargez le monde avant de reprendre.",
"sanctuary.expansion.error.journal_unavailable": "Le journal dexpansion est indisponible. La préparation est arrêtée ; corrigez le stockage puis rechargez le monde avant de reprendre.",
"sanctuary.expansion.error.invalid_id": "Lidentifiant doit commencer par une lettre minuscule et contenir au plus 32 lettres minuscules, chiffres ou tirets bas.",
"sanctuary.expansion.error.invalid_climate": "Climat inconnu. Utiliser lautocomplétion pour choisir un profil.",
"sanctuary.expansion.error.invalid_diameter": "Les diamètres autorisés sont 64, 128, 256 et 512 blocs.",
"sanctuary.expansion.error.invalid_relief": "Relief inconnu : natural, peaks, canyon ou volcano."
}
@@ -3,6 +3,6 @@
"values": [
"sanctuary:sanctuary",
"sanctuary:sanctuary_5",
"sanctuary:sanctuary_100"
"sanctuary:expansion_lab"
]
}
@@ -0,0 +1,53 @@
{
"type": "sanctuary:population_island",
"terrain": {
"type": "minecraft:squeeze",
"input": {
"type": "minecraft:interpolated",
"cell_size_xz": 8,
"cell_size_y": 4,
"input": {
"type": "minecraft:mul",
"left": 0.64,
"right": "sanctuary:floating_archipelago_384"
}
}
},
"distortion": {
"type": "minecraft:noise",
"noise": "sanctuary:island_edge",
"xz_scale": 1.0,
"y_scale": 0.0
},
"sculpt": {
"type": "minecraft:noise",
"noise": "sanctuary:island_sculpt",
"xz_scale": 1.0,
"y_scale": 1.0
},
"detail": {
"type": "minecraft:noise",
"noise": "sanctuary:island_detail",
"xz_scale": 1.0,
"y_scale": 1.0
},
"underside": {
"type": "minecraft:noise",
"noise": "sanctuary:island_underside",
"xz_scale": 1.0,
"y_scale": 1.0
},
"players": 10,
"rift_warp": {
"type": "minecraft:noise",
"noise": "sanctuary:rift_warp",
"xz_scale": 1.0,
"y_scale": 1.0
},
"rift_detail": {
"type": "minecraft:noise",
"noise": "sanctuary:rift_detail",
"xz_scale": 1.0,
"y_scale": 1.0
}
}
@@ -0,0 +1,23 @@
{
"default_block": "minecraft:stone",
"default_fluid": "minecraft:air",
"disable_mob_generation": false,
"legacy_random_source": true,
"material_rule": "sanctuary:population_island",
"noise": {
"height": 384,
"min_y": 0
},
"noise_router": {
"chunk_surface_level": 0.0,
"continents": 0.0,
"depth": 0.0,
"erosion": 0.0,
"final_density": "sanctuary:population_10",
"ridges": 0.0,
"temperature": "sanctuary:temperate_variation",
"vegetation": "sanctuary:temperate_moisture"
},
"sea_level": -64,
"spawn_target": []
}
@@ -0,0 +1,70 @@
{
"dimensions": {
"minecraft:overworld": {
"type": "sanctuary:sanctuary_384",
"generator": {
"type": "sanctuary:expansion_lab",
"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"
]
},
"initial_diameter": 512
}
},
"minecraft:the_end": {
"type": "minecraft:the_end",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:the_end"
},
"settings": "minecraft:end"
}
},
"minecraft:the_nether": {
"type": "minecraft:the_nether",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:multi_noise",
"preset": "minecraft:nether"
},
"settings": "minecraft:nether"
}
}
}
}
@@ -17,7 +17,7 @@
"lush_caves": "sanctuary:population_lush_caves",
"dripstone_caves": "sanctuary:population_dripstone_caves"
},
"settings": "sanctuary:population_20"
"settings": "sanctuary:population_10"
}
},
"minecraft:the_end": {
@@ -0,0 +1,45 @@
{
"dimensions": {
"minecraft:overworld": {
"type": "sanctuary:sanctuary_384",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "sanctuary:population_island",
"oak_forest": "sanctuary:population_oak_forest",
"birch_forest": "sanctuary:population_birch_forest",
"clearing": "sanctuary:population_clearing",
"dry_woodland": "sanctuary:population_dry_woodland",
"dark_grove": "sanctuary:population_dark_grove",
"bamboo_grove": "sanctuary:population_bamboo_grove",
"sulfur_depths": "sanctuary:population_sulfur_depths",
"rocky_heath": "sanctuary:population_rocky_heath",
"lush_caves": "sanctuary:population_lush_caves",
"dripstone_caves": "sanctuary:population_dripstone_caves"
},
"settings": "sanctuary:population_20"
}
},
"minecraft:the_end": {
"type": "minecraft:the_end",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:the_end"
},
"settings": "minecraft:end"
}
},
"minecraft:the_nether": {
"type": "minecraft:the_nether",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:multi_noise",
"preset": "minecraft:nether"
},
"settings": "minecraft:nether"
}
}
}
}
@@ -3,6 +3,8 @@
"package": "fr.koka.sanctuary.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"ExpansionChunkMapAccessor",
"ExpansionCarverMixin",
"InitialSpawnMixin",
"SurfaceHydrologyMixin",
"LayeredSpringOutletMixin",
@@ -0,0 +1,119 @@
package fr.koka.sanctuary.expansion;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
/** Persistence failures must never turn saved continents into a new empty history. No game world is opened. */
public final class ExpansionJournalSmoke {
private static final long SEED = Long.MIN_VALUE + 37;
public static void main(String[] args) throws Exception {
Path scratch = Files.createTempDirectory("sanctuary-expansion-journal-");
try {
Path directory = scratch.resolve("normal");
var journal = new ExpansionJournal(directory, SEED, 512);
ExpansionIsland root = ExpansionIsland.origin(512, SEED);
ExpansionIsland north = island("north_one", "sanctuary", "north", 0, -1024);
require(journal.islands().equals(List.of(root)), "New journal must contain the exact root");
byte[] original = Files.readAllBytes(directory.resolve(ExpansionJournal.FILE_NAME));
journal.commit(List.of(root));
require(Arrays.equals(original, Files.readAllBytes(directory.resolve(ExpansionJournal.FILE_NAME))), "Repeating a commit must leave the journal unchanged");
journal.commit(List.of(root, north));
require(new ExpansionJournal(directory, SEED, 512).islands().equals(List.of(root, north)), "Reservation must survive restart before generation");
journal.commit(List.of(root, north.ready()));
require(new ExpansionJournal(directory, SEED, 512).islands().equals(List.of(root, north.ready())), "Completion must survive restart");
try { journal.islands().add(north); throw new AssertionError("Journal list was mutable"); } catch (UnsupportedOperationException expected) { }
reject(() -> journal.commit(List.of(root)), "Removing a region");
reject(() -> journal.commit(List.of(root, north)), "Reverting a completed region");
reject(() -> journal.commit(List.of(root, island("north_one", "sanctuary", "north", 0, -2048).ready())), "Moving an existing region");
reject(() -> journal.commit(List.of(root, north.ready(), north)), "Duplicating an identifier");
reject(() -> journal.commit(List.of(root, north.ready(), island("near", "sanctuary", "east", 512, 0))), "Overlapping reservations");
reject(() -> journal.commit(List.of(root, north.ready(), island("orphan", "missing", "east", 2048, 0))), "Missing parent");
reject(() -> journal.commit(List.of(root, north.ready(), island("wrong_way", "sanctuary", "east", 0, 2048))), "Wrong direction");
reject(() -> journal.commit(List.of(root, north.ready(), island("early_ready", "sanctuary", "east", 2048, 0).ready())), "Skipping reservation");
reject(() -> new ExpansionJournal(directory, SEED + 1, 512), "Wrong world seed");
reject(() -> new ExpansionJournal(directory, SEED, 724), "Wrong root diameter");
require(journal.islands().equals(List.of(root, north.ready())), "Rejected plans changed the live journal");
var larger = new ExpansionJournal(scratch.resolve("large"), SEED, 724);
require(larger.islands().getFirst().equals(ExpansionIsland.origin(724, SEED)), "Large origin must reload exactly");
reject(() -> new ExpansionJournal(scratch.resolve("unsupported"), SEED, 256), "Unsupported origin size");
Path missing = scratch.resolve("missing"); Files.createDirectory(missing);
reject(() -> new ExpansionJournal(missing, SEED, 512), "Existing directory with no state");
Files.writeString(missing.resolve(".expansions-interrupted.tmp"), "partial");
reject(() -> new ExpansionJournal(missing, SEED, 512), "Interrupted bootstrap");
require(!Files.exists(missing.resolve(ExpansionJournal.FILE_NAME)), "Missing journal was silently created");
String valid = Files.readString(directory.resolve(ExpansionJournal.FILE_NAME));
String[] invalid = {
"{",
valid + " {}",
valid.replace("\"schema\": 1", "\"schema\": 2"),
valid.replace("\"schema\": 1", "\"schema\": 1, \"schema\": 1"),
valid.replace("\"schema\": 1", "\"schema\": 1.1"),
valid.replace("\"root_diameter\": 512", "\"root_diameter\": \"512\""),
valid.replace("\"center_x\": 0", "\"center_x\": 0.1"),
valid.replace("\"generation\": 12", "\"generation\": 13"),
valid.replace("\"parent\": \"sanctuary\"", "\"parent\": \"unknown\""),
valid.replace("\"id\": \"north_one\"", "\"id\": \"sanctuary\""),
valid.replace("\"seed\": " + north.seed(), "\"seed\": 0"),
valid.replace("\"center_z\": -1024", "\"center_z\": -16")
};
for (int i = 0; i < invalid.length; i++) {
Path damaged = scratch.resolve("damaged_" + i); Files.createDirectory(damaged);
Path state = damaged.resolve(ExpansionJournal.FILE_NAME); Files.writeString(state, invalid[i]);
byte[] before = Files.readAllBytes(state);
reject(() -> new ExpansionJournal(damaged, SEED, 512), "Corrupt journal " + i);
require(Arrays.equals(before, Files.readAllBytes(state)), "Corrupt journal was overwritten");
}
// A second writer and external damage must fail closed before replacing the on-disk plan.
var stale = new ExpansionJournal(directory, SEED, 512);
ExpansionIsland east = island("east_one", "sanctuary", "east", 2048, 0);
journal.commit(List.of(root, north.ready(), east));
reject(() -> stale.commit(List.of(root, north.ready())), "Stale writer");
Path state = directory.resolve(ExpansionJournal.FILE_NAME);
Files.writeString(state, "broken", StandardCharsets.UTF_8);
reject(() -> journal.commit(List.of(root, north.ready(), east.ready())), "Externally damaged state");
require(Files.readString(state).equals("broken"), "Damaged live state was overwritten");
// A retained reservation can be completed after reload, then extended through its ready parent.
Path resumeDirectory = scratch.resolve("resume");
var beforeCrash = new ExpansionJournal(resumeDirectory, SEED, 512);
beforeCrash.commit(List.of(root, north));
var afterCrash = new ExpansionJournal(resumeDirectory, SEED, 512);
ExpansionIsland onward = island("onward", "north_one", "north", 0, -2048);
reject(() -> afterCrash.commit(List.of(root, north, onward)), "Unfinished parent");
afterCrash.commit(List.of(root, north.ready()));
afterCrash.commit(List.of(root, north.ready(), onward));
require(new ExpansionJournal(resumeDirectory, SEED, 512).islands().size() == 3, "Resumed region lost its descendant");
var tooMany = new ArrayList<ExpansionIsland>(); tooMany.add(root);
for (int i = 0; i < 64; i++) tooMany.add(island("overflow_" + i, "sanctuary", "north", 0, -(i + 1) * 1024));
reject(() -> afterCrash.commit(tooMany), "Region count limit");
System.out.println("Expansion journal smoke passed: atomic persistence, restart, immutable history, corruption and interrupted reservation");
} finally {
try (var paths = Files.walk(scratch)) {
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
}
}
}
private static ExpansionIsland island(String id, String parent, String direction, int x, int z) {
return new ExpansionIsland(id, parent, direction, ExpansionIsland.directionalClimate(direction), 64, x, z,
ExpansionIsland.deriveSeed(SEED, id), "natural", "reserved", ExpansionIsland.GENERATION);
}
private static void reject(IOAction action, String description) throws Exception {
try { action.run(); } catch (IOException expected) { return; }
throw new AssertionError(description + " was accepted");
}
private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); }
@FunctionalInterface private interface IOAction { void run() throws Exception; }
}
@@ -0,0 +1,88 @@
package fr.koka.sanctuary.expansion;
/** Coordinate continuity, finite small islands, preserved root underside and real upper landforms. */
public final class ExpansionReliefSmoke {
public static void main(String[] args) {
for (long seed : new long[]{0, 42, 8675309, Long.MIN_VALUE, Long.MAX_VALUE}) {
for (int diameter : new int[]{512, 724}) {
var root = ExpansionIsland.origin(diameter, seed);
require(ExpansionRelief.verticalScale(root) == 1.0, "Root height must not be compressed");
for (int x = -300; x <= 300; x += 31) for (int z = -300; z <= 300; z += 37) {
var column = ExpansionRelief.column(root, x, z);
require(ExpansionRelief.sourceX(root, x) == x && ExpansionRelief.sourceZ(root, z) == z,
"Rounded public root diameter must not rescale the old coordinates");
for (int y = 1; y <= 160; y += 7) {
require(ExpansionRelief.sourceY(root, column, y) == y, "Keep root underside exactly in place");
require(ExpansionRelief.sculpt(root, column, y, 0.7F) == 0.7F, "Do not carve the natural root underside");
}
}
}
for (int diameter : ExpansionIsland.DIAMETERS) for (String relief : ExpansionIsland.RELIEFS) {
var island = island(seed, diameter, relief, 1024, -2048);
require(ExpansionRelief.verticalScale(island) >= 0.4 && ExpansionRelief.verticalScale(island) <= 1.0,
"Small islands keep enough vertical space for two levels");
for (int x = -diameter; x <= diameter; x += Math.max(1, diameter / 12)) {
int wx = island.centerX() + x, wz = island.centerZ() + diameter / 11;
var column = ExpansionRelief.column(island, wx, wz);
require(column.equals(ExpansionRelief.column(island, wx, wz)), "Seed and coordinates reproduce the relief");
require(Math.abs(column.uplift()) <= 48 && column.canyonWeight() >= 0 && column.canyonWeight() <= 1
&& column.craterWeight() >= 0 && column.craterWeight() <= 1, "Deformation is finite and bounded");
double previous = Double.NEGATIVE_INFINITY;
for (int y = 0; y <= 384; y++) {
double sy = ExpansionRelief.sourceY(island, column, y);
require(Double.isFinite(sy) && sy > previous, "Y remapping must not fold or invert the source terrain");
previous = sy;
require(ExpansionRelief.sculpt(island, column, y, -0.3F) == -0.3F, "Cuts retain preexisting void");
float density = ExpansionRelief.sculpt(island, column, y, 0.8F);
require(Float.isFinite(density) && density <= 0.8F && density >= -1.0F, "Cuts never add material");
}
if (Math.abs(x) >= island.radius()) require(column.equals(new ExpansionRelief.Column(0, 0, 0)),
"Upper deformation fades before the outer rim");
}
var shifted = island(seed, diameter, relief, -4096, 8192);
require(ExpansionRelief.column(island, island.centerX() + 9, island.centerZ() - 7)
.equals(ExpansionRelief.column(shifted, shifted.centerX() + 9, shifted.centerZ() - 7)),
"Moving an island must not change its local relief");
if (relief.equals("volcano")) {
require(ExpansionRelief.inCrater(island, island.centerX(), island.centerZ()), "A volcano has an inner bowl");
require(!ExpansionRelief.inCrater(island, island.centerX() + diameter / 4, island.centerZ()), "Its flank is not the crater");
int center = syntheticSurface(island, 0, 0);
int flank = syntheticSurface(island, diameter / 8, 0);
require(flank - center >= 8, "A volcanic profile must form an elevated ring around an actual depressed centre");
}
if (relief.equals("canyon")) {
var center = ExpansionRelief.column(island, island.centerX(), island.centerZ());
require(ExpansionRelief.sculpt(island, center, ExpansionRelief.worldY(island, 220), 1.0F) < 0,
"The canyon must cut through a solid upper slab");
require(ExpansionRelief.sculpt(island, center, ExpansionRelief.worldY(island, 110), 1.0F) == 1.0F,
"The new canyon leaves the lowest support intact");
}
}
}
var first = island(0, 512, "peaks", 0, 0);
var second = island(42, 512, "peaks", 0, 0);
require(!ExpansionRelief.column(first, 67, -34).equals(ExpansionRelief.column(second, 67, -34)),
"The island seed must change upper relief");
int spread = 0;
for (int x = -160; x <= 160; x += 8) spread = Math.max(spread, Math.abs(syntheticSurface(first, x, 0) - syntheticSurface(first, 0, 0)));
require(spread >= 8, "Peaks must produce a visible vertical difference, not only a renamed palette");
System.out.println("ExpansionReliefSmoke: monotone scaling, preserved root underside, small-island bounds, canyon and volcanic ring passed.");
}
private static ExpansionIsland island(long seed, int diameter, String relief, int x, int z) {
return new ExpansionIsland("test", "sanctuary", "south", "warm", diameter, x, z, seed, relief, "ready", 12);
}
private static int syntheticSurface(ExpansionIsland island, int dx, int dz) {
var column = ExpansionRelief.column(island, island.centerX() + dx, island.centerZ() + dz);
int surface = -1;
for (int y = 1; y < 384; y++) {
double sy = ExpansionRelief.sourceY(island, column, y);
float slab = (float) Math.clamp(Math.min((sy - 70.0) / 12.0, (240.0 - sy) / 12.0), -1, 1);
if (ExpansionRelief.sculpt(island, column, y, slab) > 0) surface = y;
}
return surface;
}
private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); }
}
@@ -6,8 +6,8 @@ import com.google.gson.JsonPrimitive;
/** Capacity and region boundaries are persistence contracts, including negative coordinates. */
public final class PopulationCapacitySmoke {
public static void main(String[] args) {
require(IslandCapacity.values().length == 3, "Only the three validated sizes are exposed");
for (int players : new int[]{5, 20, 100}) {
require(IslandCapacity.values().length == 4, "New small/large and both historical geometries remain supported");
for (int players : new int[]{5, 10, 20, 100}) {
var capacity = IslandCapacity.fromPlayers(players);
double ratio = Math.pow(capacity.radius() / IslandCapacity.FIVE.radius(), 2);
require(Math.abs(ratio - players / 5.0) < 1e-10, "Area scales with planned population");
@@ -34,7 +34,7 @@ public final class PopulationCapacitySmoke {
}
}
}
for (int invalid : new int[]{0, 1, 4, 6, 10, 21, 99, 101, -5}) {
for (int invalid : new int[]{0, 1, 4, 6, 11, 21, 99, 101, -5}) {
require(IslandCapacity.CODEC.parse(JsonOps.INSTANCE, new JsonPrimitive(invalid)).error().isPresent(),
"Unsupported saved counts must fail, not silently become a different island");
boolean rejected = false;
@@ -42,7 +42,7 @@ public final class PopulationCapacitySmoke {
catch (IllegalArgumentException expected) { rejected = true; }
require(rejected, "Unsupported configuration is rejected");
}
System.out.println("Population capacity smoke passed: 5/20/100, codec, area and signed region ownership");
System.out.println("Population capacity smoke passed: small/large, legacy sizes, codec and signed region ownership");
}
private static void require(boolean condition, String message) {
@@ -0,0 +1,65 @@
package fr.koka.sanctuary.worldgen;
import java.util.ArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/** Detects the world-wide stall when a cold region holds the planning lock. */
public final class PopulationPlanCacheSmoke {
public static void main(String[] args) throws Exception {
var cache = new PopulationPlanCache<String, Integer>();
cache.getOrCreate("ready", () -> 10);
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
try (var workers = Executors.newFixedThreadPool(8)) {
Future<Integer> cold = workers.submit(() -> cache.getOrCreate("cold", () -> {
entered.countDown();
try {
if (!release.await(10, TimeUnit.SECONDS)) throw new AssertionError("Cold plan was not released");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new AssertionError(e);
}
return 20;
}));
try {
require(entered.await(5, TimeUnit.SECONDS), "Cold plan did not start");
Future<Integer> ready = workers.submit(() -> cache.getOrCreate("ready", () -> {
throw new AssertionError("Completed plan must not be rebuilt");
}));
require(ready.get(5, TimeUnit.SECONDS) == 10, "Warm reads must not await unrelated planning");
require(cache.size() == 1, "Unfinished plans must not be exposed");
} finally {
release.countDown();
}
require(cold.get(5, TimeUnit.SECONDS) == 20, "Cold result lost");
var builds = new AtomicInteger();
var start = new CountDownLatch(1);
var results = new ArrayList<Future<Integer>>();
for (int i = 0; i < 8; i++) results.add(workers.submit(() -> {
start.await();
return cache.getOrCreate("shared", builds::incrementAndGet);
}));
start.countDown();
for (var result : results) require(result.get(5, TimeUnit.SECONDS) == 1, "All callers need the same complete plan");
require(builds.get() == 1, "Concurrent misses must compute their region once");
}
try {
cache.getOrCreate("failed", () -> { throw new IllegalStateException("Expected planner failure"); });
throw new AssertionError("Planner failure was swallowed");
} catch (IllegalStateException expected) {
require(cache.size() == 3, "Failed plan must not be published");
}
require(cache.getOrCreate("failed", () -> 40) == 40, "A failed computation must remain retryable");
System.out.println("Population plan cache smoke passed: warm reads, single publication and failed-plan retry");
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
+8 -6
View File
@@ -51,16 +51,18 @@ Pour produire un fichier importable par un lanceur compatible Modrinth :
```sh
cd build/packwiz # depuis la racine du dépôt
packwiz modrinth export --output ../Sanctuary-0.1.0-alpha.10.mrpack
packwiz modrinth export --output ../Sanctuary-0.1.0-alpha.11.mrpack
```
Lexemple vise lalpha.11 et nécessite ses artefacts vérifiés.
Cette version propose Petit/Grand, avec Grand par défaut, et loptimisation ;
les anciennes tailles restent attachées aux sauvegardes déjà créées.
L'export contient le JAR Sanctuary local et la référence vérifiée de Fabric API.
Il ne publie rien sur Modrinth.
Les artefacts alpha.10 sont vérifiés localement. Les instances Sanctuary
Beta déjà raccordées continuent dutiliser le canal stable et recevront sa
mise à jour après publication, sans nouvelle importation.
Lalpha.9 est publiée et vérifiée. Linstance Sanctuary Beta existante suit
le canal packwiz ; cet export reste destiné aux imports ponctuels.
Lalpha.11 est publiée sur le canal stable. Linstance Sanctuary Beta existante
a été synchronisée deux fois et ses fichiers personnels suivis sont conservés.
Les instances déjà raccordées reçoivent cette mise à jour sans nouvelle importation.
Cet export reste destiné aux imports ponctuels.
Utiliser l'URL HTTP affichée de `pack.toml` dans packwiz-installer. Le serveur
local doit rester actif pendant l'installation. Ce n'est pas une publication
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "0.1.0-alpha.10"
version = "0.1.0-alpha.12"
pack-format = "packwiz:1.1.0"
[index]