Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdfbba0089 |
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0-alpha.12 — 2026-09-09, livraison locale
|
||||
|
||||
- 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 d’une 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. L’alpha.11 reste sur le canal stable. 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)
|
||||
|
||||
@@ -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. L’alpha.12 fournit des
|
||||
outils d’expansion réservés à un nouveau monde de laboratoire.
|
||||
|
||||
## Socle livré jusqu’à l’alpha.8
|
||||
|
||||
@@ -159,6 +160,44 @@ de la même instance sont consignées dans [Distribution](docs/packwiz.md).
|
||||
L’alpha.11 est publiée ; la même instance Prism a été synchronisée deux fois,
|
||||
avec les 364 fichiers personnels et réglages suivis conservés.
|
||||
|
||||
## Alpha.12 — laboratoire d’expansion
|
||||
|
||||
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 l’est 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 l’emplacement sans l’activer ;
|
||||
`resume <id>` reprend une réservation interrompue. L’autocomplétion liste les
|
||||
directions, douze profils climatiques et quatre reliefs.
|
||||
|
||||
Les anciens chunks, même vides, sont refusés dans l’emprise d’une 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 d’expansion/rechargement de graine 42
|
||||
passent. Le JAR et le pack sont construits localement ; le canal stable et
|
||||
l’instance Prism restent en alpha.11.
|
||||
|
||||
## Versions et prérequis
|
||||
|
||||
Au 8 septembre 2026, la cible disponible est **26.3-pre-2**. Le dépôt ne prétend
|
||||
@@ -173,7 +212,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.11 |
|
||||
| Sanctuary / pack | 0.1.0-alpha.12, construite et vérifiée localement |
|
||||
|
||||
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
|
||||
@@ -200,7 +239,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).
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# Alpha.12 — laboratoire d’expansion
|
||||
|
||||
Ticket local **WG-13**, branche `codex/expansion-lab`. Contrat du laboratoire et guide
|
||||
d’essai. 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 s’appliquent 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.
|
||||
|
||||
L’alpha 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 d’ouverture 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 n’existe 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 d’une î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 l’ouverture.
|
||||
- Le journal est écrit atomiquement et relu avant de publier le nouveau plan au
|
||||
générateur. Un journal existant absent ou corrompu n’est 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 d’une ancienne région
|
||||
n’est 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, jusqu’au 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 d’origine 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 n’est 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 n’importe quelle île prête. `preview` emploie les mêmes paramètres ;
|
||||
il propose des coordonnées mais ne valide ni l’occupation des chunks ni le sol.
|
||||
Répéter un `create` identique rend la même réservation ; un autre profil sous
|
||||
le même identifiant est refusé. `resume <id>` reprend une préparation interrompue.
|
||||
Après une erreur du journal, il faut rétablir le stockage puis recharger le monde.
|
||||
|
||||
| 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 l’altitude et de variations locales ; une
|
||||
petite île n’affiche 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`. L’autocomplé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 d’identifiant 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 n’est 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 l’enveloppe de terrain. Les petites îles sont donc encore assez éloignées :
|
||||
le test de diamètre 64 place une première île à 800 blocs du centre de Sanctuary,
|
||||
puis une branche à 544 blocs de son parent. Un emplacement déjà exploré est
|
||||
refusé ; le laboratoire ne réécrit pas le vide pour rapprocher les îles.
|
||||
Il existe une limite de 64 régions, origine comprise, et de ±100 000 blocs pour
|
||||
leurs centres. La vérification initiale d’occupation est synchrone et peut
|
||||
suspendre brièvement le serveur pendant les lectures disque ; la préparation
|
||||
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 n’est 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 à l’est de celle-ci. Chaque préparation
|
||||
a atteint 100 chunks FULL. Les biomes sauvegardés, les points d’arrivée sur sol
|
||||
réel, les permissions du dispatcher, le refus des doublons incohérents, la
|
||||
conservation d’un 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 n’a 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 d’or écrit avant l’arrê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 d’un 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 l’origine 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.
|
||||
@@ -419,6 +419,38 @@ valide pas le rendu ni un temps de chargement universel. Voir
|
||||
[Distribution](packwiz.md) pour le reçu de publication et de synchronisation.
|
||||
Ce ticket local n’est pas une issue distante publiée.
|
||||
|
||||
### WG-13 — Laboratoire d’expansion à 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 d’acceptation :**
|
||||
|
||||
- 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 d’emprise/support ;
|
||||
preuve moteur d’une 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 d’un 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. Publication non effectuée ;
|
||||
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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
+14
-7
@@ -1,8 +1,9 @@
|
||||
# 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.
|
||||
L’alpha.12 fournit un laboratoire avec registre persistant et commandes opérateur,
|
||||
suivant le [contrat de laboratoire](alpha12-laboratory.md). Le déblocage par
|
||||
recherches et contributions reste à implémenter. Le relevé de ressources décrit plus bas
|
||||
est un outil de développement séparé, exécuté dans le monde jetable des GameTests.
|
||||
|
||||
## Taille de l’île initiale — alpha.11
|
||||
@@ -19,11 +20,13 @@ sauvegardes 5/20/100 conservent leurs réglages et leurs dimensions. Les formats
|
||||
20/100 sont cachés de la création. Les mesures de l’alpha.10 conservées plus bas
|
||||
restent historiques ; les contrôles alpha.11 sont dans [Validation](testing.md).
|
||||
|
||||
## 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 d’autres découvertes.
|
||||
|
||||
| Direction | Axe Minecraft depuis Sanctuary | Tendance climatique |
|
||||
| --- | --- | --- |
|
||||
@@ -36,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 d’origine 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
|
||||
|
||||
@@ -13,6 +13,15 @@ JAR Sanctuary sont des pièces jointes de releases Gitea. Aucun JAR de mod ou
|
||||
dépendance téléchargée n'entre dans l'historique Git. Le canal Beta est distinct
|
||||
de l'ancien pack 26.2.
|
||||
|
||||
## Alpha.12 locale
|
||||
|
||||
Le ticket WG-13 construit le JAR alpha.12 et son pack de développement sous
|
||||
`build/packwiz/`. Il ajoute un preset laboratoire pour de **nouveaux mondes**.
|
||||
La release publique, le canal stable et l’instance Prism restent en alpha.11 :
|
||||
la validation locale ne publie ni ne synchronise l’installation personnelle.
|
||||
Le [guide](alpha12-laboratory.md) et les [essais](testing.md) décrivent ce qui
|
||||
est prêt à tester. Une future livraison suivra la procédure ci-dessous.
|
||||
|
||||
## Livrer une mise à jour
|
||||
|
||||
Dernière livraison vérifiée le 9 septembre 2026 :
|
||||
|
||||
+45
-1
@@ -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 d’un 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 d’or écrit avant l’arrê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é n’est simulé. Le crash forcé au milieu d’une 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 n’a servi aux essais.
|
||||
|
||||
## Tests de forme
|
||||
|
||||
La tâche `:sanctuary:worldgenSmoke`, incluse dans `check`, vérifie les invariants de
|
||||
@@ -43,7 +86,8 @@ l’absence 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.
|
||||
|
||||
+19
-5
@@ -134,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 l’arché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 l’histoire des découvertes de son univers.
|
||||
La recherche porte sur des climats et leurs distributions, sans imposer la même
|
||||
forme ni le même climat à une direction pour toutes les parties.
|
||||
|
||||
L’alpha.12 sert de laboratoire à cette conception : commandes opérateur pour
|
||||
créer et visiter les continents, reliefs hauts aussi expérimentés sur Sanctuary
|
||||
Island et structures vanilla compatibles. Ces essais sont réservés à une nouvelle
|
||||
génération. Les recherches, mailboxes et deposit boxes viendront ensuite ; elles
|
||||
ne sont pas nécessaires aux commandes d’essai. Le passage en bêta interviendra
|
||||
après clarification du périmètre de la vision et de la progression à développer.
|
||||
|
||||
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,
|
||||
@@ -154,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.
|
||||
L’autorisation ne garantit pas leur présence sur chaque île. Le laboratoire
|
||||
commence par les villages, temples, cabanes de marais et camps abandonnés.
|
||||
- **Lost Cities** : des zones traitées comme des biomes, où des bâtiments en ruine remplacent la végétation dominante. Le nom exprime ici un type de territoire souhaité, sans décider encore s'il sera produit par une intégration ou par du code Sanctuary.
|
||||
- **Structures contemporaines** : parcs, parkings et autres vestiges. Les parkings sont souterrains et peuvent s'étendre comme une infestation. Ils n'apparaissent pas dans la dimension des cavernes.
|
||||
- **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.
|
||||
|
||||
@@ -9,6 +9,22 @@ L’alpha.11 propose deux choix de création, **Petit** et **Grand**, avec Grand
|
||||
par défaut. Les anciennes tailles 20/100 restent chargeables dans les sauvegardes.
|
||||
Les observations de chaque version restent dans [Validation](testing.md).
|
||||
|
||||
## Laboratoire d’expansion — 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 n’est fournie.
|
||||
Le [guide alpha.12](alpha12-laboratory.md) décrit les outils, les limites et les
|
||||
observations réelles. Les sections historiques ci-dessous gardent leur portée.
|
||||
|
||||
## Petit et Grand — alpha.11
|
||||
|
||||
| Choix public | Diamètre nominal | Aire nominale relative | Preset | Réglage enregistré |
|
||||
|
||||
+2
-2
@@ -8,6 +8,6 @@ loader_version=0.19.5
|
||||
loom_version=1.17.20
|
||||
fabric_api_version=0.160.0+26.3
|
||||
|
||||
mod_version=0.1.0-alpha.11
|
||||
pack_version=0.1.0-alpha.11
|
||||
mod_version=0.1.0-alpha.12
|
||||
pack_version=0.1.0-alpha.12
|
||||
maven_group=fr.koka.sanctuary
|
||||
|
||||
@@ -26,8 +26,9 @@ 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') {
|
||||
@@ -36,13 +37,17 @@ tasks.named('runGameTest') {
|
||||
systemProperty('sanctuary.test.players', providers.gradleProperty('sanctuaryTestPlayers').getOrElse('10'))
|
||||
systemProperty('sanctuary.test.resourceSurvey', providers.gradleProperty('sanctuaryResourceSurvey').getOrElse('sample'))
|
||||
systemProperty('sanctuary.test.densityMaps', providers.gradleProperty('sanctuaryDensityMaps').getOrElse('true'))
|
||||
systemProperty('sanctuary.test.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: benchmark
|
||||
expand(gametest_entrypoint: expansion ? 'fr.koka.sanctuary.gametest.ExpansionWorldGameTests' : benchmark
|
||||
? 'fr.koka.sanctuary.gametest.WorldgenPerformanceGameTests'
|
||||
: 'fr.koka.sanctuary.gametest.SanctuaryWorldGameTests')
|
||||
}
|
||||
@@ -241,3 +246,17 @@ tasks.register('populationPlanCacheSmoke', JavaExec) {
|
||||
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') }
|
||||
|
||||
+84
@@ -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");
|
||||
}
|
||||
}
|
||||
+434
@@ -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()); }
|
||||
}
|
||||
+5
-2
@@ -38,8 +38,11 @@ final class PopulationPersistenceDiagnostics {
|
||||
"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")),
|
||||
"World creation must expose only small and large Sanctuary presets");
|
||||
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);
|
||||
|
||||
+14
@@ -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,6 +44,7 @@ public abstract class SanctuaryGameTestServerMixin {
|
||||
@Redirect(method = "lambda$create$1", at = @At(value = "FIELD", target =
|
||||
"Lnet/minecraft/world/level/levelgen/presets/WorldPresets;FLAT_ALL_DIMENSIONS:Lnet/minecraft/resources/ResourceKey;"))
|
||||
private static ResourceKey<WorldPreset> sanctuary$selectActualPreset() {
|
||||
if (Boolean.getBoolean("sanctuary.test.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";
|
||||
@@ -47,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());
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
+146
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,5 +50,61 @@
|
||||
"biome.sanctuary.population_sulfur_depths": "Sulfur Depths",
|
||||
"biome.sanctuary.population_lush_caves": "Sanctuary Lush Caves",
|
||||
"biome.sanctuary.population_dripstone_caves": "Sanctuary Dripstone Caves",
|
||||
"generator.sanctuary.sanctuary_20": "Sanctuary (20 players — legacy size)"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -50,5 +50,61 @@
|
||||
"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",
|
||||
"generator.sanctuary.sanctuary_20": "Sanctuary (20 joueurs — ancien format)"
|
||||
"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 d’arrivé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 d’expansion 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 d’expansion est indisponible. La préparation est arrêtée ; corrigez le stockage puis rechargez le monde avant de reprendre.",
|
||||
"sanctuary.expansion.error.invalid_id": "L’identifiant doit commencer par une lettre minuscule et contenir au plus 32 lettres minuscules, chiffres ou tirets bas.",
|
||||
"sanctuary.expansion.error.invalid_climate": "Climat inconnu. Utiliser l’autocomplétion pour choisir un profil.",
|
||||
"sanctuary.expansion.error.invalid_diameter": "Les diamètres autorisés sont 64, 128, 256 et 512 blocs.",
|
||||
"sanctuary.expansion.error.invalid_relief": "Relief inconnu : natural, peaks, canyon ou volcano."
|
||||
}
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@
|
||||
"replace": false,
|
||||
"values": [
|
||||
"sanctuary:sanctuary",
|
||||
"sanctuary:sanctuary_5"
|
||||
"sanctuary:sanctuary_5",
|
||||
"sanctuary:expansion_lab"
|
||||
]
|
||||
}
|
||||
|
||||
+70
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "0.1.0-alpha.11"
|
||||
version = "0.1.0-alpha.12"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
Reference in New Issue
Block a user