Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
332d60be96 |
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0-alpha.12.1 — 2026-09-09, correctif local
|
||||
|
||||
- Corrige le blocage de toutes les directions lorsque du vide a déjà été
|
||||
généré : la recherche continue plus loin sur le même rayon, sans remplacer
|
||||
les chunks existants.
|
||||
- Paramètre final `distance` facultatif pour `quick`, `create`, `preview` :
|
||||
distance minimale entre centres en blocs, de 0 à 100 000 ; 0 ou omis = auto.
|
||||
- Recherche limitée à 32 positions et un budget global de lecture de 30 secondes.
|
||||
Les erreurs disque et timeouts interrompent la demande ; seul un emplacement
|
||||
occupé permet de poursuivre. La graine et le profil sont conservés.
|
||||
- Compatibilité des commandes précédentes et des mondes laboratoire alpha.12 :
|
||||
journal v1 et génération 12 inchangés, sans migration ni régénération.
|
||||
|
||||
Les contrôles moteur de création et de rechargement passent sur la graine 42,
|
||||
avec huit premiers sites occupés et distance explicite. `check build assemblePack`
|
||||
passe, avec les douze tests historiques. Publication à consigner. Voir [WG-14](docs/backlog.md).
|
||||
|
||||
## 0.1.0-alpha.12 — 2026-09-09
|
||||
|
||||
- Nouveau preset Sanctuary — Laboratoire, distinct de Petit/Grand. Outils
|
||||
|
||||
@@ -199,6 +199,24 @@ passent. L’alpha.12, son MRpack et le canal packwiz sont publiés ; la même
|
||||
instance Prism a été synchronisée deux fois, avec les 466 fichiers personnels
|
||||
et réglages suivis conservés. Voir [Distribution](docs/packwiz.md).
|
||||
|
||||
## Alpha.12.1 — distance et emplacements libres
|
||||
|
||||
Le correctif ajoute une distance minimale facultative à la fin des commandes :
|
||||
|
||||
```text
|
||||
/sanctuary expansion quick north autumn 128 peaks 2048
|
||||
/sanctuary expansion create desert sanctuary east arid 128 natural 4096
|
||||
```
|
||||
|
||||
La distance se mesure entre les centres depuis le parent choisi. Sans distance,
|
||||
le placement reste automatique. Les emplacements déjà générés sont maintenant
|
||||
écartés en cherchant plus loin dans la même direction, sans changer le climat
|
||||
ni réécrire les chunks. Les mondes laboratoire alpha.12 peuvent être conservés :
|
||||
le journal et la génération restent compatibles. Voir le [guide](docs/alpha12-laboratory.md).
|
||||
|
||||
`check build assemblePack`, les scénarios moteur et les tests de distance passent.
|
||||
Publication de l’alpha.12.1 en cours.
|
||||
|
||||
## Versions et prérequis
|
||||
|
||||
Au 8 septembre 2026, la cible disponible est **26.3-pre-2**. Le dépôt ne prétend
|
||||
@@ -213,7 +231,7 @@ sera une mise à jour explicite, avec vérification des API et des sauvegardes.
|
||||
| Fabric API | 0.160.0+26.3 |
|
||||
| Fabric Loom | 1.17.20 |
|
||||
| Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 |
|
||||
| Sanctuary / pack | 0.1.0-alpha.12, publiée et synchronisée |
|
||||
| Sanctuary / pack | 0.1.0-alpha.12.1, 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
|
||||
@@ -240,7 +258,7 @@ décrits dans [Validation](docs/testing.md).
|
||||
|
||||
Résultats :
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.12.jar` : mod à installer avec
|
||||
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.12.1.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).
|
||||
|
||||
@@ -66,13 +66,37 @@ avec un camp abandonné ; aucune structure n’est garantie sur chaque graine.
|
||||
```
|
||||
|
||||
`quick` part toujours de Sanctuary et attribue un identifiant `island_N`.
|
||||
`create <id> <parent> <direction> <climate> <diameter> <relief>` ouvre une branche
|
||||
`create <id> <parent> <direction> <climate> <diameter> <relief> [distance]` ouvre une branche
|
||||
nommée depuis n’importe quelle île prête. `preview` emploie les mêmes paramètres ;
|
||||
il propose des coordonnées mais ne valide ni l’occupation des chunks ni le sol.
|
||||
Répéter un `create` identique rend la même réservation ; un autre profil sous
|
||||
le même identifiant est refusé. `resume <id>` reprend une préparation interrompue.
|
||||
Après une erreur du journal, il faut rétablir le stockage puis recharger le monde.
|
||||
|
||||
Depuis l’**alpha.12.1**, `quick`, `create` et `preview` acceptent une distance
|
||||
minimale facultative à la fin, de 0 à 100 000 blocs. Elle mesure la distance
|
||||
entre les centres du parent et de l’enfant, y compris en diagonale. L’alignement
|
||||
des chunks et les marges du terrain peuvent éloigner légèrement le résultat.
|
||||
Une valeur de 0, ou son omission, laisse le placement automatique.
|
||||
|
||||
```text
|
||||
/sanctuary expansion quick north autumn 128 peaks 2048
|
||||
/sanctuary expansion create desert sanctuary east arid 128 natural 4096
|
||||
/sanctuary expansion preview glacier sanctuary northeast glacial 256 canyon 2048
|
||||
```
|
||||
|
||||
Si une zone est déjà générée, `create` et `quick` essaient plus loin dans la
|
||||
même direction, sur 32 positions maximum avec un budget total de lecture de
|
||||
30 secondes. Le climat, le relief et la graine restent ceux de la demande.
|
||||
`preview` propose seulement le premier emplacement géométrique ; le résultat
|
||||
réel peut être plus loin. Les erreurs de lecture interrompent la recherche.
|
||||
Les anciens appels restent valides. Un identifiant déjà créé garde sa position ;
|
||||
demander sous le même nom une distance supérieure à sa distance réelle est refusé.
|
||||
|
||||
Cette correction utilise le même journal v1 et les mêmes descripteurs de
|
||||
génération 12 : le monde laboratoire alpha.12 actuel peut être conservé. Aucun
|
||||
ancien chunk n’est régénéré ; la recherche réserve uniquement une nouvelle zone.
|
||||
|
||||
| Profil de commande | Surface recherchée |
|
||||
| --- | --- |
|
||||
| `temperate` | Chênes, bouleaux, clairières et fleurs |
|
||||
@@ -127,7 +151,7 @@ La réservation ajoute 32 blocs de décoration et 176 blocs de halo moteur autou
|
||||
de l’enveloppe de terrain. Les petites îles sont donc encore assez éloignées :
|
||||
le test de diamètre 64 place une première île à 800 blocs du centre de Sanctuary,
|
||||
puis une branche à 544 blocs de son parent. Un emplacement déjà exploré est
|
||||
refusé ; le laboratoire ne réécrit pas le vide pour rapprocher les îles.
|
||||
écarté ; depuis l’alpha.12.1, la recherche continue plus loin sans réécrire ce vide.
|
||||
Il existe une limite de 64 régions, origine comprise, et de ±100 000 blocs pour
|
||||
leurs centres. La vérification initiale d’occupation est synchrone et peut
|
||||
suspendre brièvement le serveur pendant les lectures disque ; la préparation
|
||||
|
||||
@@ -453,6 +453,34 @@ dans [Laboratoire alpha.12](alpha12-laboratory.md).
|
||||
Ce ticket ne clôture pas par avance les océans, toute la palette de structures
|
||||
ou la progression collective prévus par WG-04 à WG-07 et la vision.
|
||||
|
||||
### WG-14 — Distance et recherche d’un emplacement libre — alpha.12.1
|
||||
|
||||
**Branche :** `codex/expansion-free-space`.
|
||||
|
||||
**Problème :** les huit premiers emplacements peuvent contenir du vide déjà
|
||||
sauvegardé. L’alpha.12 refuse alors toutes les directions au lieu de chercher
|
||||
plus loin, et ne propose aucun paramètre de distance.
|
||||
|
||||
**Contrat et résultat attendu :**
|
||||
|
||||
- Distance minimale facultative en fin de `quick`, `create` et `preview`, en
|
||||
blocs entre les centres du parent et de l’enfant ; 0 ou omission = automatique.
|
||||
- Rechercher au plus 32 positions sur le même rayon en dépassant les zones
|
||||
occupées ; conserver le budget global de lecture de 30 secondes.
|
||||
- Seule une occupation certaine autorise un nouvel essai. Une erreur disque,
|
||||
un timeout ou un terrain sans sol ne doivent pas devenir un nouveau tirage.
|
||||
- Préserver les anciennes commandes, les identifiants, la graine de chaque île,
|
||||
les descripteurs de génération 12 et le journal v1. La correction s’applique
|
||||
aux mondes laboratoire alpha.12 sans conversion. Les régions existantes
|
||||
sont inchangées ; seules de nouvelles réservations peuvent être ajoutées.
|
||||
- Vérifier les huit premières directions occupées, la création plus loin,
|
||||
la distance demandée depuis une île fille et les chunks sauvegardés après
|
||||
redémarrage. Fournir les retours FR/EN et la distribution packwiz/MRpack.
|
||||
|
||||
**État :** correction implémentée, smokes et scénarios moteur de création/rechargement
|
||||
réussis. `check build assemblePack` et douze tests historiques réussis.
|
||||
Publication en cours.
|
||||
|
||||
## Réserve de thèmes futurs
|
||||
|
||||
Ces thèmes servent à retrouver la vision, pas à demander leur implémentation immédiate. On en extrait un ticket seulement lorsqu'il devient utile au prochain incrément jouable.
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
{
|
||||
"version": "0.1.0-alpha.12.1",
|
||||
"date": "2026-09-09",
|
||||
"minecraft": "26.3-pre-2",
|
||||
"java": 25,
|
||||
"generation": 12,
|
||||
"journal_schema": 1,
|
||||
"base_commit": "40a86588b5f83fbc7bd2728eb9b439baa1fc9ba5",
|
||||
"scope": "Development-world engine tests; no personal world, live player teleportation or client rendering. Process timings are not performance benchmarks.",
|
||||
"normalization": "world_directory fields replaced with <development_game_test_world>; other diagnostic fields unchanged.",
|
||||
"build": {
|
||||
"command": "./gradlew check build assemblePack",
|
||||
"passed": true,
|
||||
"required_historical_engine_tests": 12,
|
||||
"seed": 0,
|
||||
"profile": 10,
|
||||
"log_sha256": "72f2173023e5407391d7e0e528012fcda9a314c981acfea462918c36ffa7bf90"
|
||||
},
|
||||
"artifact": {
|
||||
"path": "mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.12.1.jar",
|
||||
"sha256": "c9df4da956cfa54581b3fde21de006bf8ac3ec025b8dd0daa5d060bcb70aabd4"
|
||||
},
|
||||
"first_process_diagnostic_sha256": "3ef35faf09130409135879ad3a4be4ceba4f5644a57ae9cdcec4cd4d6e84c06a",
|
||||
"reload_diagnostic_sha256": "401f2e6739de9efd3668aa27c5ef45a6e81e1acfde44442bf101e5ac6397074a",
|
||||
"fresh_world": {
|
||||
"seed": 42,
|
||||
"process_id": 5743,
|
||||
"generator_version": 12,
|
||||
"islands": [
|
||||
{
|
||||
"centre": [
|
||||
0,
|
||||
-1600
|
||||
],
|
||||
"diameter": 64,
|
||||
"upper_biome": "minecraft:jungle",
|
||||
"climate": "tropical",
|
||||
"landing": [
|
||||
-4,
|
||||
171,
|
||||
-1604
|
||||
],
|
||||
"landing_biome": "minecraft:jungle",
|
||||
"parent": "sanctuary",
|
||||
"id": "alpha"
|
||||
},
|
||||
{
|
||||
"centre": [
|
||||
2048,
|
||||
-1600
|
||||
],
|
||||
"diameter": 64,
|
||||
"upper_biome": "minecraft:desert",
|
||||
"climate": "arid",
|
||||
"landing": [
|
||||
2048,
|
||||
198,
|
||||
-1600
|
||||
],
|
||||
"landing_biome": "minecraft:desert",
|
||||
"parent": "alpha",
|
||||
"id": "beta"
|
||||
}
|
||||
],
|
||||
"scope": "Fresh laboratory world; real FULL chunks and native structure loot, asynchronous activation, branching, journal re-read and loaded void admission refusal. Journal re-read is not a process restart. The parent block witness represents a player edit; no player teleport, multiplayer or client rendering is simulated.",
|
||||
"registered_command_permission_checks": true,
|
||||
"parent_witness": [
|
||||
-16,
|
||||
248,
|
||||
-16
|
||||
],
|
||||
"root_landing": [
|
||||
-16,
|
||||
242,
|
||||
-16
|
||||
],
|
||||
"initial_occupied_directions": 8,
|
||||
"journal_reloaded_regions": 3,
|
||||
"native_structure": {
|
||||
"container": [
|
||||
28,
|
||||
247,
|
||||
73
|
||||
],
|
||||
"loot_table": "minecraft:barrels/abandoned_camp_barrel",
|
||||
"full_chunks": 12,
|
||||
"placed_block": "Block{minecraft:barrel}[facing=south,open=false]",
|
||||
"structure": "minecraft:abandoned_camp_forest",
|
||||
"mode": "natural candidate accepted by production ExpansionStructures, native decoration"
|
||||
},
|
||||
"restart_snapshot": {
|
||||
"world_directory": "<development_game_test_world>",
|
||||
"gold_witness": [
|
||||
2048,
|
||||
204,
|
||||
-1600
|
||||
],
|
||||
"journal_sha256": "21136a5e8f1857b10ef5673281c8a3a4568ae032641995c206ed2031e112aa5b",
|
||||
"islands": [
|
||||
{
|
||||
"id": "sanctuary",
|
||||
"parent": "sanctuary",
|
||||
"direction": "north",
|
||||
"climate": "temperate",
|
||||
"diameter": 512,
|
||||
"center_x": 0,
|
||||
"center_z": 0,
|
||||
"seed": 42,
|
||||
"relief": "natural",
|
||||
"status": "ready",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "alpha",
|
||||
"parent": "sanctuary",
|
||||
"direction": "north",
|
||||
"climate": "tropical",
|
||||
"diameter": 64,
|
||||
"center_x": 0,
|
||||
"center_z": -1600,
|
||||
"seed": 390175152143173586,
|
||||
"relief": "natural",
|
||||
"status": "ready",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "beta",
|
||||
"parent": "alpha",
|
||||
"direction": "east",
|
||||
"climate": "arid",
|
||||
"diameter": 64,
|
||||
"center_x": 2048,
|
||||
"center_z": -1600,
|
||||
"seed": 6281303726624369227,
|
||||
"relief": "natural",
|
||||
"status": "ready",
|
||||
"generation": 12
|
||||
}
|
||||
],
|
||||
"generator_codec": {
|
||||
"biome_source": {
|
||||
"biomes": [
|
||||
"minecraft:the_void",
|
||||
"minecraft:forest",
|
||||
"minecraft:birch_forest",
|
||||
"minecraft:flower_forest",
|
||||
"minecraft:plains",
|
||||
"minecraft:meadow",
|
||||
"minecraft:taiga",
|
||||
"minecraft:snowy_taiga",
|
||||
"minecraft:snowy_plains",
|
||||
"minecraft:grove",
|
||||
"minecraft:ice_spikes",
|
||||
"minecraft:frozen_peaks",
|
||||
"minecraft:old_growth_spruce_taiga",
|
||||
"minecraft:dark_forest",
|
||||
"minecraft:swamp",
|
||||
"minecraft:pale_garden",
|
||||
"minecraft:jungle",
|
||||
"minecraft:sparse_jungle",
|
||||
"minecraft:bamboo_jungle",
|
||||
"minecraft:mangrove_swamp",
|
||||
"minecraft:savanna",
|
||||
"minecraft:savanna_plateau",
|
||||
"minecraft:desert",
|
||||
"minecraft:badlands",
|
||||
"minecraft:eroded_badlands",
|
||||
"minecraft:wooded_badlands",
|
||||
"minecraft:windswept_hills",
|
||||
"minecraft:windswept_gravelly_hills",
|
||||
"minecraft:stony_peaks",
|
||||
"minecraft:dappled_forest",
|
||||
"minecraft:cherry_grove",
|
||||
"minecraft:lush_caves",
|
||||
"minecraft:dripstone_caves",
|
||||
"minecraft:sulfur_caves"
|
||||
]
|
||||
},
|
||||
"type": "sanctuary:expansion_lab"
|
||||
},
|
||||
"blocked_sites": [
|
||||
{
|
||||
"id": "blocked_north",
|
||||
"parent": "sanctuary",
|
||||
"direction": "north",
|
||||
"climate": "boreal",
|
||||
"diameter": 64,
|
||||
"center_x": 0,
|
||||
"center_z": -800,
|
||||
"seed": 6170446579001265223,
|
||||
"relief": "natural",
|
||||
"status": "reserved",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "blocked_northeast",
|
||||
"parent": "sanctuary",
|
||||
"direction": "northeast",
|
||||
"climate": "boreal",
|
||||
"diameter": 64,
|
||||
"center_x": 800,
|
||||
"center_z": -800,
|
||||
"seed": 2312608842580142629,
|
||||
"relief": "natural",
|
||||
"status": "reserved",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "blocked_east",
|
||||
"parent": "sanctuary",
|
||||
"direction": "east",
|
||||
"climate": "boreal",
|
||||
"diameter": 64,
|
||||
"center_x": 800,
|
||||
"center_z": 0,
|
||||
"seed": -5785143794938635053,
|
||||
"relief": "natural",
|
||||
"status": "reserved",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "blocked_southeast",
|
||||
"parent": "sanctuary",
|
||||
"direction": "southeast",
|
||||
"climate": "boreal",
|
||||
"diameter": 64,
|
||||
"center_x": 800,
|
||||
"center_z": 800,
|
||||
"seed": 6543995692374257629,
|
||||
"relief": "natural",
|
||||
"status": "reserved",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "blocked_south",
|
||||
"parent": "sanctuary",
|
||||
"direction": "south",
|
||||
"climate": "boreal",
|
||||
"diameter": 64,
|
||||
"center_x": 0,
|
||||
"center_z": 800,
|
||||
"seed": 9109857537375437182,
|
||||
"relief": "natural",
|
||||
"status": "reserved",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "blocked_southwest",
|
||||
"parent": "sanctuary",
|
||||
"direction": "southwest",
|
||||
"climate": "boreal",
|
||||
"diameter": 64,
|
||||
"center_x": -800,
|
||||
"center_z": 800,
|
||||
"seed": 7977138842745944844,
|
||||
"relief": "natural",
|
||||
"status": "reserved",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "blocked_west",
|
||||
"parent": "sanctuary",
|
||||
"direction": "west",
|
||||
"climate": "boreal",
|
||||
"diameter": 64,
|
||||
"center_x": -800,
|
||||
"center_z": 0,
|
||||
"seed": 8731833603567083481,
|
||||
"relief": "natural",
|
||||
"status": "reserved",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "blocked_northwest",
|
||||
"parent": "sanctuary",
|
||||
"direction": "northwest",
|
||||
"climate": "boreal",
|
||||
"diameter": 64,
|
||||
"center_x": -800,
|
||||
"center_z": -800,
|
||||
"seed": 6470159394492078415,
|
||||
"relief": "natural",
|
||||
"status": "reserved",
|
||||
"generation": 12
|
||||
}
|
||||
],
|
||||
"samples": [
|
||||
{
|
||||
"resolved_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
|
||||
"base_column_sha256": "646af3da3a327ab15d229b7fe2c328cc10eae7fce31d6b24c60f3be643bc1095",
|
||||
"base_height": 242,
|
||||
"stored_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
|
||||
"point": [
|
||||
-16,
|
||||
242,
|
||||
-16
|
||||
],
|
||||
"id": "sanctuary",
|
||||
"stored_column_sha256": "a291039b2ed502c828b4f1ec97fe111f49f62e69b5589e05116d9b1931f461a4"
|
||||
},
|
||||
{
|
||||
"resolved_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
|
||||
"base_column_sha256": "4ec4238d357426e34a1decfd817fc96c6b596758e98ffc1d98906a1db8d5b707",
|
||||
"base_height": 204,
|
||||
"stored_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
|
||||
"point": [
|
||||
-4,
|
||||
171,
|
||||
-1604
|
||||
],
|
||||
"id": "alpha",
|
||||
"stored_column_sha256": "9866d275b53719e434d20996e2c061f4342e431c41d671216781d1f00c204ff1"
|
||||
},
|
||||
{
|
||||
"resolved_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
|
||||
"base_column_sha256": "4df3dd476f41a8c6b0e0273b9ebe5cbba4dec6cb22001c553c15d4f6b255c555",
|
||||
"base_height": 198,
|
||||
"stored_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
|
||||
"point": [
|
||||
2048,
|
||||
198,
|
||||
-1600
|
||||
],
|
||||
"id": "beta",
|
||||
"stored_column_sha256": "246bc9b1827effb58159dbc540c655f2d218079a49263d19ac786cf116557093"
|
||||
}
|
||||
],
|
||||
"world_flushed_before_shutdown": true
|
||||
},
|
||||
"native_structure_starts_seen": 1,
|
||||
"structure_start_chunks_inspected": 106,
|
||||
"ticks": 1470,
|
||||
"elapsed_ms": 41563.165541,
|
||||
"passed": true
|
||||
},
|
||||
"reloaded_world": {
|
||||
"seed": 42,
|
||||
"process_id": 5830,
|
||||
"scope": "A second Java process opens the same saved development chunks and expansion journal, and verifies a gold block written and flushed before the first shutdown. GameTestServer reconstructs its world settings from the preset; the prior generator codec is decoded separately here and its base columns are compared. This is not a normal-client world-selection test or a multiplayer/teleportation test.",
|
||||
"saved_only_collision_refusals": 8,
|
||||
"previous_process_id": 5743,
|
||||
"world_directory": "<development_game_test_world>",
|
||||
"gold_witness": [
|
||||
2048,
|
||||
204,
|
||||
-1600
|
||||
],
|
||||
"samples": [
|
||||
{
|
||||
"stored_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
|
||||
"point": [
|
||||
-16,
|
||||
242,
|
||||
-16
|
||||
],
|
||||
"id": "sanctuary",
|
||||
"stored_column_sha256": "a291039b2ed502c828b4f1ec97fe111f49f62e69b5589e05116d9b1931f461a4",
|
||||
"resolved_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
|
||||
"base_column_sha256": "646af3da3a327ab15d229b7fe2c328cc10eae7fce31d6b24c60f3be643bc1095",
|
||||
"base_height": 242
|
||||
},
|
||||
{
|
||||
"stored_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
|
||||
"point": [
|
||||
-4,
|
||||
171,
|
||||
-1604
|
||||
],
|
||||
"id": "alpha",
|
||||
"stored_column_sha256": "9866d275b53719e434d20996e2c061f4342e431c41d671216781d1f00c204ff1",
|
||||
"resolved_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
|
||||
"base_column_sha256": "4ec4238d357426e34a1decfd817fc96c6b596758e98ffc1d98906a1db8d5b707",
|
||||
"base_height": 204
|
||||
},
|
||||
{
|
||||
"stored_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
|
||||
"point": [
|
||||
2048,
|
||||
198,
|
||||
-1600
|
||||
],
|
||||
"id": "beta",
|
||||
"stored_column_sha256": "246bc9b1827effb58159dbc540c655f2d218079a49263d19ac786cf116557093",
|
||||
"resolved_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
|
||||
"base_column_sha256": "4df3dd476f41a8c6b0e0273b9ebe5cbba4dec6cb22001c553c15d4f6b255c555",
|
||||
"base_height": 198
|
||||
}
|
||||
],
|
||||
"regions": 3,
|
||||
"native_container": [
|
||||
28,
|
||||
247,
|
||||
73
|
||||
],
|
||||
"saved_generator_codec_decoded": true,
|
||||
"after_reload_expansion": [
|
||||
0,
|
||||
1600
|
||||
],
|
||||
"after_reload_landing": [
|
||||
-4,
|
||||
153,
|
||||
1596
|
||||
],
|
||||
"final_regions": 4,
|
||||
"passed": true
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,13 @@ 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.
|
||||
|
||||
## Correctif alpha.12.1 — préparation
|
||||
|
||||
WG-14 corrige la recherche d’emplacements occupés et ajoute la distance minimale
|
||||
aux commandes. Les mondes laboratoire alpha.12 restent compatibles, sans
|
||||
conversion. Le correctif, son MRpack et sa publication sont en validation ;
|
||||
le canal et l’instance installée restent alpha.12 jusqu’à vérification de livraison.
|
||||
|
||||
## Alpha.12 publiée — 9 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.12](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.12)
|
||||
|
||||
@@ -19,6 +19,27 @@ Pour générer de vrais chunks et vérifier le spawn sur d'autres graines :
|
||||
La propriété ne s'applique qu'au mod de test, jamais au mod distribué ni à une
|
||||
sauvegarde de joueur. Chaque commande repart d'un monde de développement neuf.
|
||||
|
||||
## Correctif alpha.12.1 — tous les premiers emplacements occupés
|
||||
|
||||
Le scénario laboratoire utilise les mêmes commandes Gradle ci-dessous. Il
|
||||
prépare maintenant un vrai chunk FULL vide sur chacun des huit premiers sites,
|
||||
constate leur occupation puis ouvre une tropicale plus au nord. Une branche
|
||||
aride demande ensuite une distance explicite de 2 048 blocs depuis cette île.
|
||||
Les anciens chunks vides et le bloc témoin restent inchangés.
|
||||
|
||||
Le second processus vérifie d’abord les huit refus sur disque avec les chunks
|
||||
témoins non chargés, puis ouvre une autre île plus loin au sud et attend du
|
||||
terrain prêt. Les preuves se trouvent dans
|
||||
`diagnostics/expansion-alpha12-1-seed-42.json` et
|
||||
`diagnostics/expansion-alpha12-1-reload-seed-42.json` sous le dossier GameTest.
|
||||
Le rapport exporté est [alpha.12.1](benchmarks/alpha121-expansion.json).
|
||||
|
||||
`expansionPlacementSmoke`, inclus dans `check`, vérifie les huit directions,
|
||||
les quatre tailles, les distances minimales diagonales/cardinales, les bornes,
|
||||
les réservations existantes et l’immutabilité. Les tests de commandes contrôlent
|
||||
les anciennes syntaxes, les retours `preview` à 2 048 blocs et les distances
|
||||
hors limites. Le correctif ne change ni le codec, ni la génération 12, ni le journal v1.
|
||||
|
||||
## Laboratoire alpha.12 — validation locale
|
||||
|
||||
`check build assemblePack` passe sur Java 25 / Minecraft 26.3-pre-2, avec
|
||||
|
||||
+2
-2
@@ -8,6 +8,6 @@ loader_version=0.19.5
|
||||
loom_version=1.17.20
|
||||
fabric_api_version=0.160.0+26.3
|
||||
|
||||
mod_version=0.1.0-alpha.12
|
||||
pack_version=0.1.0-alpha.12
|
||||
mod_version=0.1.0-alpha.12.1
|
||||
pack_version=0.1.0-alpha.12.1
|
||||
maven_group=fr.koka.sanctuary
|
||||
|
||||
@@ -260,3 +260,11 @@ tasks.register('expansionReliefSmoke', JavaExec) {
|
||||
mainClass = 'fr.koka.sanctuary.expansion.ExpansionReliefSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('expansionJournalSmoke', 'expansionReliefSmoke') }
|
||||
|
||||
tasks.register('expansionPlacementSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.expansion.ExpansionPlacementSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('expansionPlacementSmoke') }
|
||||
|
||||
+55
@@ -5,9 +5,12 @@ import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import fr.koka.sanctuary.expansion.ExpansionIsland;
|
||||
import fr.koka.sanctuary.expansion.ExpansionRuntime;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import net.minecraft.commands.CommandSource;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||
import net.minecraft.server.level.ChunkLevel;
|
||||
import net.minecraft.server.permissions.LevelBasedPermissionSet;
|
||||
import net.minecraft.server.permissions.PermissionLevel;
|
||||
@@ -56,6 +59,58 @@ public final class ExpansionCommandChecks {
|
||||
helper.assertTrue(dispatcher.execute(preview, operatorTwo) == 1, "OP2 must execute the same read-only preview successfully");
|
||||
helper.assertTrue(session.islands().equals(before), "Preview must not reserve or activate an island");
|
||||
|
||||
var feedback = new ArrayList<Component>();
|
||||
var previewSource = server.createCommandSourceStack().withLevel(level).withPermission(LevelBasedPermissionSet.GAMEMASTER)
|
||||
.withSource(new CommandSource() {
|
||||
@Override public void sendSystemMessage(Component message) { feedback.add(message); }
|
||||
@Override public boolean acceptsSuccess() { return true; }
|
||||
@Override public boolean acceptsFailure() { return true; }
|
||||
@Override public boolean shouldInformAdmins() { return false; }
|
||||
});
|
||||
var parent = session.island("sanctuary");
|
||||
for (String direction : new String[] {"north", "northeast"}) {
|
||||
feedback.clear();
|
||||
helper.assertTrue(dispatcher.execute("sanctuary expansion preview distance_preview sanctuary " + direction
|
||||
+ " boreal 64 natural 2048", previewSource) == 1, "The optional distance must execute for " + direction);
|
||||
helper.assertTrue(feedback.size() == 1 && feedback.getFirst().getContents() instanceof TranslatableContents,
|
||||
"A successful distance preview must return its proposed coordinates");
|
||||
var result = (TranslatableContents) feedback.getFirst().getContents();
|
||||
helper.assertTrue(result.getKey().equals("sanctuary.expansion.preview"), "Distance preview must return the production preview response");
|
||||
var args = result.getArgs();
|
||||
int x = ((Number) args[1]).intValue(), z = ((Number) args[2]).intValue();
|
||||
int dx = x - parent.centerX(), dz = z - parent.centerZ();
|
||||
helper.assertTrue(Math.hypot(dx, dz) >= 2048,
|
||||
"The command distance must be a minimum centre-to-centre distance, including diagonals");
|
||||
helper.assertTrue(dz < 0 && (direction.equals("north") ? dx == 0 : dx == -dz),
|
||||
"The distance preview must preserve the exact requested direction");
|
||||
helper.assertTrue(session.islands().equals(before), "Distance preview must not reserve or activate an island");
|
||||
}
|
||||
for (String command : new String[] {
|
||||
"sanctuary expansion quick north",
|
||||
"sanctuary expansion quick north boreal 64 natural",
|
||||
"sanctuary expansion quick north boreal 64 natural 2048",
|
||||
"sanctuary expansion create distance_create sanctuary north boreal 64 natural",
|
||||
"sanctuary expansion create distance_create sanctuary north boreal 64 natural 2048"}) {
|
||||
var syntax = dispatcher.parse(command, operatorTwo);
|
||||
helper.assertTrue(!syntax.getReader().canRead() && syntax.getExceptions().isEmpty() && syntax.getContext().getCommand() != null,
|
||||
"Old and distance-aware create/quick syntax must remain executable: " + command);
|
||||
}
|
||||
helper.assertTrue(dispatcher.execute(preview + " 0", operatorTwo) == 1,
|
||||
"An explicit zero distance must preserve automatic positioning");
|
||||
|
||||
for (String distance : new String[] {"-1", "100001"}) {
|
||||
for (String command : new String[] {
|
||||
preview,
|
||||
"sanctuary expansion create distance_invalid sanctuary north boreal 64 natural",
|
||||
"sanctuary expansion quick north boreal 64 natural"}) {
|
||||
boolean distanceRejected = false;
|
||||
try { dispatcher.execute(command + " " + distance, operatorTwo); }
|
||||
catch (CommandSyntaxException expected) { distanceRejected = true; }
|
||||
helper.assertTrue(distanceRejected && session.islands().equals(before),
|
||||
"All expansion commands must reject an out-of-range distance without mutation");
|
||||
}
|
||||
}
|
||||
|
||||
// 65 is accepted by Brigadier's numeric bounds, then rejected by the production size contract.
|
||||
int invalid = dispatcher.execute("sanctuary expansion create command_invalid sanctuary east boreal 65 natural", operatorTwo);
|
||||
helper.assertTrue(invalid == 0, "An unsupported nominal diameter must fail in the production command handler");
|
||||
|
||||
+77
-24
@@ -6,6 +6,7 @@ import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.expansion.ExpansionAdmission;
|
||||
import fr.koka.sanctuary.expansion.ExpansionBiomeSource;
|
||||
import fr.koka.sanctuary.expansion.ExpansionChunkGenerator;
|
||||
import fr.koka.sanctuary.expansion.ExpansionIsland;
|
||||
@@ -74,6 +75,19 @@ public final class ExpansionWorldGameTests {
|
||||
"The reload proof must run in another Java process");
|
||||
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed(), "Reload must keep the original world seed");
|
||||
JsonObject saved = previous.getAsJsonObject("restart_snapshot");
|
||||
List<ExpansionIsland> blockers = ExpansionIsland.CODEC.listOf().parse(JsonOps.INSTANCE, saved.get("blocked_sites")).getOrThrow();
|
||||
int savedRefusals = 0;
|
||||
for (var site : blockers) {
|
||||
helper.assertTrue(level.getChunkSource().chunkMap.getUpdatingChunkIfPresent(new ChunkPos(site.centerX() >> 4, site.centerZ() >> 4).pack()) == null,
|
||||
"Reload collision proof must start with an unloaded saved witness");
|
||||
try { ExpansionAdmission.verify(level, site); }
|
||||
catch (ExpansionAdmission.OccupiedException expected) {
|
||||
helper.assertTrue(expected.getMessage().contains("saved chunk"), "The reload proof must detect disk occupancy, not just a loaded holder");
|
||||
savedRefusals++;
|
||||
}
|
||||
}
|
||||
helper.assertTrue(savedRefusals == 8, "All eight first sites must remain unavailable on disk after restart");
|
||||
report.put("saved_only_collision_refusals", savedRefusals);
|
||||
helper.assertTrue(saved.get("world_directory").getAsString().equals(worldDirectory(level)),
|
||||
"Reload must use exactly the same development save directory");
|
||||
helper.assertTrue(session.islands().size() == 3 && session.islands().stream().allMatch(i -> i.status().equals("ready")),
|
||||
@@ -123,9 +137,12 @@ public final class ExpansionWorldGameTests {
|
||||
report.put("regions", session.islands().size());
|
||||
report.put("native_container", coordinates(containerPos));
|
||||
report.put("saved_generator_codec_decoded", true);
|
||||
report.put("passed", true);
|
||||
writeDiagnostic(level, true, report);
|
||||
helper.succeed();
|
||||
var firstCandidate = session.candidate("after_reload", "sanctuary", "south", "boreal", 64, "natural");
|
||||
var opened = session.create("after_reload", "sanctuary", "south", "boreal", 64, "natural");
|
||||
helper.assertTrue(opened.centerZ() > firstCandidate.centerZ() && opened.centerX() == 0,
|
||||
"Creation must skip the saved occupied first site and preserve the requested southern ray");
|
||||
report.put("after_reload_expansion", List.of(opened.centerX(), opened.centerZ()));
|
||||
awaitReloadExpansion(helper, session, blockers, witness, report);
|
||||
} catch (Exception exception) {
|
||||
report.put("passed", false);
|
||||
report.put("failure", exception.toString());
|
||||
@@ -135,6 +152,41 @@ public final class ExpansionWorldGameTests {
|
||||
}
|
||||
}
|
||||
|
||||
private static void awaitReloadExpansion(GameTestHelper helper, ExpansionRuntime.Session session,
|
||||
List<ExpansionIsland> blockers, BlockPos gold, Map<String, Object> report) {
|
||||
try {
|
||||
helper.assertTrue(helper.getTick() < 5000, "Reload expansion preparation deadline: " + session.progress());
|
||||
if (!session.island("after_reload").status().equals("ready")) {
|
||||
helper.runAtTickTime(helper.getTick() + 10, () -> awaitReloadExpansion(helper, session, blockers, gold, report));
|
||||
return;
|
||||
}
|
||||
verifyUntouchedVoid(helper, blockers);
|
||||
helper.assertTrue(helper.getLevel().getBlockState(gold).is(Blocks.GOLD_BLOCK), "Opening farther away must preserve the saved player witness");
|
||||
var landing = session.landing("after_reload");
|
||||
helper.assertTrue(ExpansionRuntime.safeLanding(helper.getLevel(), landing), "The new region after restart must reach usable terrain");
|
||||
report.put("after_reload_landing", coordinates(landing));
|
||||
report.put("final_regions", session.islands().size());
|
||||
report.put("passed", true);
|
||||
writeDiagnostic(helper.getLevel(), true, report);
|
||||
helper.succeed();
|
||||
} catch (Exception exception) {
|
||||
report.put("passed", false);
|
||||
report.put("failure", exception.toString());
|
||||
try { writeDiagnostic(helper.getLevel(), true, report); } catch (IOException failure) { exception.addSuppressed(failure); }
|
||||
helper.fail(exception.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static void verifyUntouchedVoid(GameTestHelper helper, List<ExpansionIsland> blockers) {
|
||||
var generator = (ExpansionChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
for (var site : blockers) {
|
||||
var chunk = full(helper, helper.getLevel(), site.centerX() >> 4, site.centerZ() >> 4);
|
||||
helper.assertTrue(generator.islandAt(site.centerX(), site.centerZ()) == null
|
||||
&& Arrays.stream(chunk.getSections()).allMatch(section -> section.hasOnlyAir()),
|
||||
"An occupied saved void chunk must remain unchanged after farther placement: " + site.direction());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Scenario {
|
||||
private final GameTestHelper helper;
|
||||
private final ServerLevel level;
|
||||
@@ -152,6 +204,7 @@ public final class ExpansionWorldGameTests {
|
||||
private int phase;
|
||||
private int structureIndex;
|
||||
private int validStarts;
|
||||
private final List<ExpansionIsland> blockedSites = new ArrayList<>();
|
||||
|
||||
Scenario(GameTestHelper helper) {
|
||||
this.helper = helper;
|
||||
@@ -177,9 +230,10 @@ public final class ExpansionWorldGameTests {
|
||||
helper.assertTrue(session.island("alpha").equals(alpha.ready()), "Activation must only advance the immutable island state");
|
||||
observeIsland("alpha", Set.of("jungle", "sparse_jungle", "bamboo_jungle", "mangrove_swamp"));
|
||||
verifyMarker();
|
||||
beta = session.create("beta", "alpha", "east", "arid", 64, "natural");
|
||||
beta = session.create("beta", "alpha", "east", "arid", 64, "natural", 2048);
|
||||
helper.assertTrue(beta.parent().equals("alpha") && beta.centerX() > alpha.centerX()
|
||||
&& beta.centerZ() == alpha.centerZ(), "A child must branch east from alpha, not from world origin");
|
||||
helper.assertTrue(beta.centerX() - alpha.centerX() >= 2048, "The requested minimum distance must be honoured from the chosen parent");
|
||||
verifyJournal();
|
||||
phase = 2;
|
||||
}
|
||||
@@ -189,7 +243,7 @@ public final class ExpansionWorldGameTests {
|
||||
observeIsland("beta", Set.of("desert", "badlands", "eroded_badlands", "wooded_badlands"));
|
||||
verifyMarker();
|
||||
verifyJournal();
|
||||
rejectVisitedVoid();
|
||||
verifyUntouchedVoid(helper, blockedSites);
|
||||
prepareStructureSearch();
|
||||
phase = 3;
|
||||
}
|
||||
@@ -242,9 +296,25 @@ public final class ExpansionWorldGameTests {
|
||||
report.put("root_landing", coordinates(landing));
|
||||
samplePoints.put("sanctuary", landing);
|
||||
|
||||
for (String direction : ExpansionIsland.DIRECTIONS) {
|
||||
var site = session.candidate("blocked_" + direction, "sanctuary", direction, "boreal", 64, "natural");
|
||||
var chunk = full(site.centerX() >> 4, site.centerZ() >> 4);
|
||||
helper.assertTrue(Arrays.stream(chunk.getSections()).allMatch(section -> section.hasOnlyAir()), "The first site must initially contain only real generated void");
|
||||
blockedSites.add(site);
|
||||
}
|
||||
int occupied = 0;
|
||||
for (var site : blockedSites) {
|
||||
try { ExpansionAdmission.verify(level, site); }
|
||||
catch (ExpansionAdmission.OccupiedException expected) { occupied++; }
|
||||
}
|
||||
helper.assertTrue(occupied == 8, "Reproduce the user failure with all eight first directions occupied");
|
||||
report.put("initial_occupied_directions", occupied);
|
||||
var nearestNorth = session.candidate("alpha", "sanctuary", "north", "tropical", 64, "natural");
|
||||
|
||||
alpha = session.create("alpha", "sanctuary", "north", "tropical", 64, "natural");
|
||||
helper.assertTrue(alpha.status().equals("reserved") && alpha.centerZ() < 0 && alpha.centerX() == 0,
|
||||
"North placement and the explicitly chosen tropical climate must remain independent");
|
||||
helper.assertTrue(alpha.centerZ() < nearestNorth.centerZ(), "Automatic placement must skip occupied chunks and continue farther north");
|
||||
helper.assertTrue(alpha.equals(session.create("alpha", "sanctuary", "north", "tropical", 64, "natural"))
|
||||
&& session.islands().size() == 2, "Repeating a matching request must not create another region or change its identity");
|
||||
boolean conflict = false;
|
||||
@@ -295,6 +365,7 @@ public final class ExpansionWorldGameTests {
|
||||
snapshot.put("journal_sha256", sha256(Files.readString(journalFile(level))));
|
||||
snapshot.put("islands", ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow());
|
||||
snapshot.put("generator_codec", ChunkGenerator.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow());
|
||||
snapshot.put("blocked_sites", ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, blockedSites).getOrThrow());
|
||||
List<Map<String, Object>> samples = new ArrayList<>();
|
||||
for (var island : session.islands()) samples.add(sample(helper, generator, island, samplePoints.get(island.id())));
|
||||
snapshot.put("samples", samples);
|
||||
@@ -303,24 +374,6 @@ public final class ExpansionWorldGameTests {
|
||||
report.put("restart_snapshot", snapshot);
|
||||
}
|
||||
|
||||
private void rejectVisitedVoid() throws IOException {
|
||||
ExpansionIsland target = session.candidate("visited", "sanctuary", "southwest", "boreal", 64, "natural");
|
||||
helper.assertTrue(generator.islandAt(target.centerX(), target.centerZ()) == null, "Admission witness must initially be outside every island");
|
||||
LevelChunk visited = full(target.centerX() >> 4, target.centerZ() >> 4);
|
||||
helper.assertTrue(Arrays.stream(visited.getSections()).allMatch(section -> section.hasOnlyAir()),
|
||||
"The visited-region witness must be a real empty FULL chunk, not an occupied island");
|
||||
int before = session.islands().size();
|
||||
boolean refused = false;
|
||||
String reason = "";
|
||||
try { session.create("visited", "sanctuary", "southwest", "boreal", 64, "natural"); }
|
||||
catch (IOException expected) { refused = true; reason = expected.getMessage(); }
|
||||
helper.assertTrue(refused && session.islands().size() == before,
|
||||
"A visited empty chunk must prevent admission without publishing or changing a region");
|
||||
helper.assertTrue(generator.islandAt(target.centerX(), target.centerZ()) == null
|
||||
&& Arrays.stream(visited.getSections()).allMatch(section -> section.hasOnlyAir()), "Refused activation must leave the visited void unchanged");
|
||||
report.put("visited_void_refusal", Map.of("chunk", List.of(visited.getPos().x(), visited.getPos().z()), "reason", reason));
|
||||
}
|
||||
|
||||
private void prepareStructureSearch() {
|
||||
ExpansionIsland root = session.island("sanctuary");
|
||||
int limit = (root.terrainRadius() + 15) / 16;
|
||||
@@ -422,7 +475,7 @@ public final class ExpansionWorldGameTests {
|
||||
}
|
||||
private static String worldDirectory(ServerLevel level) { return level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize().toString(); }
|
||||
private static Path diagnostic(ServerLevel level, boolean reload) {
|
||||
return Path.of("diagnostics", "expansion-alpha12-" + (reload ? "reload-" : "") + "seed-" + level.getSeed() + ".json");
|
||||
return Path.of("diagnostics", "expansion-alpha12-1-" + (reload ? "reload-" : "") + "seed-" + level.getSeed() + ".json");
|
||||
}
|
||||
private static void writeDiagnostic(ServerLevel level, boolean reload, Map<String, Object> report) throws IOException {
|
||||
Path output = diagnostic(level, reload);
|
||||
|
||||
@@ -23,6 +23,57 @@ public final class ExpansionAdmission {
|
||||
private ExpansionAdmission() { }
|
||||
|
||||
public static void verify(ServerLevel level, ExpansionIsland island) throws IOException {
|
||||
verify(level, island, searchDeadline());
|
||||
}
|
||||
|
||||
/** A caller searching several positions must share this deadline across all admission attempts. */
|
||||
public static long searchDeadline() {
|
||||
return System.nanoTime() + DEADLINE_NANOS;
|
||||
}
|
||||
|
||||
public static void verify(ServerLevel level, ExpansionIsland island, long deadlineNanos) throws IOException {
|
||||
checkDeadline(deadlineNanos);
|
||||
List<ChunkPos> positions = positions(level, island);
|
||||
ChunkMap map = level.getChunkSource().chunkMap;
|
||||
verifyHolders(map, positions, deadlineNanos);
|
||||
for (int start = 0; start < positions.size(); start += READ_BATCH) {
|
||||
checkDeadline(deadlineNanos);
|
||||
int end = Math.min(positions.size(), start + READ_BATCH);
|
||||
List<CompletableFuture<Optional<CompoundTag>>> reads = new ArrayList<>();
|
||||
for (int i = start; i < end; i++) reads.add(map.read(positions.get(i)));
|
||||
ChunkPos occupied = null;
|
||||
try {
|
||||
for (int i = 0; i < reads.size(); i++) {
|
||||
long remaining = deadlineNanos - System.nanoTime();
|
||||
if (remaining <= 0) throw new TimeoutException("Admission deadline expired");
|
||||
if (reads.get(i).get(remaining, TimeUnit.NANOSECONDS).isPresent()) {
|
||||
occupied = positions.get(start + i);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Expansion admission interrupted", e);
|
||||
} catch (TimeoutException e) {
|
||||
throw new IOException("search_timeout", e);
|
||||
} catch (ExecutionException e) {
|
||||
throw new IOException("Expansion could not prove that every reserved chunk is absent", e);
|
||||
}
|
||||
// Drain every submitted read before skipping this occupied region, so a failed read
|
||||
// is never swallowed as an ordinary collision and no next attempt overlaps the batch.
|
||||
checkDeadline(deadlineNanos);
|
||||
if (occupied != null) throw new OccupiedException("Expansion intersects an existing saved chunk: " + occupied);
|
||||
}
|
||||
verifyHolders(map, positions, deadlineNanos);
|
||||
// Caller must commit and publish on this same server turn, without yielding or processing ticks.
|
||||
}
|
||||
|
||||
/** Recheck active requests immediately before commit, after read-only terrain prospecting. */
|
||||
public static void verifyHolders(ServerLevel level, ExpansionIsland island, long deadlineNanos) throws IOException {
|
||||
checkDeadline(deadlineNanos);
|
||||
verifyHolders(level.getChunkSource().chunkMap, positions(level, island), deadlineNanos);
|
||||
}
|
||||
|
||||
private static List<ChunkPos> positions(ServerLevel level, ExpansionIsland island) throws IOException {
|
||||
if (!level.getServer().isSameThread()) throw new IOException("Expansion admission must run on the server thread");
|
||||
if (island.origin()) throw new IOException("The existing Sanctuary region cannot be admitted again");
|
||||
int radius = island.reservedRadius();
|
||||
@@ -33,35 +84,31 @@ public final class ExpansionAdmission {
|
||||
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);
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
private static void verifyHolders(ChunkMap map, List<ChunkPos> positions, long deadlineNanos) throws IOException {
|
||||
for (ChunkPos position : positions) {
|
||||
checkDeadline(deadlineNanos);
|
||||
rejectHolder(map, position);
|
||||
}
|
||||
for (ChunkPos position : positions) rejectHolder(map, position);
|
||||
// Caller must commit and publish on this same server turn, without yielding or processing ticks.
|
||||
}
|
||||
|
||||
public static void checkDeadline(long deadlineNanos) throws IOException {
|
||||
if (deadlineNanos - System.nanoTime() <= 0) throw new IOException("search_timeout");
|
||||
}
|
||||
|
||||
private static void rejectHolder(ChunkMap map, ChunkPos position) throws IOException {
|
||||
long key = position.pack();
|
||||
if (map.getUpdatingChunkIfPresent(key) != null || ((ExpansionChunkMapAccessor) map).sanctuary$pendingUnloads().containsKey(key)) {
|
||||
throw new IOException("Expansion intersects a loaded or unloading chunk: " + position);
|
||||
throw new OccupiedException("Expansion intersects a loaded or unloading chunk: " + position);
|
||||
}
|
||||
}
|
||||
|
||||
/** Only proven occupancy may move the search farther along the requested direction. */
|
||||
public static final class OccupiedException extends IOException {
|
||||
public OccupiedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,9 @@ public final class ExpansionCommands {
|
||||
.executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"), "natural"))
|
||||
.then(Commands.argument("relief", StringArgumentType.word())
|
||||
.suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.RELIEFS, b))
|
||||
.executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief"))))))));
|
||||
.executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief")))
|
||||
.then(distanceArgument().executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"),
|
||||
word(c, "relief"), IntegerArgumentType.getInteger(c, "distance")))))))));
|
||||
dispatcher.register(Commands.literal("sanctuary").requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)).then(expansion));
|
||||
});
|
||||
}
|
||||
@@ -79,17 +81,28 @@ public final class ExpansionCommands {
|
||||
.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;
|
||||
}))))))));
|
||||
.executes(c -> create(c, preview, 0))
|
||||
.then(distanceArgument().executes(c -> create(c, preview, IntegerArgumentType.getInteger(c, "distance"))))))))));
|
||||
}
|
||||
private static com.mojang.brigadier.builder.RequiredArgumentBuilder<CommandSourceStack, Integer> distanceArgument() {
|
||||
return Commands.argument("distance", IntegerArgumentType.integer(0, 100_000))
|
||||
.suggests((c, b) -> SharedSuggestionProvider.suggest(List.of("1024", "2048", "4096"), b));
|
||||
}
|
||||
private static int create(CommandContext<CommandSourceStack> c, boolean preview, int minimumDistance) {
|
||||
return run(c, () -> {
|
||||
var session = ExpansionRuntime.session(c.getSource().getLevel());
|
||||
var island = preview ? session.candidate(word(c, "id"), word(c, "parent"), word(c, "direction"), word(c, "climate"),
|
||||
IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief"), minimumDistance)
|
||||
: session.create(word(c, "id"), word(c, "parent"), word(c, "direction"), word(c, "climate"),
|
||||
IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief"), minimumDistance);
|
||||
describe(c, island, preview);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
});
|
||||
}
|
||||
private static int quick(CommandContext<CommandSourceStack> c, String climate, int diameter, String relief) {
|
||||
return quick(c, climate, diameter, relief, 0);
|
||||
}
|
||||
private static int quick(CommandContext<CommandSourceStack> c, String climate, int diameter, String relief, int minimumDistance) {
|
||||
return run(c, () -> {
|
||||
var session = ExpansionRuntime.session(c.getSource().getLevel());
|
||||
int next = session.islands().size();
|
||||
@@ -101,7 +114,7 @@ public final class ExpansionCommands {
|
||||
String direction = word(c, "direction");
|
||||
if (!ExpansionIsland.DIRECTIONS.contains(direction)) throw new IllegalArgumentException("invalid_direction");
|
||||
var island = session.create("island_" + next, "sanctuary", direction,
|
||||
climate == null ? ExpansionIsland.directionalClimate(direction) : climate, diameter, relief);
|
||||
climate == null ? ExpansionIsland.directionalClimate(direction) : climate, diameter, relief, minimumDistance);
|
||||
describe(c, island, false);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Pure placement along a chosen ray; no chunk is inspected or generated here. */
|
||||
public final class ExpansionPlacement {
|
||||
public static final int MAX_ATTEMPTS = 32;
|
||||
public static final int MAX_DISTANCE = 100_000;
|
||||
private static final int[] DX = {0, 1, 1, 1, 0, -1, -1, -1};
|
||||
private static final int[] DZ = {-1, -1, 0, 1, 1, 1, 0, -1};
|
||||
|
||||
private ExpansionPlacement() { }
|
||||
|
||||
public static List<ExpansionIsland> candidates(long worldSeed, List<ExpansionIsland> islands,
|
||||
String id, String parentId, String direction, String climate, int diameter, String relief,
|
||||
int minimumDistance) {
|
||||
if (id == null || parentId == null || !id.matches("[a-z][a-z0-9_]{0,31}") || !parentId.matches("[a-z][a-z0-9_]{0,31}"))
|
||||
throw new IllegalArgumentException("invalid_id");
|
||||
if (id.equals("sanctuary")) throw new IllegalArgumentException("reserved_id");
|
||||
if (direction == null || !ExpansionIsland.DIRECTIONS.contains(direction)) throw new IllegalArgumentException("invalid_direction");
|
||||
if (climate == null || !ExpansionIsland.CLIMATES.contains(climate)) throw new IllegalArgumentException("invalid_climate");
|
||||
if (!ExpansionIsland.DIAMETERS.contains(diameter)) throw new IllegalArgumentException("invalid_diameter");
|
||||
if (relief == null || !ExpansionIsland.RELIEFS.contains(relief)) throw new IllegalArgumentException("invalid_relief");
|
||||
validateDistance(minimumDistance);
|
||||
var parent = islands.stream().filter(i -> i.id().equals(parentId)).findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("unknown_island"));
|
||||
if (!parent.status().equals("ready")) throw new IllegalArgumentException("parent_not_ready");
|
||||
int ordinal = ExpansionIsland.DIRECTIONS.indexOf(direction);
|
||||
var sample = new ExpansionIsland(id, parentId, direction, climate, diameter, 0, 0,
|
||||
ExpansionIsland.deriveSeed(worldSeed, id), relief, "reserved", ExpansionIsland.GENERATION);
|
||||
int step = ((parent.reservedRadius() + sample.reservedRadius() + 63) / 16) * 16;
|
||||
boolean diagonal = DX[ordinal] != 0 && DZ[ordinal] != 0;
|
||||
int requestedComponent = (int) Math.ceil(minimumDistance / (diagonal ? Math.sqrt(2) : 1.0) / 16) * 16;
|
||||
int start = Math.max(step, requestedComponent);
|
||||
List<ExpansionIsland> candidates = new ArrayList<>();
|
||||
for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
long component = (long) start + (long) step * attempt;
|
||||
long x = parent.centerX() + DX[ordinal] * component;
|
||||
long z = parent.centerZ() + DZ[ordinal] * component;
|
||||
if (Math.abs(x) > MAX_DISTANCE || Math.abs(z) > MAX_DISTANCE) break;
|
||||
var candidate = new ExpansionIsland(id, parentId, direction, climate, diameter, (int) x, (int) z,
|
||||
sample.seed(), relief, "reserved", ExpansionIsland.GENERATION);
|
||||
if (islands.stream().noneMatch(candidate::conflicts)) candidates.add(candidate);
|
||||
}
|
||||
return List.copyOf(candidates);
|
||||
}
|
||||
|
||||
public static void validateDistance(int minimumDistance) {
|
||||
if (minimumDistance < 0 || minimumDistance > MAX_DISTANCE) throw new IllegalArgumentException("invalid_distance");
|
||||
}
|
||||
|
||||
public static double distanceFromParent(ExpansionIsland island, ExpansionIsland parent) {
|
||||
return Math.hypot((double) island.centerX() - parent.centerX(), (double) island.centerZ() - parent.centerZ());
|
||||
}
|
||||
}
|
||||
@@ -80,57 +80,54 @@ public final class ExpansionRuntime {
|
||||
return job == null ? (failure.isEmpty() ? "idle" : failure) : job.island.id() + ": " + job.index + "/" + job.chunks.size();
|
||||
}
|
||||
public ExpansionIsland candidate(String id, String parentId, String direction, String climate, int diameter, String relief) {
|
||||
if (id == null || parentId == null || !id.matches("[a-z][a-z0-9_]{0,31}") || !parentId.matches("[a-z][a-z0-9_]{0,31}"))
|
||||
throw new IllegalArgumentException("invalid_id");
|
||||
if (id.equals("sanctuary")) throw new IllegalArgumentException("reserved_id");
|
||||
if (direction == null || !ExpansionIsland.DIRECTIONS.contains(direction)) throw new IllegalArgumentException("invalid_direction");
|
||||
if (climate == null || !ExpansionIsland.CLIMATES.contains(climate)) throw new IllegalArgumentException("invalid_climate");
|
||||
if (!ExpansionIsland.DIAMETERS.contains(diameter)) throw new IllegalArgumentException("invalid_diameter");
|
||||
if (relief == null || !ExpansionIsland.RELIEFS.contains(relief)) throw new IllegalArgumentException("invalid_relief");
|
||||
var parent = island(parentId);
|
||||
if (!parent.status().equals("ready")) throw new IllegalArgumentException("parent_not_ready");
|
||||
int ordinal = ExpansionIsland.DIRECTIONS.indexOf(direction);
|
||||
int[] dx = {0, 1, 1, 1, 0, -1, -1, -1};
|
||||
int[] dz = {-1, -1, 0, 1, 1, 1, 0, -1};
|
||||
var sample = new ExpansionIsland(id, parentId, direction, climate, diameter, 0, 0,
|
||||
ExpansionIsland.deriveSeed(level.getSeed(), id), relief, "reserved", ExpansionIsland.GENERATION);
|
||||
int step = ((parent.reservedRadius() + sample.reservedRadius() + 63) / 16) * 16;
|
||||
// Eight short deterministic searches along the requested ray, without generating void.
|
||||
for (int ring = 1; ring <= 8; ring++) {
|
||||
int x = Math.addExact(parent.centerX(), dx[ordinal] * step * ring);
|
||||
int z = Math.addExact(parent.centerZ(), dz[ordinal] * step * ring);
|
||||
if (Math.abs((long) x) > 100_000 || Math.abs((long) z) > 100_000) break;
|
||||
var candidate = new ExpansionIsland(id, parentId, direction, climate, diameter, x, z,
|
||||
sample.seed(), relief, "reserved", ExpansionIsland.GENERATION);
|
||||
if (islands().stream().noneMatch(candidate::conflicts)) return candidate;
|
||||
}
|
||||
throw new IllegalArgumentException("no_space");
|
||||
return candidate(id, parentId, direction, climate, diameter, relief, 0);
|
||||
}
|
||||
public ExpansionIsland candidate(String id, String parentId, String direction, String climate, int diameter, String relief,
|
||||
int minimumDistance) {
|
||||
return ExpansionPlacement.candidates(level.getSeed(), islands(), id, parentId, direction, climate, diameter, relief, minimumDistance)
|
||||
.stream().findFirst().orElseThrow(() -> new IllegalArgumentException("no_free_space"));
|
||||
}
|
||||
public ExpansionIsland create(String id, String parent, String direction, String climate, int diameter, String relief) throws IOException {
|
||||
return create(id, parent, direction, climate, diameter, relief, 0);
|
||||
}
|
||||
public ExpansionIsland create(String id, String parent, String direction, String climate, int diameter, String relief,
|
||||
int minimumDistance) throws IOException {
|
||||
long deadline = ExpansionAdmission.searchDeadline();
|
||||
requireHealthyJournal();
|
||||
ExpansionPlacement.validateDistance(minimumDistance);
|
||||
var existing = islands().stream().filter(i -> i.id().equals(id)).findFirst();
|
||||
if (existing.isPresent()) {
|
||||
var value = existing.get();
|
||||
if (!value.parent().equals(parent) || !value.direction().equals(direction) || !value.climate().equals(climate)
|
||||
|| value.diameter() != diameter || !value.relief().equals(relief)) throw new IllegalArgumentException("id_conflict");
|
||||
|| value.diameter() != diameter || !value.relief().equals(relief)
|
||||
|| (minimumDistance > 0 && ExpansionPlacement.distanceFromParent(value, island(parent)) < minimumDistance))
|
||||
throw new IllegalArgumentException("id_conflict");
|
||||
return value;
|
||||
}
|
||||
if (islands().size() >= 64) throw new IllegalArgumentException("island_limit");
|
||||
var value = candidate(id, parent, direction, climate, diameter, relief);
|
||||
// Explicit refusal if a proposed location was already explored, even in the void.
|
||||
ExpansionAdmission.verify(level, value);
|
||||
try {
|
||||
if (probe(value) == null) throw new IllegalArgumentException("no_ground");
|
||||
var next = new ArrayList<>(islands());
|
||||
next.add(value);
|
||||
commitJournal(next); // Durability and re-read come before worldgen sees the island.
|
||||
generator.setIslands(journal.islands());
|
||||
failure = "";
|
||||
enqueue(value);
|
||||
return value;
|
||||
} finally {
|
||||
generator.discardUnpublishedContext(value);
|
||||
var candidates = ExpansionPlacement.candidates(level.getSeed(), islands(), id, parent, direction, climate, diameter, relief, minimumDistance);
|
||||
for (var value : candidates) {
|
||||
ExpansionAdmission.checkDeadline(deadline);
|
||||
try {
|
||||
// Existing void is preserved: only proven occupation advances along the same ray.
|
||||
ExpansionAdmission.verify(level, value, deadline);
|
||||
if (probe(value) == null) throw new IllegalArgumentException("no_ground");
|
||||
var next = new ArrayList<>(islands());
|
||||
next.add(value);
|
||||
ExpansionAdmission.verifyHolders(level, value, deadline);
|
||||
commitJournal(next); // Durability and re-read come before worldgen sees the island.
|
||||
generator.setIslands(journal.islands());
|
||||
failure = "";
|
||||
enqueue(value);
|
||||
return value;
|
||||
} catch (ExpansionAdmission.OccupiedException occupied) {
|
||||
// IO failures, timeouts and unsuitable terrain stop this request without a new draw.
|
||||
} finally {
|
||||
generator.discardUnpublishedContext(value);
|
||||
}
|
||||
}
|
||||
ExpansionAdmission.checkDeadline(deadline);
|
||||
throw new IllegalArgumentException("no_free_space");
|
||||
}
|
||||
public void resume(String id) {
|
||||
requireHealthyJournal();
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"biome.sanctuary.population_dripstone_caves": "Sanctuary Dripstone Caves",
|
||||
"generator.sanctuary.sanctuary_20": "Sanctuary (20 players — legacy size)",
|
||||
"generator.sanctuary.expansion_lab": "Sanctuary — Laboratory",
|
||||
"sanctuary.expansion.help": "Alpha.12 laboratory — /sanctuary expansion quick <direction> [climate] [diameter] [relief]; create or preview <id> <parent> <direction> <climate> <diameter> <relief>; list; visit <id>; resume <id>. Diameters: 64, 128, 256, 512.",
|
||||
"sanctuary.expansion.help": "Laboratory — /sanctuary expansion quick <direction> [climate] [diameter] [relief] [distance]; create or preview <id> <parent> <direction> <climate> <diameter> <relief> [distance]; list; visit <id>; resume <id>. Diameters: 64, 128, 256, 512. Minimum centre-to-centre distance in blocks (0 = automatic, maximum 100000). Fill in preceding arguments to specify distance. Example: quick north autumn 128 peaks 2048. Creation searches farther if needed, up to 32 locations and 30 seconds.",
|
||||
"sanctuary.expansion.entry": "%s ← %s · %s · %s · Ø%s · %s · X%s Z%s · %s",
|
||||
"sanctuary.expansion.progress": "Preparation: %s",
|
||||
"sanctuary.expansion.state.ready": "Ready",
|
||||
@@ -75,7 +75,7 @@
|
||||
"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.occupied": "This region already contains chunks. Increase the distance to search farther away.",
|
||||
"sanctuary.expansion.error.teleport_failed": "Teleportation failed.",
|
||||
"sanctuary.expansion.climate.temperate": "Temperate",
|
||||
"sanctuary.expansion.climate.boreal": "Boreal",
|
||||
@@ -106,5 +106,8 @@
|
||||
"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."
|
||||
"sanctuary.expansion.error.invalid_relief": "Unknown relief: natural, peaks, canyon or volcano.",
|
||||
"sanctuary.expansion.error.invalid_distance": "Distance must be between 0 and 100000 blocks. 0 uses automatic placement.",
|
||||
"sanctuary.expansion.error.no_free_space": "No free location found within the search limits. Try a greater distance or a different parent. No existing terrain was changed.",
|
||||
"sanctuary.expansion.error.search_timeout": "The search reached its 30-second limit. Try a greater distance. No existing terrain was changed."
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"biome.sanctuary.population_dripstone_caves": "Grottes de spéléothèmes de Sanctuary",
|
||||
"generator.sanctuary.sanctuary_20": "Sanctuary (20 joueurs — ancien format)",
|
||||
"generator.sanctuary.expansion_lab": "Sanctuary — Laboratoire",
|
||||
"sanctuary.expansion.help": "Laboratoire alpha.12 — /sanctuary expansion quick <direction> [climat] [diamètre] [relief] ; create ou preview <id> <parent> <direction> <climat> <diamètre> <relief> ; list ; visit <id> ; resume <id>. Diamètres : 64, 128, 256, 512.",
|
||||
"sanctuary.expansion.help": "Laboratoire — /sanctuary expansion quick <direction> [climat] [diamètre] [relief] [distance] ; create ou preview <id> <parent> <direction> <climat> <diamètre> <relief> [distance] ; list ; visit <id> ; resume <id>. Diamètres : 64, 128, 256, 512. Distance minimale entre les centres, en blocs (0 = automatique, maximum 100000). Remplir les arguments précédents pour préciser la distance. Exemple : quick north autumn 128 peaks 2048. La création cherche plus loin si nécessaire, dans la limite de 32 emplacements et 30 secondes.",
|
||||
"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",
|
||||
@@ -75,7 +75,7 @@
|
||||
"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.occupied": "La région contient déjà des chunks. Augmenter la distance pour chercher plus loin.",
|
||||
"sanctuary.expansion.error.teleport_failed": "La téléportation a échoué.",
|
||||
"sanctuary.expansion.climate.temperate": "Tempéré",
|
||||
"sanctuary.expansion.climate.boreal": "Boréal",
|
||||
@@ -106,5 +106,8 @@
|
||||
"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."
|
||||
"sanctuary.expansion.error.invalid_relief": "Relief inconnu : natural, peaks, canyon ou volcano.",
|
||||
"sanctuary.expansion.error.invalid_distance": "La distance doit être comprise entre 0 et 100000 blocs. 0 utilise le placement automatique.",
|
||||
"sanctuary.expansion.error.no_free_space": "Aucun emplacement libre trouvé dans les limites de recherche. Réessayer avec une distance plus grande ou un autre parent. Aucun terrain existant n’a été modifié.",
|
||||
"sanctuary.expansion.error.search_timeout": "La recherche a atteint sa limite de 30 secondes. Réessayer avec une distance plus grande. Aucun terrain existant n’a été modifié."
|
||||
}
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
/** Requested distances and retry positions must preserve the saved graph and its reservation boundaries. */
|
||||
public final class ExpansionPlacementSmoke {
|
||||
private static final long SEED = 42;
|
||||
|
||||
public static void main(String[] args) {
|
||||
var root = ExpansionIsland.origin(512, SEED);
|
||||
var saved = new ArrayList<>(List.of(root));
|
||||
var before = List.copyOf(saved);
|
||||
|
||||
for (String direction : ExpansionIsland.DIRECTIONS) {
|
||||
var defaults = candidates(saved, "new_island", root.id(), direction, 64, 0);
|
||||
verifyCandidates(defaults, saved, root, direction, 64, 0);
|
||||
require(defaults.size() == 32, "An unobstructed origin must offer later positions when the first saved chunks are occupied");
|
||||
var first = defaults.getFirst();
|
||||
require(Math.max(Math.abs(first.centerX()), Math.abs(first.centerZ())) == 800,
|
||||
"Distance zero must retain the alpha.12 first placement in " + direction);
|
||||
require(first.equals(candidates(saved, "new_island", root.id(), direction, 64, 1).getFirst()),
|
||||
"A short requested distance must still respect the existing reservation and generation halo");
|
||||
|
||||
for (int diameter : ExpansionIsland.DIAMETERS) {
|
||||
var distant = candidates(saved, "new_island", root.id(), direction, diameter, 2048);
|
||||
verifyCandidates(distant, saved, root, direction, diameter, 2048);
|
||||
require(distance(root, distant.getFirst()) < 2048 + Math.sqrt(2) * 16,
|
||||
"An unobstructed requested distance needs only chunk alignment, not an unnecessary extra ring");
|
||||
require(distant.equals(candidates(saved, "new_island", root.id(), direction, diameter, 2048)),
|
||||
"Repeating a preview must produce the same ordered candidates");
|
||||
}
|
||||
rejectMutation(defaults, root);
|
||||
}
|
||||
require(saved.equals(before), "Planning changed the saved island descriptors or their order");
|
||||
require(root.generation() == 12 && root.status().equals("ready") && root.centerX() == 0 && root.centerZ() == 0,
|
||||
"The distance option must not migrate or move an existing alpha.12 origin");
|
||||
|
||||
var first = candidates(saved, "occupied", root.id(), "north", 64, 0).getFirst().ready();
|
||||
var withSibling = new ArrayList<>(List.of(root, first));
|
||||
var siblingSnapshot = List.copyOf(withSibling);
|
||||
var onward = candidates(withSibling, "later", root.id(), "north", 64, 0);
|
||||
verifyCandidates(onward, withSibling, root, "north", 64, 0);
|
||||
require(distance(root, onward.getFirst()) > distance(root, first),
|
||||
"An already reserved island must move the search farther along the chosen ray");
|
||||
require(withSibling.equals(siblingSnapshot), "Avoiding a sibling changed existing saved regions");
|
||||
|
||||
// Two distant parents isolate translation from any obstruction around the origin.
|
||||
var parentA = parent("branch", 8192, -8192);
|
||||
var parentB = parent("branch", -16384, 16384);
|
||||
for (String direction : ExpansionIsland.DIRECTIONS) {
|
||||
var localA = candidates(List.of(root, parentA), "child", parentA.id(), direction, 128, 2048);
|
||||
var localB = candidates(List.of(root, parentB), "child", parentB.id(), direction, 128, 2048);
|
||||
verifyCandidates(localA, List.of(root, parentA), parentA, direction, 128, 2048);
|
||||
verifyCandidates(localB, List.of(root, parentB), parentB, direction, 128, 2048);
|
||||
require(localA.getFirst().centerX() - parentA.centerX() == localB.getFirst().centerX() - parentB.centerX()
|
||||
&& localA.getFirst().centerZ() - parentA.centerZ() == localB.getFirst().centerZ() - parentB.centerZ(),
|
||||
"The requested distance is measured from the selected parent, including negative coordinates");
|
||||
}
|
||||
|
||||
var tropical = ExpansionPlacement.candidates(SEED, saved, "palette", root.id(), "north", "tropical", 128, "natural", 2048);
|
||||
var arid = ExpansionPlacement.candidates(SEED, saved, "palette", root.id(), "north", "arid", 128, "volcano", 2048);
|
||||
require(tropical.getFirst().centerX() == arid.getFirst().centerX() && tropical.getFirst().centerZ() == arid.getFirst().centerZ(),
|
||||
"Climate and relief choices must not impose a different geographical direction or distance");
|
||||
|
||||
for (int sign : new int[]{-1, 1}) {
|
||||
String direction = sign < 0 ? "west" : "east";
|
||||
var edge = parent("edge", sign * 99200, 0);
|
||||
var nearLimit = candidates(List.of(root, edge), "border", edge.id(), direction, 64, 0);
|
||||
verifyCandidates(nearLimit, List.of(root, edge), edge, direction, 64, 0);
|
||||
require(nearLimit.size() < 32, "The search must stop at the coordinate boundary instead of wrapping around");
|
||||
var maximum = candidates(saved, "maximum", root.id(), direction, 64, 100000);
|
||||
verifyCandidates(maximum, saved, root, direction, 64, 100000);
|
||||
require(maximum.getFirst().centerX() == sign * 100000, "The inclusive distance and coordinate limit must remain usable");
|
||||
var boundary = parent("boundary", sign * 100000, 0);
|
||||
require(candidates(List.of(root, boundary), "beyond", boundary.id(), direction, 64, 0).isEmpty(),
|
||||
"An exhausted ray must return no candidate instead of clamping or wrapping into existing terrain");
|
||||
}
|
||||
rejectDistance(saved, -1);
|
||||
rejectDistance(saved, Integer.MIN_VALUE);
|
||||
rejectDistance(saved, 100001);
|
||||
rejectDistance(saved, Integer.MAX_VALUE);
|
||||
|
||||
require(saved.equals(before), "Failed or alternative previews mutated the saved origin");
|
||||
System.out.println("ExpansionPlacementSmoke: eight directions, explicit distances, all diameters, reservation avoidance, parent translation, bounds and immutable history passed.");
|
||||
}
|
||||
|
||||
private static List<ExpansionIsland> candidates(List<ExpansionIsland> saved, String id, String parent, String direction, int diameter, int minimumDistance) {
|
||||
return ExpansionPlacement.candidates(SEED, saved, id, parent, direction, "autumn", diameter, "peaks", minimumDistance);
|
||||
}
|
||||
|
||||
private static void verifyCandidates(List<ExpansionIsland> candidates, List<ExpansionIsland> saved, ExpansionIsland parent,
|
||||
String direction, int diameter, int minimumDistance) {
|
||||
require(!candidates.isEmpty() && candidates.size() <= 32, "A feasible ray returns one to thirty-two candidates");
|
||||
var positions = new HashSet<String>();
|
||||
double previous = -1;
|
||||
for (var candidate : candidates) {
|
||||
long dx = (long) candidate.centerX() - parent.centerX();
|
||||
long dz = (long) candidate.centerZ() - parent.centerZ();
|
||||
require((candidate.centerX() & 15) == 0 && (candidate.centerZ() & 15) == 0, "Candidate centres must align to whole chunks");
|
||||
require(Math.abs((long) candidate.centerX()) <= 100000 && Math.abs((long) candidate.centerZ()) <= 100000,
|
||||
"Candidate centres stay within the saved coordinate contract");
|
||||
require(positions.add(candidate.centerX() + ":" + candidate.centerZ()), "A retry must not revisit an earlier candidate");
|
||||
require(onRay(dx, dz, direction), "Every retry remains on the chosen parent-relative direction");
|
||||
double current = distance(parent, candidate);
|
||||
require(current >= minimumDistance && current > previous, "Retries move outward and respect the minimum centre-to-centre distance");
|
||||
previous = current;
|
||||
for (var existing : saved) require(!candidate.conflicts(existing), "Candidates must not overlap any existing reservation and its halo");
|
||||
require(candidate.parent().equals(parent.id()) && candidate.direction().equals(direction)
|
||||
&& candidate.climate().equals("autumn") && candidate.diameter() == diameter && candidate.relief().equals("peaks"),
|
||||
"Placement must preserve the selected branch and generation profile");
|
||||
require(candidate.seed() == ExpansionIsland.deriveSeed(SEED, candidate.id()) && candidate.generation() == 12
|
||||
&& candidate.status().equals("reserved"), "Retries preserve the island seed, generation version and initial reservation state");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean onRay(long dx, long dz, String direction) {
|
||||
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 static ExpansionIsland parent(String id, int x, int z) {
|
||||
String direction = z == 0 ? (x < 0 ? "west" : "east")
|
||||
: (z < 0 ? (x > 0 ? "northeast" : "northwest") : (x > 0 ? "southeast" : "southwest"));
|
||||
return new ExpansionIsland(id, "sanctuary", direction, "temperate", 64, x, z,
|
||||
ExpansionIsland.deriveSeed(SEED, id), "natural", "ready", 12);
|
||||
}
|
||||
|
||||
private static double distance(ExpansionIsland from, ExpansionIsland to) {
|
||||
return Math.hypot((double) to.centerX() - from.centerX(), (double) to.centerZ() - from.centerZ());
|
||||
}
|
||||
|
||||
private static void rejectDistance(List<ExpansionIsland> saved, int distance) {
|
||||
try { candidates(saved, "invalid", "sanctuary", "north", 64, distance); }
|
||||
catch (IllegalArgumentException expected) {
|
||||
require("invalid_distance".equals(expected.getMessage()), "An invalid distance needs its specific translated error key");
|
||||
return;
|
||||
}
|
||||
throw new AssertionError("Out-of-range distance was accepted: " + distance);
|
||||
}
|
||||
|
||||
private static void rejectMutation(List<ExpansionIsland> candidates, ExpansionIsland root) {
|
||||
try { candidates.add(root); }
|
||||
catch (UnsupportedOperationException expected) { return; }
|
||||
throw new AssertionError("The candidate list exposes mutable shared state");
|
||||
}
|
||||
|
||||
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.12"
|
||||
version = "0.1.0-alpha.12.1"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
Reference in New Issue
Block a user