Compare commits

..
4 Commits
Author SHA1 Message Date
koka 99860af312 Add Sanctuary gems, original clay colors and shared discovery commands for beta.126
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 17:10:15 +02:00
koka 1547396c06 Document verified beta.125 release and instance synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 16:37:18 +02:00
koka 4ec5b0d028 Add sculpture corner and height placement, voxel chips and discovered workshop models
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 16:35:46 +02:00
koka 67e6e7c4f2 Record beta.124 publication and preserved Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 16:02:47 +02:00
138 changed files with 2488 additions and 79 deletions
+23 -6
View File
@@ -20,12 +20,13 @@ La [vision complète](docs/vision.md) conserve les intentions ; le
L’économie, les claims et les dimensions décrits dans la vision restent
à implémenter.
La version **beta.124** utilise une seule boîte ajustée au volume de chaque
sculpture, commune à la sélection et aux collisions. Les détails ne créent plus
de petites marches ; l'orientation, l'ancrage et la lumière restent cohérents.
Le [ticket de livraison](docs/sculpture-bounds-beta124.md) détaille le calcul
mis en cache et les vérifications en jeu et archives réussies. La publication
et la synchronisation de linstance sont préparées.
La version **beta.126** ajoute le rubis et le saphir, leurs minerais et leurs
blocs, rangés après l'émeraude. Leur génération est configurable et limitée
aux nouveaux chunks de Sanctuary. L'argile classique du Clay Workshop conserve
les couleurs originales des modèles. La commande `/sanctuary discovery`
enrichit les découvertes communes, globalement ou précisément, et actualise
les recettes et catalogues liés. Le [ticket de livraison](docs/gems-beta126.md)
décrit les règles, la compatibilité et les vérifications.
La [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà
latelier, les œufs et la neige saisonnière dans linstance **Sanctuary Beta**.
Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées.
@@ -36,6 +37,22 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
l’étude de Xaero et des alternatives libres.
## beta.126 — Gemmes, argile et découvertes
Rubis et saphir avec filons configurables, couleurs originales avec l'argile,
commandes de découverte globales ou ciblées.
[Contrat et vérifications](docs/gems-beta126.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.126/Sanctuary-beta.126.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.126/Sanctuary-Test-beta.126.mrpack).
## beta.125 — Sculptures : placement, éclats et animaux
Coins et trois hauteurs sur la grille 1/16 ; particules issues des voxels ;
modèles découverts disponibles dans le Clay Workshop.
[Contrat et vérifications](docs/sculpture-placement-beta125.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.125/Sanctuary-beta.125.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.125/Sanctuary-Test-beta.125.mrpack).
## beta.124 — Une boîte par sculpture
Sélection et collisions simplifiées, dimensions ajustées et lumière conservée.
+136
View File
@@ -0,0 +1,136 @@
# GEM-01 — Gemmes, argile et découvertes — beta.126
Contrat du 17 septembre 2026, branche `codex/gems-beta126`, Minecraft 26.3.
Deux gemmes natives complètent l'émeraude : `sanctuary:ruby` et
`sanctuary:sapphire`. Chaque famille comprend un bloc de stockage, un minerai
ordinaire et un minerai des abîmes. Les huit PNG du créateur sont copiés à
l'identique ; leurs empreintes figurent dans `tools/gems-textures-beta126.json`.
Les noms des fichiers français `saphir` sont associés aux identifiants anglais
stables `sapphire`, sans modifier les pixels. Les deux items utilisent les fichiers de remplacement
`Desktop/ruby.png` et `Desktop/saphir.png` fournis en fin de livraison.
L'ordre créatif est émeraude, rubis, saphir : ingrédients, blocs de stockage,
minerais ordinaires et minerais des abîmes dans leurs onglets natifs respectifs.
Minage à la pioche de fer ou mieux, résistance et expérience comme l'émeraude,
Fortune, Toucher de soie, cuisson/four à fusion et conversion 9 gemmes ↔ 1 bloc.
Les blocs peuvent former un socle de balise et les gemmes servir de paiement.
Les recettes rejoignent la collection actuelle de l'émeraude, sans ajouter de
transactions de villageois. Libellés français et anglais, tags communs `c:`.
## Génération procédurale et contrat de compatibilité
Profil initial **gemmes 1 / beta.126** : quatre tentatives par chunk et par
gemme, filons natifs de taille 3, hauteur uniforme Y0256, probabilité de rejet
au contact de l'air de 0,25. Ces nombres sont un réglage initial à équilibrer
lors de la future refonte ; aucun quota de gemmes par chunk n'est garanti.
Les variantes ordinaires remplacent les roches du tag vanilla
`stone_ore_replaceables`, les variantes des abîmes celles de
`deepslate_ore_replaceables`.
Les fichiers `worldgen/feature/ore_<gemme>.json`,
`worldgen/placed_feature/ore_<gemme>.json` et les tags de biomes
`has_ore/<gemme>` séparent les cibles, la forme, la fréquence, la hauteur et
les régions. Un datapack peut les remplacer sans recompiler le mod. Le code
réutilise `OreFeature` et le hasard natif issu de la graine ; il limite ces
filons aux mondes utilisant `SanctuaryChunkGenerator`. Les biomes vanilla
partagés avec Sanctuary restent compatibles sans ajouter les gemmes aux
mondes vanilla, au Nether ou à l'End.
Cette évolution additive est autorisée pour les **nouveaux chunks uniquement**.
Aucun remplissage rétrospectif, aucune régénération, aucun remplacement de chunk
sauvegardé ni activation d'expansion. Les codecs du relief, les dimensions et
le format des sauvegardes restent inchangés. Les filons suivent les protections
existantes de la décoration native. Les anciens profils expérimentaux précédant
le générateur Sanctuary unifié ne sont pas étendus.
## Argile spéciale dans le Clay Workshop
Le bloc d'argile vanilla conserve les couleurs échantillonnées sur le modèle
original, animal découvert ou import GLB. Les argiles colorées et les autres
blocs continuent à transférer leur palette de texture. Une même règle est
appliquée à l'aperçu client et au résultat serveur. Le changement de matériau
invalide le résultat même si les deux palettes sont identiques. Le texte de
l'atelier explique l'exception en français et en anglais.
Une sculpture coûte toujours un bloc ; aucune recette, découverte, donnée de
sculpture déjà fabriquée ni règle de placement n'est modifiée.
## Commande de découverte commune
`/sanctuary discovery all` découvre tous les blocs, objets et espèces enregistrés.
Les variantes `blocks all`, `items all` et `mobs all` ciblent une catégorie ;
`block <identifiant>`, `item <identifiant>` et `mob <identifiant>` ciblent une
entrée avec la complétion native. Un sélecteur de joueurs en fin de commande
est facultatif ; sans cible, elle s'applique au joueur qui l'exécute.
Exemples :
```mcfunction
/sanctuary discovery all
/sanctuary discovery mobs all
/sanctuary discovery block minecraft:stone
/sanctuary discovery item minecraft:diamond
/sanctuary discovery mob minecraft:cow
/sanctuary discovery all @a
```
La permission suit « Autoriser les commandes » en solo et les droits opérateur
sur serveur. La console doit préciser la cible. Les identifiants invalides,
l'air et les types d'entités sans espèce découvrable sont refusés.
Les blocs passent par le registre de découvertes et son événement existant ;
les objets passent par `RecipeService.discover`. Les collections et recettes
sont ensuite recalculées normalement. Les espèces rejoignent `seen_mobs`,
la même donnée que l'observation naturelle, consommée par les familiers,
le Métabli et le Clay Workshop. Il n'existe aucun drapeau de déblocage parallèle.
Les catalogues déjà ouverts sont à rouvrir après la commande.
La commande conserve les quantités possédées et les compteurs de combat,
minage, pose et fabrication. Elle ne donne pas d'objets, n'achète pas les skills
et n'attribue pas d'advancements. Les recettes réservées aux opérateurs gardent
leurs règles. Les historiques personnels sont enrichis sans changer leur
schéma ; la commande refuse les données illisibles plutôt que les remplacer.
## Vérifications
Client natif Minecraft 26.3 / Java 25, `Gems126ClientChecks`, réussi en
1 min 08 s. Les mondes sont jetables, sans sauvegarde personnelle ouverte :
- Les huit items et six blocs sont présents, avec les huit textures exactes.
Quatre groupes créatifs respectent émeraude → rubis → saphir, sans doublons.
- Douze recettes natives, découverte du bloc de stockage, outil fer minimum,
Fortune III, Toucher de soie et balises passent les contrôles serveur.
- Graine **126**, profil gemmes 1, 81 chunks autour de l'origine : 7 rubis dans
la pierre et 8 dans la roche des abîmes ; 15 saphirs dans la pierre et 10 dans
la roche des abîmes. La génération est refusée dans le monde plat vanilla.
Les minerais persistent à la reconnexion, et un minerai retiré ne réapparaît pas.
- L'argile conserve exactement les couleurs des voxels d'une vache et d'un GLB
texturé. Bois et argile colorée transfèrent toujours leur palette. Le retour
à l'argile, l'égalité aperçu/résultat et le coût d'un bloc en survie sont vérifiés.
- La commande native respecte « Autoriser les commandes », accepte une cible
et la console, refuse les identifiants invalides et permet une espèce, un
bloc, un objet, toutes les espèces ou tout le contenu. Les recettes liées
sont recalculées. La répétition est idempotente et les découvertes survivent
à la reconnexion sans inventer de compteurs de combat ou de possession.
Logs et captures dans `build/gems126-client.log` et
`mods/sanctuary/build/run/clientGameTest/screenshots/`. Le GameTest dédié
reste exclu conformément au refus antérieur de son EULA. Le build complet et
les archives sont vérifiés séparément avant publication.
Build final `./gradlew check build assemblePack assembleTestPack
-x :sanctuary:runGameTest` réussi en **2 min 10 s**, 126 tâches. Les contrôles
natifs de gameplay précèdent uniquement le remplacement final des deux PNG
d'items ; les huit fichiers livrés sont vérifiés à l'octet près contre les
sources, tous en 16 × 16. Les JAR de sources correspondent aux sources courantes.
L'audit entre les binaires beta.125 et beta.126 ne trouve que les 112 entrées
de production prévues. Les archives normal/Test contiennent le même mod et
le template reproduit exactement ses ressources exportables.
| Artefact | SHA-256 |
| --- | --- |
| `Sanctuary-beta.126.mrpack` | `716221ce24549781ab91f5437ad2310497b198a4b61c43c40c67a27984365ae5` |
| `Sanctuary-Test-beta.126.mrpack` | `bbf22d7dd72e6746538b4ec28686f699225d4a59372304e5db42140a80e88bd1` |
| `sanctuary-beta.126.jar` | `85dbe1b41d98751029bb62e87d259522f8609758896c797b1e02fa7656ec0342` |
+25
View File
@@ -14,6 +14,31 @@ 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.
## beta.125 — placement, éclats et animaux sculptés
Coins et trois hauteurs, particules de voxels et espèces découvertes au Clay
Workshop. [Contrat et vérifications](sculpture-placement-beta125.md).
[Release beta.125](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.125)
publiée ; canal `47eb833911a96eef0b13805dafe65b2ab7b95c87`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable dans
`sanctuary-backups/before-beta.125/`. Packs normal/Test et template copiés
et vérifiés dans le dossier habituel `sanctuary-beta/build/`.
## beta.124 — une boîte par sculpture
Une boîte ajustée commune à la sélection et aux collisions ; détails physiques
simplifiés, orientation/ancrage et éclairage conservés.
[Contrat et vérifications](sculpture-bounds-beta124.md).
[Release beta.124](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.124)
publiée ; canal `67ebcc7d07f8b75e8988f6099e3d6e5704f123fb`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.124/`. Packs normal/Test et template également
copiés et vérifiés dans le dossier habituel `sanctuary-beta/build/`.
## beta.123 — clé dorée et clé de Steve
La clé dorée se limite aux orientations cohérentes ; la clé de Steve reprend
+14
View File
@@ -77,3 +77,17 @@ habituel `sanctuary-beta/build/`.
Le GameTest dédié reste exclu conformément au refus antérieur de son EULA.
Aucun monde personnel n'est ouvert par les essais.
## Publication et installation
La [release beta.124](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.124)
est publiée depuis `00179e30ef0b45ef98378bc164a7810552dd1bdd`.
Le tag exact et les artefacts sont immuables, leurs téléchargements publics
vérifiés. Canal packwiz : `67ebcc7d07f8b75e8988f6099e3d6e5704f123fb`.
Deux synchronisations isolées puis deux dans **Sanctuary Beta** réussissent.
Un seul JAR Sanctuary beta.124 est actif, conforme au hash vérifié ; le second
passage ne change rien. Les **923 fichiers personnels et réglages suivis**
restent inchangés, sans ouverture de monde personnel. Copie préalable dans
`sanctuary-backups/before-beta.124/` de l'instance existante.
Reçus : `build/sculpture124-isolated.json` et `build/sculpture124-prism.json`.
+108
View File
@@ -0,0 +1,108 @@
# SCULPT-11 — Placement, éclats et modèles découverts — beta.125
Contrat du 17 septembre 2026, branche `codex/sculpture-placement-beta125`.
Le point visé choisit neuf positions horizontales (centre, quatre bords et quatre
coins) et trois hauteurs (bas, milieu et haut). Sur un support, la sculpture
repose au bas de son bloc ; sous un plafond, elle touche le haut ; sur une face
latérale, elle suit la hauteur visée et reste contre le support. Les sculptures
restent debout et s'orientent selon le regard horizontal du joueur.
La bande centrale occupe les huit pixels médians, limites incluses. Toute pose
reste sur la grille 1/16 : une dimension impaire utilise le centre entier le
plus proche, jamais un demi-voxel. Rendu, boîte unique de sélection et collision
partagent les mêmes offsets. La lumière conserve les règles de beta.122.
## Compatibilité additive des états existants
Les cinq anciennes valeurs `anchor` gardent exactement leurs noms. Quatre
valeurs de coin sont ajoutées. La nouvelle propriété `vertical_anchor` accepte
`bottom`, `center`, `top` et `original`, valeur par défaut réservée à la
compatibilité. La lecture d'un ancien bloc sans cette propriété conserve
l'ancienne hauteur, même si le modèle a des marges transparentes en bas.
Le bloc possède 144 états : 108 placements nouveaux et 36 états de compatibilité.
Le schéma 1 des sculptures, leurs données de voxels et tous leurs identifiants
restent inchangés. Aucun monde n'est parcouru ou réécrit, aucun chunk régénéré.
Les nouveaux ancrages nécessitent beta.125 ; le retour à une version antérieure
ne garantit pas leur position. Ce contrat autorise l'ajout compatible des états,
pas une migration destructive des sauvegardes personnelles.
## Particules
Les coups et la casse émettent des éclats de la couleur des surfaces voxelisées
réellement affichées, avec leur orientation et leur ancrage. Un coup vise la
surface réelle la plus proche du point sélectionné, même devant un trou visuel.
La casse échantillonne au plus 64 faces ; aucun calcul de texture n'est refait.
Les éclats utilisent une taille de voxel, la gravité et la lumière natives.
Une petite mémoire par monde (128 entrées de 64 faces, validité 40 ticks) conserve les
surfaces si la suppression du bloc précède l'événement de casse. Les autres
blocs gardent leurs particules habituelles.
## Clay Workshop
L'onglet Animaux utilise exactement les découvertes serveur du catalogue de
statues (rencontre, victoire ou mort face à l'espèce). Le menu transmet cette
liste à chaque ouverture, sans dépendre d'une visite préalable au Métabli.
Le modèle natif et sa texture sont capturés localement, voxelisés en miniature,
puis recolorés par la matière choisie. Le résultat reste fabriqué et payé côté
serveur. Les sélections d'espèces non découvertes sont refusées. L'onglet Imports
conserve les fichiers GLB. Aucun nouveau format de sculpture n'est introduit.
Libellés FR et EN.
## Vérifications
Le parcours natif `Sculpture125ClientChecks` passe en **1 min 2 s**, client
Minecraft 26.3 sur macOS et serveur intégré jetable (graines 125 et 122).
- 54 clics réels sur les six faces : centres, bords, coins et hauteurs.
- 432 contextes de pose, coordonnées positives/négatives, quatre orientations,
rotations et miroirs ; 144 états valides.
- 20 anciens états sans `vertical_anchor` conservent leur hauteur et marges.
- Couleur exacte des éclats au point frappé, surfaces réellement occupées,
limite de 64 fragments, événement après suppression et particules normales
d'un bloc de pierre témoin.
- Atelier : découvertes par rencontre, victoire et mort, transmission native
du menu, refus serveur d'une espèce inconnue, trois modèles natifs (vache,
mouton, cochon), paiement d'un bloc en survie et import GLB conservé.
- Sauvegarde et reconnexion avec les nouveaux coins et hauteurs.
- 576 formes comparées aux limites du maillage affiché, boîte unique partagée,
cache réutilisé et 12 arêtes ; 9 216 empreintes orientées sur grille entière.
- 75 sculptures superposées : ciel à 15, lumière de bloc à 13 après traversée,
pièce noire à 0. Pose dans le joueur refusée, même devant un trou visuel.
Marqueurs dans `build/sculpture125-client.log`. Captures de plafond, murs et
atelier relues dans `build/sculpture125-evidence/`. Aucun essai Windows,
client distant ou shader tiers ; trois espèces vérifiées visuellement, sans
prétendre avoir inspecté chaque espèce. Aucun benchmark FPS global.
Le GameTest dédié demeure exclu conformément au refus antérieur de son EULA.
Les mondes de test sont jetables ; aucun monde personnel n'est ouvert.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 16 s**, 126 tâches (110 exécutées, 16 à jour). Les sources
des JAR correspondent exactement au dépôt. La comparaison avec beta.124
limite les différences aux classes de placement, particules et atelier prévues,
aux nouveaux libellés FR/EN, au mixin client et à la source blanche de particules.
Les packs normal/Test partagent le même JAR, sans monde ni fixture ; le template
reproduit les ressources du mod et du pack intégré.
- Sanctuary-beta.125.mrpack : 10286360 octets, SHA-256 `9277e59c640c40392ab3750b402bb840020884878f6ece6978e6c4653e18655e`.
- Sanctuary-Test-beta.125.mrpack : 10305282 octets, SHA-256 `9e3dbfa14b5382ba4eb62a78fa7d2161758c2f0e1fdc50fbd5ed776fd4ce32c5`.
- JAR Sanctuary : SHA-256 `b38b75b900dd8cbe015318c739d5547fd7eac52f4337fa517e98f3f6f0ce3b46`.
Reçu local : `build/sculpture125-artifact.json`. Les deux packs et le template
sont copiés et vérifiés dans le dossier habituel `sanctuary-beta/build/`.
## Publication et installation
La [release beta.125](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.125)
est publiée depuis `4ec5b0d028a23a9f753770988ffba61015e2e49a`.
Tag et artefacts immuables, téléchargements publics vérifiés. Canal packwiz :
`47eb833911a96eef0b13805dafe65b2ab7b95c87`.
Deux synchronisations isolées puis deux dans l'instance **Sanctuary Beta**
réussissent. Un seul JAR Sanctuary beta.125 est actif, conforme au hash vérifié ;
le second passage ne change rien. Les **923 fichiers personnels et réglages
suivis** restent inchangés, sans ouverture de monde personnel. Copie préalable
sous `sanctuary-backups/before-beta.125/`. Reçus :
`build/sculpture125-isolated.json` et `build/sculpture125-prism.json`.
+2 -2
View File
@@ -9,8 +9,8 @@ loom_version=1.17.20
fabric_api_version=0.160.5+26.3
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
mod_version=beta.124
pack_version=beta.124
mod_version=beta.126
pack_version=beta.126
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+8
View File
@@ -93,6 +93,14 @@ tasks.named('runGameTest') {
}
tasks.named('processGametestResources') {
def gems126Client = providers.gradleProperty('sanctuaryGems126ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryGems126ClientTests', gems126Client)
if (gems126Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Gems126ClientChecks') }
def sculpture125Client = providers.gradleProperty('sanctuarySculpture125ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySculpture125ClientTests', sculpture125Client)
if (sculpture125Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Sculpture125ClientChecks') }
def wrench123Client = providers.gradleProperty('sanctuaryWrench123ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryWrench123ClientTests', wrench123Client)
if (wrench123Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Wrench123ClientChecks') }
@@ -0,0 +1,42 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.companion.CompanionService;
import fr.koka.sanctuary.plans.*;
import java.nio.file.*;
import java.util.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.core.BlockPos;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.*;
import net.minecraft.world.level.GameType;
/** Native preview, server result and survival payment for the special plain-clay material. */
final class Clay126Checks {
static void exercise(ClientGameTestContext c,TestServerContext server){
var pos=new BlockPos(4,1,4);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(3,1,3);p.setGameMode(GameType.SURVIVAL);p.getAbilities().invulnerable=true;p.onUpdateAbilities();p.level().setBlockAndUpdate(pos,Statuary.WORKSHOP.defaultBlockState());CompanionService.save(p,CompanionService.record(p).note("seen_mobs","cow"));Statuary.open(p,pos);p.containerMenu.getSlot(0).set(new ItemStack(Items.CLAY,2));p.containerMenu.broadcastChanges();});
c.waitForScreen(ClayWorkshopScreen.class);c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();((ClayWorkshopScreen)m.gui.screen()).load(EntityTypes.COW);});
c.waitFor(m->{var screen=(ClayWorkshopScreen)m.gui.screen();return !screen.busy()&&screen.selected()!=null&&screen.selected().equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem()));},250);
var cow=c.computeOnClient(m->((ClayWorkshopScreen)m.gui.screen()).selected());check(cow.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>3,"Cow source retains texture colors");
for(var material:List.of(Items.OAK_PLANKS,fr.koka.sanctuary.building.ColoredBricks.families().getFirst().clay().asItem(),Items.CLAY)){
server.runOnServer(s->{var menu=s.getPlayerList().getPlayers().getFirst().containerMenu;menu.getSlot(0).set(new ItemStack(material,2));menu.broadcastChanges();});
var expected=c.computeOnClient(m->ClayWorkshopMenu.appearance(cow,material,SculptureMaterialSampler.sample(new ItemStack(material))));
check(material==Items.CLAY?expected.equals(cow):!expected.equals(cow),"Only plain clay retains original colors");
c.waitFor(m->expected.equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())),180);
c.runOnClient(m->check(ItemStack.matches(((ClayWorkshopScreen)m.gui.screen()).preview(),m.player.containerMenu.getSlot(1).getItem()),"Preview matches authoritative material switch"));
}
c.takeScreenshot("clay126-original-cow");
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.PICKUP,m.player));
server.waitFor(s->{var menu=s.getPlayerList().getPlayers().getFirst().containerMenu;return cow.equals(StatuaryEntity.sculpture(menu.getCarried()))&&menu.getSlot(0).getItem().getCount()==1;},100);
c.waitFor(m->cow.equals(StatuaryEntity.sculpture(m.player.containerMenu.getCarried()))&&m.player.containerMenu.getSlot(0).getItem().getCount()==1,100);
Path file=PlanClient.directory().resolve("clay126-"+UUID.randomUUID()+".glb");
try{Files.createDirectories(file.getParent());Files.write(file,Statuary106ClientChecks.fixture());var id=GlbModel.load(file).id();c.runOnClient(m->((ClayWorkshopScreen)m.gui.screen()).load(file));
c.waitFor(m->{var screen=(ClayWorkshopScreen)m.gui.screen();return !screen.busy()&&screen.selected()!=null&&screen.selected().modelId().equals(id)&&screen.selected().equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem()));},250);
c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();check(screen.selected().cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>1,"Imported textured GLB retains its original colors");check(ItemStack.matches(screen.preview(),m.player.containerMenu.getSlot(1).getItem()),"Imported preview equals native result");});
}catch(Exception e){throw new AssertionError(e);}finally{try{Files.deleteIfExists(file);}catch(Exception ignored){}}
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.closeContainer();p.setGameMode(GameType.CREATIVE);p.getAbilities().flying=true;p.onUpdateAbilities();});c.waitFor(m->m.gui.screen()==null,100);
System.out.println("CLAY126_PASS original animal and GLB colors, oak and colored clay palette transfer, switch back, matching previews and one clay paid in survival");
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,47 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.knowledge.*;
import fr.koka.sanctuary.plans.*;
import fr.koka.sanctuary.recipes.*;
import java.util.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.stats.Stats;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
final class Discovery126Checks {
static void exercise(ClientGameTestContext c,TestServerContext server){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var dispatcher=s.getCommands().getDispatcher();
var source=p.createCommandSourceStack();var root=dispatcher.getRoot().getChild("sanctuary").getChild("discovery");
s.getWorldData().setAllowCommands(false);check(!root.canUse(source),"Discovery commands respect native Allow Commands");
try{dispatcher.execute("sanctuary discovery all",source);throw new AssertionError("Denied command executed");}catch(com.mojang.brigadier.exceptions.CommandSyntaxException expected){}
s.getWorldData().setAllowCommands(true);
var before=CompanionService.record(p);var items=new TreeSet<>(RecipeService.knowledge(p).items);int kills=p.getStats().getValue(Stats.ENTITY_KILLED,EntityTypes.WITHER);
command(p,"mob minecraft:wither");check(StatueDiscoveries.known(p).contains("minecraft:wither"),"Precise species unlocks sculpture catalogue");check(CompanionService.record(p).notes().get("seen_mobs").contains("wither"),"Same actual seen_mobs record");check(RecipeService.knowledge(p).items.equals(items),"Mob discovery invents no unrelated item discovery");check(p.getStats().getValue(Stats.ENTITY_KILLED,EntityTypes.WITHER)==kills,"No fake kill stat");
command(p,"block minecraft:pink_glazed_terracotta");var block=BlockKnowledgeService.knowledge(p,Blocks.GLAZED_TERRACOTTA.pick(DyeColor.PINK));check(block.seen()&&!block.possessed()&&block.mined()==0&&block.placed()==0,"Precise block is seen without fabricated possession or stats");check(RecipeService.knowledge(p).items.contains("minecraft:pink_glazed_terracotta")&&RecipeService.knowledge(p).collections.contains("sanctuary:terracotta"),"Block event unlocks its normal recipe collection");
command(p,"item minecraft:amethyst_shard");check(RecipeService.knowledge(p).items.contains("minecraft:amethyst_shard")&&RecipeService.knowledge(p).recipes.contains("minecraft:spyglass"),"Precise item unlocks linked recipes");
String old=CompanionService.record(p).json();check(run(p,"mob minecraft:item")==0,"Nonmob type refused");check(CompanionService.record(p).json().equals(old),"Rejected request does not change discoveries");
try{dispatcher.execute("sanctuary discovery item sanctuary:missing_item",source);throw new AssertionError("Missing ID accepted");}catch(com.mojang.brigadier.exceptions.CommandSyntaxException expected){}
command(p,"mobs all");allSpecies(p);var pos=new BlockPos(4,1,4);Statuary.open(p,pos);check(((ClayWorkshopMenu)p.containerMenu).view.statues().equals(StatueDiscoveries.known(p)),"Workshop sees the shared full catalogue");p.closeContainer();
command(p,"all");all(p);String species=CompanionService.record(p).json(),recipes=RecipeService.knowledge(p).json();var blocks=BlockKnowledgeService.snapshot(p);command(p,"all @s");check(species.equals(CompanionService.record(p).json())&&recipes.equals(RecipeService.knowledge(p).json())&&blocks.equals(BlockKnowledgeService.snapshot(p)),"Repeated all with target selector is idempotent");
check(CompanionService.record(p).paid()==before.paid()&&CompanionService.record(p).unlocked()==before.unlocked(),"Discoveries do not fabricate paid skills");
try{check(dispatcher.execute("sanctuary discovery mob minecraft:cow "+p.getGameProfile().name(),s.createCommandSourceStack())==1,"Console can address an online player");}catch(Exception e){throw new AssertionError(e);}
System.out.println("DISCOVERY126_PASS native permissions, precise block/item/species, linked recipes, shared catalogue, all three categories, all, selector, console, idempotence and unchanged combat/ownership counters");
});
c.waitFor(m->m.gui.screen()==null,100);
}
static void all(ServerPlayer p){
allSpecies(p);check(BlockKnowledgeService.snapshot(p).stream().allMatch(BlockKnowledge::known),"All block discoveries");
var knowledge=RecipeService.knowledge(p);for(var item:BuiltInRegistries.ITEM)if(item!=Items.AIR)check(knowledge.items.contains(BuiltInRegistries.ITEM.getKey(item).toString()),"All registered items discovered");
check(knowledge.recipes.contains("minecraft:spyglass")&&knowledge.recipes.contains("sanctuary:ruby_block"),"Vanilla and Sanctuary recipes are derived from discoveries");
}
static void allSpecies(ServerPlayer p){check(StatueDiscoveries.known(p).size()==BuiltInRegistries.ENTITY_TYPE.stream().filter(t->SpawnEggItem.byId(t).isPresent()).count(),"All registered species discovered");}
static void command(ServerPlayer p,String args){check(run(p,args)==1,"Discovery command succeeds: "+args);}
private static int run(ServerPlayer p,String args){try{return p.level().getServer().getCommands().getDispatcher().execute("sanctuary discovery "+args,p.createCommandSourceStack());}catch(Exception e){throw new AssertionError(e);}}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,88 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.gems.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.registries.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.resources.ResourceKey;
import net.minecraft.tags.*;
import net.minecraft.util.RandomSource;
import net.minecraft.world.item.*;
import net.minecraft.world.item.crafting.*;
import net.minecraft.world.item.enchantment.Enchantments;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.levelgen.GenerationStep;
/** Actual native registration, recipes, drops, tab order and naturally decorated Sanctuary chunks. */
public final class Gems126ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);Starter039TestSupport.selectFirst(screen);var enter=new KeyEvent(InputConstants.KEY_RETURN,0,0);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter);intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Gems126 registry "+UUID.randomUUID());s.setSeed("126");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
world.getServer().runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var l=p.level();var pos=new BlockPos(0,4,0);var iron=new ItemStack(Items.IRON_PICKAXE);var silk=iron.copy();silk.enchant(l.registryAccess().lookupOrThrow(Registries.ENCHANTMENT).getOrThrow(Enchantments.SILK_TOUCH),1);var fortune=iron.copy();fortune.enchant(l.registryAccess().lookupOrThrow(Registries.ENCHANTMENT).getOrThrow(Enchantments.FORTUNE),3);
for(var f:Gems.FAMILIES){
for(var b:List.of(f.block(),f.ore(),f.deepslateOre())){var state=b.defaultBlockState();check(state.is(BlockTags.MINEABLE_WITH_PICKAXE)&&state.is(BlockTags.NEEDS_IRON_TOOL),"Native mining tags "+b);check(iron.isCorrectToolForDrops(state)&&!new ItemStack(Items.STONE_PICKAXE).isCorrectToolForDrops(state),"Iron tier enforced "+b);}
drop(Block.getDrops(f.block().defaultBlockState(),l,pos,null,p,iron),f.block().asItem(),1);
for(var ore:List.of(f.ore(),f.deepslateOre())){
drop(Block.getDrops(ore.defaultBlockState(),l,pos,null,p,iron),f.gem(),1);drop(Block.getDrops(ore.defaultBlockState(),l,pos,null,p,silk),ore.asItem(),1);
int max=0;for(int i=0;i<48;i++){var drops=Block.getDrops(ore.defaultBlockState(),l,pos,null,p,fortune);check(drops.size()==1&&drops.getFirst().is(f.gem()),"Fortune gem type");int n=drops.getFirst().getCount();check(n>=1&&n<=4,"Native Fortune III range");max=Math.max(max,n);}check(max>1,"Fortune increases gem yield");
var input=new SingleRecipeInput(new ItemStack(ore));for(var type:List.of(RecipeType.SMELTING,RecipeType.BLASTING)){var recipe=s.getRecipeManager().getRecipeFor(type,input,l).orElseThrow().value();check(recipe.assemble(input).is(f.gem()),"Native furnace recipe");}
}
var input=CraftingInput.of(3,3,Collections.nCopies(9,new ItemStack(f.gem())));drop(List.of(s.getRecipeManager().getRecipeFor(RecipeType.CRAFTING,input,l).orElseThrow().value().assemble(input)),f.block().asItem(),1);
input=CraftingInput.of(1,1,List.of(new ItemStack(f.block())));drop(List.of(s.getRecipeManager().getRecipeFor(RecipeType.CRAFTING,input,l).orElseThrow().value().assemble(input)),f.gem(),9);
check(f.block().defaultBlockState().is(BlockTags.BEACON_BASE_BLOCKS)&&new ItemStack(f.gem()).is(ItemTags.BEACON_PAYMENT_ITEMS),"Native beacon compatibility");
var feature=l.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(ResourceKey.create(Registries.FEATURE,SanctuaryMod.id("ore_"+f.id()))).value();check(feature instanceof GemOreFeature,"Data feature decoded");
check(!feature.place(l,l.getChunkSource().getGenerator(),RandomSource.create(126),pos),"Gem veins do not run in ordinary flat/vanilla worlds");
fr.koka.sanctuary.recipes.RecipeService.discover(p,f.gem());fr.koka.sanctuary.recipes.RecipeService.refresh(p);check(fr.koka.sanctuary.recipes.RecipeService.knowledge(p).recipes.contains("sanctuary:"+f.id()+"_block"),"Native discovery unlocks storage recipe");
}
int x=0;for(var block:List.of(Blocks.EMERALD_BLOCK,Gems.RUBY.block(),Gems.SAPPHIRE.block(),Blocks.EMERALD_ORE,Gems.RUBY.ore(),Gems.SAPPHIRE.ore(),Blocks.DEEPSLATE_EMERALD_ORE,Gems.RUBY.deepslateOre(),Gems.SAPPHIRE.deepslateOre())){l.setBlockAndUpdate(new BlockPos(x%3,1,x/3),block.defaultBlockState());x++;}
p.teleportTo(1.5,3,7);p.getAbilities().flying=true;p.onUpdateAbilities();p.getInventory().setItem(0,new ItemStack(Items.EMERALD));p.getInventory().setItem(1,new ItemStack(Gems.RUBY.gem()));p.getInventory().setItem(2,new ItemStack(Gems.SAPPHIRE.gem()));p.inventoryMenu.broadcastFullState();
System.out.println("GEMS126_SERVER_PASS eight registered items, six blocks, native loot/Fortune/Silk Touch, twelve recipes, mining tiers, beacons, discoveries and vanilla generation exclusion");
});
c.runOnClient(m->{CreativeModeTabs.tryRebuildTabContents(m.level.enabledFeatures(),true,m.level.registryAccess());order(CreativeModeTabs.INGREDIENTS,Items.EMERALD,Gems.RUBY.gem(),Gems.SAPPHIRE.gem());order(CreativeModeTabs.BUILDING_BLOCKS,Items.EMERALD_BLOCK,Gems.RUBY.block().asItem(),Gems.SAPPHIRE.block().asItem());order(CreativeModeTabs.NATURAL_BLOCKS,Items.EMERALD_ORE,Gems.RUBY.ore().asItem(),Gems.SAPPHIRE.ore().asItem());order(CreativeModeTabs.NATURAL_BLOCKS,Items.DEEPSLATE_EMERALD_ORE,Gems.RUBY.deepslateOre().asItem(),Gems.SAPPHIRE.deepslateOre().asItem());
for(var f:Gems.FAMILIES)for(var block:List.of(f.block(),f.ore(),f.deepslateOre())){var parts=new ArrayList<net.minecraft.client.renderer.block.dispatch.BlockStateModelPart>();m.getModelManager().getBlockStateModelSet().get(block.defaultBlockState()).collectParts(RandomSource.create(0),parts);check(!parts.isEmpty(),"Baked gem block model");for(var part:parts)for(var face:Direction.values())for(var quad:part.getQuads(face))check(!quad.materialInfo().sprite().contents().name().toString().contains("missingno"),"Native texture atlas resolves supplied gem PNG");}
System.out.println("GEMS126_CREATIVE_PASS emerald, ruby, sapphire adjacency in four groups; all six baked block models");
});
c.waitTicks(10);c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new net.minecraft.world.phys.Vec3(1.5,1.5,1.5)));c.waitTicks(4);c.takeScreenshot("gems126-gallery");
Clay126Checks.exercise(c,world.getServer());
Discovery126Checks.exercise(c,world.getServer());
}
intro.set(true);net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;var found=new HashMap<BlockPos,BlockState>();
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Gems126 natural "+UUID.randomUUID());s.setSeed("126");s.setAllowCommands(true);s.setGenerateStructures(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());}).create()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&ProgressionClient.active()&&!IntroSpawnFlash.active(),2400);
world.getServer().runOnServer(s->{var l=s.overworld();check(l.getChunkSource().getGenerator() instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator,"Actual Sanctuary generator");
for(int cx=-4;cx<=4;cx++)for(int cz=-4;cz<=4;cz++){var chunk=l.getChunk(cx,cz);var cursor=new BlockPos.MutableBlockPos();for(int x=0;x<16;x++)for(int z=0;z<16;z++)for(int y=0;y<=256;y++){cursor.set(cx*16+x,y,cz*16+z);var state=chunk.getBlockState(cursor);if(state.is(Gems.RUBY.ore())||state.is(Gems.RUBY.deepslateOre())||state.is(Gems.SAPPHIRE.ore())||state.is(Gems.SAPPHIRE.deepslateOre()))found.put(cursor.immutable(),state);}}
for(var f:Gems.FAMILIES){long ordinary=found.values().stream().filter(b->b.is(f.ore())).count(),deep=found.values().stream().filter(b->b.is(f.deepslateOre())).count();System.out.println("GEMS126_NATURAL "+f.id()+" stone="+ordinary+" deepslate="+deep+" seed=126 chunks=81");check(ordinary+deep>0,"Both gems generate naturally without structure placement: "+f.id());}
var biome=l.getBiome(found.keySet().iterator().next()).value();var features=biome.getGenerationSettings().features().get(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal());for(var f:Gems.FAMILIES)check(features.stream().anyMatch(h->h.is(ResourceKey.create(Registries.PLACED_FEATURE,SanctuaryMod.id("ore_"+f.id())))),"Native biome includes configurable gem placement");
Discovery126Checks.command(s.getPlayerList().getPlayers().getFirst(),"all");
var mined=found.keySet().iterator().next();l.setBlockAndUpdate(mined,Blocks.AIR.defaultBlockState());found.put(mined,Blocks.AIR.defaultBlockState());
});saved=world.getWorldSave();
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null,2400);world.getServer().runOnServer(s->{Discovery126Checks.all(s.getPlayerList().getPlayers().getFirst());for(var e:found.entrySet())check(s.overworld().getBlockState(e.getKey())==e.getValue(),"Generated ores persist on reload without repopulation");});
}
System.out.println("GEMS126_PASS registry, recipes, drops, creative order, native models, natural Sanctuary generation seed 126 and save/reconnect");
}
private static void order(ResourceKey<CreativeModeTab> tab,Item anchor,Item ruby,Item sapphire){var items=new ArrayList<>(BuiltInRegistries.CREATIVE_MODE_TAB.getOrThrow(tab).value().getDisplayItems());int i=0;while(i<items.size()&&!items.get(i).is(anchor))i++;check(i+2<items.size()&&items.get(i+1).is(ruby)&&items.get(i+2).is(sapphire),"Emerald/ruby/sapphire order in "+tab);check(items.stream().filter(v->v.is(ruby)||v.is(sapphire)).count()==2,"No duplicate creative entries");}
private static void drop(List<ItemStack> drops,Item item,int n){check(drops.size()==1&&drops.getFirst().is(item)&&drops.getFirst().getCount()==n,"Native loot/craft expected "+n+" "+item+", got "+drops);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -41,7 +41,7 @@ final class Sculpture113ClientChecks {
check(Statuary.BLOCK.getStateForPlacement(context).getValue(Statuary.ANCHOR)==SculptureAnchor.of(edge.getOpposite()),"Side hit anchors against supporting block");
}
}
for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:SculptureAnchor.values()){
for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:List.of(SculptureAnchor.CENTER,SculptureAnchor.NORTH,SculptureAnchor.SOUTH,SculptureAnchor.EAST,SculptureAnchor.WEST)){
var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor);
for(var rotation:Rotation.values()){
var actual=state.rotate(rotation);
@@ -74,7 +74,7 @@ final class Sculpture113ClientChecks {
}
var itemMesh=new StatuaryRenderer.ItemRenderer().extractArgument(StatuaryEntity.item(sculpture));
check(itemMesh.equals(mesh(entity(client,sculpture,Direction.SOUTH,SculptureAnchor.CENTER))),"Item preview shares exact centered grid geometry");
System.out.println("SCULPTURE113_GEOMETRY_PASS 5120 footprints/orientations/anchors and colored direction markers");
System.out.println("SCULPTURE113_GEOMETRY_PASS 9216 footprints/orientations/anchors and colored direction markers");
}
private static StatuaryEntity entity(Minecraft client,VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor){
var entity=new StatuaryEntity(BlockPos.ZERO,Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor));entity.setLevel(client.level);
@@ -94,10 +94,12 @@ final class Sculpture113ClientChecks {
var anchor=entity.getBlockState().getValue(Statuary.ANCHOR);
switch(anchor){
case NORTH -> check(minZ==0,"North edge flush");case SOUTH -> check(maxZ==1,"South edge flush");
case WEST -> check(minX==0,"West edge flush");case EAST -> check(maxX==1,"East edge flush");default -> {}
case WEST -> check(minX==0,"West edge flush");case EAST -> check(maxX==1,"East edge flush");
case NORTH_WEST -> check(minX==0&&minZ==0,"North-west corner flush");case NORTH_EAST -> check(maxX==1&&minZ==0,"North-east corner flush");
case SOUTH_WEST -> check(minX==0&&maxZ==1,"South-west corner flush");case SOUTH_EAST -> check(maxX==1&&maxZ==1,"South-east corner flush");default -> {}
}
if(anchor!=SculptureAnchor.WEST&&anchor!=SculptureAnchor.EAST)check(Math.abs(minX-(1-maxX))<=1/16f,"Other axis centered to nearest whole voxel");
if(anchor!=SculptureAnchor.NORTH&&anchor!=SculptureAnchor.SOUTH)check(Math.abs(minZ-(1-maxZ))<=1/16f,"Other axis centered to nearest whole voxel");
if(anchor==SculptureAnchor.CENTER||anchor==SculptureAnchor.NORTH||anchor==SculptureAnchor.SOUTH)check(Math.abs(minX-(1-maxX))<=1/16f,"Other axis centered to nearest whole voxel");
if(anchor==SculptureAnchor.CENTER||anchor==SculptureAnchor.EAST||anchor==SculptureAnchor.WEST)check(Math.abs(minZ-(1-maxZ))<=1/16f,"Other axis centered to nearest whole voxel");
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -48,7 +48,7 @@ public final class Sculpture116ClientChecks implements FabricClientGameTest {
var rendered=new HashSet<VoxelSculpture>();
for(var item:List.of(Items.STONE,Items.OAK_PLANKS,Items.GOLD_BLOCK,Items.WOOL.pick(DyeColor.RED),Items.GLASS,Items.OAK_LEAVES,Items.CLAY,Items.DEEPSLATE)){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();var menu=(ClayWorkshopMenu)p.containerMenu;menu.getSlot(0).set(new ItemStack(item,2));check(menu.getSlot(1).getItem().isEmpty(),"Previous material result invalidates immediately");menu.broadcastChanges();});
var expected=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(item)).apply(source));rendered.add(expected);
var expected=c.computeOnClient(m->ClayWorkshopMenu.appearance(source,item,SculptureMaterialSampler.sample(new ItemStack(item))));rendered.add(expected);
c.waitFor(m->expected.equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())),180);
c.runOnClient(m->check(ItemStack.matches(((ClayWorkshopScreen)m.gui.screen()).preview(),m.player.containerMenu.getSlot(1).getItem()),"Preview equals server-owned result"));
check(StatuaryEntity.decode(StatuaryEntity.encode(expected)).equals(expected),"Palette colors persist in unchanged schema 1");
@@ -135,8 +135,8 @@ public final class Sculpture122ClientChecks implements FabricClientGameTest {
var padded=new VoxelSculpture(MODEL,"Padded",13,5,15,List.of(new VoxelSculpture.Cell(2,2,3,-1),new VoxelSculpture.Cell(6,4,10,-1)));
var dense=new ArrayList<VoxelSculpture.Cell>();for(int x=0;x<16;x++)for(int y=0;y<16;y++)for(int z=0;z<16;z++)dense.add(new VoxelSculpture.Cell(x,y,z,-1));
var models=List.of(ARCH,padded,new VoxelSculpture(MODEL,"Dense",16,16,16,dense),new VoxelSculpture(MODEL,"One voxel",1,1,1,List.of(new VoxelSculpture.Cell(0,0,0,-1))));
int cases=0;for(var model:models)for(var direction:Direction.Plane.HORIZONTAL)for(var anchor:SculptureAnchor.values()){
var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,direction).setValue(Statuary.ANCHOR,anchor);var entity=new StatuaryEntity(BlockPos.ZERO,state);entity.setLevel(m.level);load(entity,model,m.level);
int cases=0;for(var model:models)for(var direction:Direction.Plane.HORIZONTAL)for(var anchor:SculptureAnchor.values())for(var height:SculptureHeight.values()){
var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,direction).setValue(Statuary.ANCHOR,anchor).setValue(Statuary.HEIGHT,height);var entity=new StatuaryEntity(BlockPos.ZERO,state);entity.setLevel(m.level);load(entity,model,m.level);
var shape=entity.shape(state);check(shape==entity.shape(state),"Repeated geometry queries reuse the same shape");var boxes=shape.toAabbs();double volume=boxes.stream().mapToDouble(b->b.getXsize()*b.getYsize()*b.getZsize()).sum();check(boxes.size()==1&&volume>=model.cells().size()/4096d,"One box encloses all occupied cells");
var outline=entity.shape(state);check(outline==entity.shape(state),"Repeated outline queries reuse the cached box");
check(outline.toAabbs().size()==1&&outline.bounds().equals(shape.bounds()),"One outline box matches exact rendered extents for every orientation and anchor");
@@ -149,7 +149,7 @@ public final class Sculpture122ClientChecks implements FabricClientGameTest {
cases++;
}
System.out.println("SCULPTURE124_BOUNDS_PASS "+cases+" cached single boxes agree with exact renderer extents; 12 outline edges each");
System.out.println("SCULPTURE122_GEOMETRY_PASS "+cases+" native bounding boxes agree with emitted mesh extents, plus cached 12-edge selection bounds for holes, padding, dense 4096 cells, single voxel, 4 directions and 5 anchors");
System.out.println("SCULPTURE122_GEOMETRY_PASS "+cases+" native bounding boxes agree with emitted mesh extents, plus cached 12-edge selection bounds for holes, padding, dense 4096 cells, single voxel, 4 directions, 9 anchors and 4 vertical modes");
}
private static void place(ServerLevel level,BlockPos pos,VoxelSculpture model,Direction facing,SculptureAnchor anchor){
var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor);level.setBlockAndUpdate(pos,state);var entity=(StatuaryEntity)level.getBlockEntity(pos);load(entity,model,level);entity.setChanged();level.sendBlockUpdated(pos,state,state,3);
@@ -0,0 +1,118 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.nbt.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.*;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.TagValueInput;
import net.minecraft.world.phys.*;
/** Native clicks on all six support faces; additive saved-state compatibility and real renderer bounds. */
public final class Sculpture125ClientChecks implements FabricClientGameTest {
private static final BlockPos SUPPORT=new BlockPos(0,4,0);
private static final String MODEL="sanctuary:model/"+"2".repeat(64);
private static final VoxelSculpture STATUE=statue();
private static final SculptureAnchor[][] ANCHORS={{SculptureAnchor.NORTH_WEST,SculptureAnchor.WEST,SculptureAnchor.SOUTH_WEST},{SculptureAnchor.NORTH,SculptureAnchor.CENTER,SculptureAnchor.SOUTH},{SculptureAnchor.NORTH_EAST,SculptureAnchor.EAST,SculptureAnchor.SOUTH_EAST}};
private static final SculptureHeight[] HEIGHTS={SculptureHeight.BOTTOM,SculptureHeight.CENTER,SculptureHeight.TOP};
private record Spot(Direction face,int u,int v){
Vec3 hit(BlockPos p){double a=.1+.4*u,b=.1+.4*v;return switch(face){
case UP,DOWN->new Vec3(p.getX()+a,p.getY()+(face==Direction.UP?1:0),p.getZ()+b);
case NORTH,SOUTH->new Vec3(p.getX()+a,p.getY()+b,p.getZ()+(face==Direction.SOUTH?1:0));
case WEST,EAST->new Vec3(p.getX()+(face==Direction.EAST?1:0),p.getY()+b,p.getZ()+a);
};}
SculptureAnchor anchor(){return switch(face){case UP,DOWN->ANCHORS[u][v];case NORTH->ANCHORS[u][2];case SOUTH->ANCHORS[u][0];case EAST->ANCHORS[0][u];case WEST->ANCHORS[2][u];};}
SculptureHeight height(){return face==Direction.UP?SculptureHeight.BOTTOM:face==Direction.DOWN?SculptureHeight.TOP:HEIGHTS[v];}
}
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);var enter=new KeyEvent(InputConstants.KEY_RETURN,0,0);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter);intro.set(false);
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
var persistent=new HashMap<BlockPos,BlockState>();
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sculpture125 "+UUID.randomUUID());s.setSeed("125");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=player(s);var l=p.level();for(int x=-8;x<=8;x++)for(int z=-8;z<=8;z++)l.setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.getAbilities().flying=true;p.onUpdateAbilities();p.getInventory().setItem(p.getInventory().getSelectedSlot(),StatuaryEntity.item(STATUE));p.inventoryMenu.broadcastFullState();
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"weather clear");contexts(p);legacy(p);Sculpture113ClientChecks.placementContexts(p);l.setBlockAndUpdate(SUPPORT,Blocks.STONE.defaultBlockState());
});
c.waitFor(m->m.player.getMainHandItem().is(Statuary.ITEM)&&m.level.getBlockState(SUPPORT).is(Blocks.STONE),150);
int clicks=0;
for(var face:Direction.values()){
Vec3 eye= switch(face){case UP->new Vec3(.5,6,3);case DOWN->new Vec3(.5,1.3,2.5);case NORTH->new Vec3(.5,3,-2.2);case SOUTH->new Vec3(.5,3,3.2);case EAST->new Vec3(3.2,3,.5);case WEST->new Vec3(-2.2,3,.5);};
server.runOnServer(s->{var p=player(s);p.teleportTo(eye.x,eye.y,eye.z);p.setDeltaMovement(Vec3.ZERO);p.getAbilities().flying=true;p.onUpdateAbilities();});c.waitFor(m->m.player.distanceToSqr(eye)<.05,100);c.runOnClient(m->{m.player.getAbilities().flying=true;m.player.setDeltaMovement(Vec3.ZERO);m.player.onUpdateAbilities();});c.waitTicks(10);
for(int u=0;u<3;u++)for(int v=0;v<3;v++){
var spot=new Spot(face,u,v);var target=SUPPORT.relative(face);
server.runOnServer(s->{for(var direction:Direction.values())player(s).level().setBlockAndUpdate(SUPPORT.relative(direction),Blocks.AIR.defaultBlockState());});
c.waitFor(m->m.level.getBlockState(target).isAir(),100);
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,spot.hit(SUPPORT)));c.waitTicks(3);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult h&&h.getBlockPos().equals(SUPPORT)&&h.getDirection()==face,"Native cursor hits requested support face "+spot+" instead of "+m.hitResult+" at "+m.hitResult.getLocation()+" eye "+m.player.getEyePosition()+" flying "+m.player.getAbilities().flying));
var facing=c.computeOnClient(m->{System.out.println("SCULPTURE125_AIM "+spot+" actual "+m.hitResult.getLocation()+" eye "+m.player.getEyePosition());return m.player.getDirection();});c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);c.waitTicks(3);
c.waitFor(m->m.level.getBlockEntity(target) instanceof StatuaryEntity e&&STATUE.equals(e.sculpture()),100);
server.runOnServer(s->{var l=player(s).level();var state=l.getBlockState(target);check(state.getValue(Statuary.ANCHOR)==spot.anchor()&&state.getValue(Statuary.HEIGHT)==spot.height()&&state.getValue(Statuary.FACING)==facing,"Server uses aimed position and horizontal gaze for "+spot+" got "+state+" expected facing "+facing);});
c.runOnClient(m->{var e=(StatuaryEntity)m.level.getBlockEntity(target);renderBounds(e);});
if(u==0&&v==2)c.takeScreenshot("sculpture125-"+face.getSerializedName());clicks++;
}
}
System.out.println("SCULPTURE125_CLICKS_PASS "+clicks+" real placements: all six faces, every corner/edge/center and supported heights");
c.runOnClient(m->Sculpture125ParticleChecks.exercise(m,SUPPORT.relative(Direction.EAST)));
Sculpture125WorkshopChecks.exercise(c,server);
server.runOnServer(s->{var l=player(s).level();for(var direction:Direction.values())l.setBlockAndUpdate(SUPPORT.relative(direction),Blocks.AIR.defaultBlockState());for(int x=0;x<3;x++)for(int y=0;y<3;y++){
var pos=new BlockPos(x*2-2,y+1,0);l.setBlockAndUpdate(pos.north(),Blocks.STONE.defaultBlockState());var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,Direction.SOUTH).setValue(Statuary.ANCHOR,ANCHORS[x][0]).setValue(Statuary.HEIGHT,HEIGHTS[y]);place(l,pos,state);persistent.put(pos,state);
}player(s).teleportTo(.5,2,5);});
c.waitFor(m->m.player.distanceToSqr(.5,2,5)<.05,100);c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(.5,2.5,.5)));c.waitTicks(8);c.takeScreenshot("sculpture125-wall-placements");saved=world.getWorldSave();
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);world.getServer().runOnServer(s->{for(var entry:persistent.entrySet()){var state=player(s).level().getBlockState(entry.getKey());check(state==entry.getValue(),"New anchors and heights persist exactly");check(STATUE.equals(((StatuaryEntity)player(s).level().getBlockEntity(entry.getKey())).sculpture()),"Model data persists unchanged");}});
c.waitFor(m->persistent.keySet().stream().allMatch(p->m.level.getBlockEntity(p) instanceof StatuaryEntity e&&STATUE.equals(e.sculpture())),150);c.runOnClient(m->persistent.keySet().forEach(p->renderBounds((StatuaryEntity)m.level.getBlockEntity(p))));
}
new Sculpture122ClientChecks().runTest(c);
System.out.println("SCULPTURE125_PASS new placement gestures, native picking, collision/render agreement, legacy states and saved new positions");
}
private static void contexts(ServerPlayer p){int count=0;
for(var pos:List.of(new BlockPos(5,3,5),new BlockPos(-5,3,-5))){p.level().setBlockAndUpdate(pos,Blocks.STONE.defaultBlockState());
for(int turn=0;turn<4;turn++){p.setYRot(turn*90);for(var face:Direction.values())for(int u=0;u<3;u++)for(int v=0;v<3;v++){
var spot=new Spot(face,u,v);var context=new BlockPlaceContext(p,InteractionHand.MAIN_HAND,StatuaryEntity.item(STATUE),new BlockHitResult(spot.hit(pos),face,pos,false));var state=Statuary.BLOCK.getStateForPlacement(context);
check(context.getClickedPos().equals(pos.relative(face))&&state.getValue(Statuary.ANCHOR)==spot.anchor()&&state.getValue(Statuary.HEIGHT)==spot.height()&&state.getValue(Statuary.FACING)==p.getDirection(),"Native placement context agrees at signed coordinates: "+spot);count++;
}}p.level().setBlockAndUpdate(pos,Blocks.AIR.defaultBlockState());
}
check(Statuary.BLOCK.getStateDefinition().getPossibleStates().size()==144,"108 placement states plus 36 legacy-height states");
for(var anchor:SculptureAnchor.values())for(var height:SculptureHeight.values()){
var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.ANCHOR,anchor).setValue(Statuary.HEIGHT,height);var box=SculptureGeometry.shape(STATUE,Direction.SOUTH,anchor,height).bounds();
for(var rotation:Rotation.values()){var rotated=state.rotate(rotation);check(rotated.getValue(Statuary.HEIGHT)==height,"Yaw rotation retains height");var b=SculptureGeometry.shape(STATUE,rotated.getValue(Statuary.FACING),rotated.getValue(Statuary.ANCHOR),height).bounds();double ex=box.getCenter().x-.5,ez=box.getCenter().z-.5;double tx=switch(rotation){case NONE->ex;case CLOCKWISE_90->-ez;case CLOCKWISE_180->-ex;case COUNTERCLOCKWISE_90->ez;};double tz=switch(rotation){case NONE->ez;case CLOCKWISE_90->ex;case CLOCKWISE_180->-ez;case COUNTERCLOCKWISE_90->-ex;};check(Math.abs((b.getCenter().x-.5)-tx)<=1/16d&&Math.abs((b.getCenter().z-.5)-tz)<=1/16d&&b.minY==box.minY,"Rotated anchor follows native structure rotation without half voxels");}
for(var mirror:Mirror.values()){var mirrored=state.mirror(mirror);var b=SculptureGeometry.shape(STATUE,mirrored.getValue(Statuary.FACING),mirrored.getValue(Statuary.ANCHOR),height).bounds();double x=mirror==Mirror.FRONT_BACK?1-box.getCenter().x:box.getCenter().x,z=mirror==Mirror.LEFT_RIGHT?1-box.getCenter().z:box.getCenter().z;check(Math.abs(b.getCenter().x-x)<=1/16d&&Math.abs(b.getCenter().z-z)<=1/16d&&b.minY==box.minY,"Mirrored corner and vertical placement follow native structure mirror");}
}
System.out.println("SCULPTURE125_CONTEXT_PASS "+count+" contexts, signed coordinates, four headings, rotations/mirrors and 144 valid states");
}
private static void legacy(ServerPlayer p){int n=0;
for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:List.of(SculptureAnchor.CENTER,SculptureAnchor.NORTH,SculptureAnchor.SOUTH,SculptureAnchor.WEST,SculptureAnchor.EAST)){
var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor).setValue(Statuary.HEIGHT,SculptureHeight.TOP);var nbt=NbtUtils.writeBlockState(state);nbt.getCompoundOrEmpty("properties").remove("vertical_anchor");var restored=NbtUtils.readBlockState(BuiltInRegistries.BLOCK,nbt);
check(restored.getValue(Statuary.HEIGHT)==SculptureHeight.ORIGINAL&&restored.getValue(Statuary.ANCHOR)==anchor&&restored.getValue(Statuary.FACING)==facing,"Old NBT keeps orientation and anchor and defaults original Y");var b=SculptureGeometry.shape(STATUE,facing,anchor,restored.getValue(Statuary.HEIGHT)).bounds();check(b.minY==2/16d&&b.maxY==10/16d,"Legacy vertical padding is preserved exactly");n++;
}System.out.println("SCULPTURE125_LEGACY_PASS "+n+" old blockstates retain exact placements without rewriting payloads");
}
private static void renderBounds(StatuaryEntity entity){var renderer=new StatuaryRenderer();var render=renderer.createRenderState();renderer.extractRenderState(entity,render,0,Vec3.ZERO,null);double x0=1,y0=1,z0=1,x1=0,y1=0,z1=0;for(var q:render.mesh){x0=Math.min(x0,q.x());y0=Math.min(y0,q.y());z0=Math.min(z0,q.z());x1=Math.max(x1,q.x()+1/16d);y1=Math.max(y1,q.y()+1/16d);z1=Math.max(z1,q.z()+1/16d);}var bounds=entity.shape(entity.getBlockState()).bounds();check(bounds.equals(new AABB(x0,y0,z0,x1,y1,z1)),"Native single box follows actual rendered offsets on all three axes");Sculpture113ClientChecks.rendered(entity);}
private static void place(ServerLevel level,BlockPos pos,BlockState state){level.setBlockAndUpdate(pos,state);var e=(StatuaryEntity)level.getBlockEntity(pos);var data=new CompoundTag();data.put("sculpture",StatuaryEntity.encode(STATUE));e.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING,level.registryAccess(),data));e.setChanged();level.sendBlockUpdated(pos,state,state,3);}
private static VoxelSculpture statue(){var cells=new ArrayList<VoxelSculpture.Cell>();for(int y=2;y<10;y++)for(int x=y<6?2:1;x<=(y<6?4:5);x++)for(int z=y<6?1:0;z<=(y<6?3:4);z++)cells.add(new VoxelSculpture.Cell(x,y,z,y<6?0xff44aabb:0xffddb88b));return new VoxelSculpture(MODEL,"Placement statue",7,10,5,cells);}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static void check(boolean ok,String reason){if(!ok)throw new AssertionError(reason);}
}
@@ -0,0 +1,36 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.plans.*;
import java.lang.reflect.Field;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.particle.*;
import net.minecraft.core.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
final class Sculpture125ParticleChecks {
static void exercise(Minecraft mc,BlockPos pos){
var entity=(StatuaryEntity)mc.level.getBlockEntity(pos);var state=entity.getBlockState();
var render=new StatuaryRenderer();var rs=render.createRenderState();render.extractRenderState(entity,rs,0,Vec3.ZERO,null);
Set<Integer> colors=new HashSet<>();for(var q:rs.mesh)colors.add(q.color());
var queue=queue(mc);queue.clear();var q=rs.mesh.getLast();var hit=Vec3.atLowerCornerOf(pos).add(SculptureParticles.center(q));
mc.hitResult=new BlockHitResult(hit,Direction.UP,pos,false);mc.level.addBreakingBlockEffects(pos,Direction.UP,false);
check(queue.size()==1&&queue.peek() instanceof SculptureParticles.Fragment,"Native hit emits one voxel chip, no clay debris");
var chip=(SingleQuadParticle)queue.peek();check(color(chip)==q.color(),"Hit chip uses the exact targeted voxel color");
queue.clear();mc.level.addDestroyBlockEffect(pos,state);verify(queue,rs.mesh,colors,pos);int count=queue.size();
queue.clear();mc.level.setBlock(pos,Blocks.AIR.defaultBlockState(),3);check(mc.level.getBlockEntity(pos)==null,"Native block update removes sculpture before break effect");
mc.level.addDestroyBlockEffect(pos,state);verify(queue,rs.mesh,colors,pos);check(queue.size()==count,"Late break event retains voxel colors and offsets");
queue.clear();mc.level.addDestroyBlockEffect(pos,Blocks.STONE.defaultBlockState());check(!queue.isEmpty()&&queue.stream().noneMatch(SculptureParticles.Fragment.class::isInstance),"Other blocks retain native particles");queue.clear();
System.out.println("SCULPTURE125_PARTICLES_PASS native hit/destroy hooks, exact voxel colors, real surface origins, 64-chip bound, late removal and ordinary block regression");
}
private static void verify(Queue<Particle> queue,List<StatuaryRenderer.Quad> mesh,Set<Integer> colors,BlockPos pos){
check(!queue.isEmpty()&&queue.size()<=64,"Bounded destruction particles");var observed=new HashSet<Integer>();
for(var p:queue){check(p instanceof SculptureParticles.Fragment,"No clay fallback particles");int color=color((SingleQuadParticle)p);observed.add(color);check(colors.contains(color),"Particle retains an actual model color");var point=new Vec3(number(p,"x"),number(p,"y"),number(p,"z")).subtract(Vec3.atLowerCornerOf(pos));check(mesh.stream().anyMatch(q->q.color()==color&&SculptureParticles.center(q).equals(point)),"Particle belongs to an actual colored voxel face after rotation/anchor");check(((SingleQuadParticle)p).getQuadSize(0)==1/32f,"One voxel diameter, no oversized clay chips");}
check(observed.size()==colors.size(),"Both fixture material colors occur in destruction chips");
}
@SuppressWarnings("unchecked") private static Queue<Particle> queue(Minecraft mc){try{var f=ParticleEngine.class.getDeclaredField("particlesToAdd");f.setAccessible(true);return (Queue<Particle>)f.get(mc.particleEngine);}catch(Exception e){throw new AssertionError(e);}}
private static double number(Object value,String name){try{Class<?> type=value.getClass();Field f=null;while(type!=null){try{f=type.getDeclaredField(name);break;}catch(NoSuchFieldException ignored){type=type.getSuperclass();}}f.setAccessible(true);return ((Number)f.get(value)).doubleValue();}catch(Exception e){throw new AssertionError(e);}}
private static int color(SingleQuadParticle p){return 0xff000000|Math.round((float)number(p,"rCol")*255)<<16|Math.round((float)number(p,"gCol")*255)<<8|Math.round((float)number(p,"bCol")*255);}
private static void check(boolean ok,String reason){if(!ok)throw new AssertionError(reason);}
}
@@ -0,0 +1,53 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.companion.CompanionService;
import fr.koka.sanctuary.plans.*;
import java.nio.file.*;
import java.util.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.stats.Stats;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.*;
import net.minecraft.world.level.GameType;
final class Sculpture125WorkshopChecks {
static void exercise(ClientGameTestContext c,TestServerContext server){
var pos=new BlockPos(4,1,4);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(3,1,3);p.setGameMode(GameType.SURVIVAL);p.getAbilities().invulnerable=true;p.onUpdateAbilities();p.level().setBlockAndUpdate(pos,Statuary.WORKSHOP.defaultBlockState());
CompanionService.save(p,CompanionService.record(p).note("seen_mobs",""));Statuary.open(p,pos);
});
c.waitForScreen(ClayWorkshopScreen.class);
c.runOnClient(m->{var menu=(ClayWorkshopMenu)m.player.containerMenu;check(!menu.view.statues().contains("minecraft:cow"),"Unseen cow is absent without visiting Metabli");var screen=(ClayWorkshopScreen)m.gui.screen();screen.load(EntityTypes.COW);check(screen.selected()==null&&!screen.busy(),"Unseen animal cannot start local generation");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var menu=(ClayWorkshopMenu)p.containerMenu;menu.getSlot(0).set(new ItemStack(Items.OAK_PLANKS,2));var locked=new VoxelSculpture(StatueDiscoveries.modelId("minecraft:cow"),"Cow",1,1,1,List.of(new VoxelSculpture.Cell(0,0,0,-1)));menu.select(p,locked,Items.OAK_PLANKS,new SculpturePalette(List.of(-1)));check(menu.getSlot(1).getItem().isEmpty(),"Server rejects a locked animal model despite valid material");p.closeContainer();
CompanionService.save(p,CompanionService.record(p).note("seen_mobs","cow"));p.awardStat(Stats.ENTITY_KILLED.get(EntityTypes.SHEEP));p.awardStat(Stats.ENTITY_KILLED_BY.get(EntityTypes.PIG));
});
c.waitFor(m->m.gui.screen()==null,100);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();Statuary.open(p,pos);var menu=(ClayWorkshopMenu)p.containerMenu;check(menu.view.statues().equals(StatueDiscoveries.known(p)),"Workshop uses the exact statue catalogue discoveries");menu.getSlot(0).set(new ItemStack(Items.OAK_PLANKS,4));menu.broadcastChanges();});
c.waitForScreen(ClayWorkshopScreen.class);
c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();var menu=(ClayWorkshopMenu)m.player.containerMenu;check(menu.view.statues().containsAll(List.of("minecraft:cow","minecraft:sheep","minecraft:pig")),"Encounter, kill and killed-by unlocks are transmitted");});
for(var type:List.of(EntityTypes.COW,EntityTypes.SHEEP,EntityTypes.PIG)){
String model=StatueDiscoveries.modelId(BuiltInRegistries.ENTITY_TYPE.getKey(type).toString());
c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(type.getDescription().getString())).findFirst().orElseThrow().onPress(new KeyEvent(257,0,0));});
c.waitTicks(10);c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();System.out.println("SCULPTURE125_WORKSHOP_DEBUG "+type+" busy "+screen.busy()+" source "+(screen.selected()==null?"null":screen.selected().modelId())+" input "+m.player.containerMenu.getSlot(0).getItem()+" output "+m.player.containerMenu.getSlot(1).getItem());});
c.waitFor(m->{var screen=(ClayWorkshopScreen)m.gui.screen();return !screen.busy()&&screen.selected()!=null&&screen.selected().modelId().equals(model)&&m.player.containerMenu.getSlot(1).getItem().is(Statuary.ITEM)&&StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem()).modelId().equals(model);},250);
c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();var source=screen.selected();check(source.cells().size()>20&&Math.max(source.width(),Math.max(source.height(),source.depth()))==16,"Native animal model voxelized at miniature scale");check(ItemStack.matches(screen.preview(),m.player.containerMenu.getSlot(1).getItem()),"Animal preview equals server-paid palette result");});
c.takeScreenshot("sculpture125-workshop-"+BuiltInRegistries.ENTITY_TYPE.getKey(type).getPath());
}
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.PICKUP,m.player));
c.waitFor(m->m.player.containerMenu.getCarried().is(Statuary.ITEM)&&m.player.containerMenu.getSlot(0).getItem().getCount()==3,100);
server.waitFor(s->{var menu=s.getPlayerList().getPlayers().getFirst().containerMenu;return menu.getCarried().is(Statuary.ITEM)&&menu.getSlot(0).getItem().getCount()==3;},100);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(p.containerMenu.getSlot(0).getItem().getCount()==3&&p.containerMenu.getCarried().is(Statuary.ITEM),"Exactly one block consumed for native animal sculpture in survival");});
Path file=PlanClient.directory().resolve("sculpture125-"+UUID.randomUUID()+".glb");
try{Files.createDirectories(file.getParent());Files.write(file,Statuary106ClientChecks.fixture(true));c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(ClayWorkshopScreen.text("imports").getString())).findFirst().orElseThrow().onPress(new KeyEvent(257,0,0));screen.load(file);});
var id=GlbModel.load(file).id();c.waitFor(m->{var screen=(ClayWorkshopScreen)m.gui.screen();return !screen.busy()&&screen.selected()!=null&&screen.selected().modelId().equals(id)&&id.equals(Optional.ofNullable(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())).map(VoxelSculpture::modelId).orElse(""));},250);c.takeScreenshot("sculpture125-workshop-import");
}catch(Exception e){throw new AssertionError(e);}finally{try{Files.deleteIfExists(file);}catch(Exception ignored){}}
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.closeContainer();p.setGameMode(GameType.CREATIVE);p.getAbilities().flying=true;p.onUpdateAbilities();});c.waitFor(m->m.gui.screen()==null,100);
System.out.println("SCULPTURE125_WORKSHOP_PASS encounter/kill/death unlocks, native menu sync, locked server rejection, three textured animals, paid survival craft and GLB import");
}
private static void check(boolean ok,String reason){if(!ok)throw new AssertionError(reason);}
}
@@ -62,7 +62,7 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Statuary106 "+UUID.randomUUID());s.setSeed("106");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
saved=world.getWorldSave();c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var source=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(Items.CLAY)).apply(originalSource));
var source=originalSource;
var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.teleportTo(.5,1,.5);p.getInventory().clearContent();p.setGameMode(GameType.SURVIVAL);for(int x=-7;x<=9;x++)for(int z=-5;z<=9;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.level().setBlockAndUpdate(WORKSHOP,Statuary.WORKSHOP.defaultBlockState());
var legacy=net.minecraft.nbt.NbtUtils.writeBlockState(Statuary.BLOCK.defaultBlockState());legacy.remove("properties");
@@ -44,6 +44,7 @@ public final class SanctuaryMod implements ModInitializer {
@Override
public void onInitialize() {
fr.koka.sanctuary.building.ColoredBricks.register();
fr.koka.sanctuary.gems.Gems.register();
fr.koka.sanctuary.sky.VortexClouds.register();
fr.koka.sanctuary.loading.PreloadProgress.register();
fr.koka.sanctuary.progression.SanctuaryGameplay.register();
@@ -14,6 +14,7 @@ import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** File selection is local; the two ordinary inventory slots and every crafted item are server-owned. */
public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorkshopMenu> {
@@ -21,6 +22,8 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
private static final Identifier SLOT=Identifier.withDefaultNamespace("container/slot");
private static final Identifier SELECTION=fr.koka.sanctuary.SanctuaryMod.id("hud/hotbar_selection");
private static final ExecutorService WORKER=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"Sanctuary clay models");t.setDaemon(true);return t;});
private boolean imported;
private List<net.minecraft.world.entity.EntityType<?>> animals=List.of();
private List<Path> files=List.of();private int page,generation,ticks;private boolean busy,pending;
private VoxelSculpture source,previewSource;private ItemStack preview=ItemStack.EMPTY,lastMaterial=ItemStack.EMPTY;
private SculpturePalette palette;private Object sampledResources;
@@ -38,12 +41,18 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
@Override protected void init(){
super.init();
try{files=CreationLibrary.files(CreationLibrary.Kind.MODEL);}catch(Exception e){status=ModelImportErrors.message(e);}
page=Math.min(page,Math.max(0,(files.size()-1)/4));
for(int i=0;i<4;i++){int at=page*4+i;if(at>=files.size())break;var file=files.get(at);
var label=Component.literal(file.getFileName().toString());var b=addRenderableWidget(Button.builder(label,p->load(file)).bounds(leftPos+10,topPos+32+i*18,128,17).build());b.setTooltip(Tooltip.create(label));}
animals=menu.view.statues().stream().map(net.minecraft.resources.Identifier::tryParse).filter(Objects::nonNull)
.<net.minecraft.world.entity.EntityType<?>>map(id->net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getValue(id)).filter(Objects::nonNull)
.sorted(Comparator.comparing(t->t.getDescription().getString())).toList();
int count=imported?files.size():animals.size();page=Math.min(page,Math.max(0,(count-1)/4));
addRenderableWidget(Button.builder(text("animals"),b->{imported=false;page=0;rebuildWidgets();}).bounds(leftPos+10,topPos+19,62,16).build()).active=imported;
addRenderableWidget(Button.builder(text("imports"),b->{imported=true;page=0;rebuildWidgets();}).bounds(leftPos+74,topPos+19,64,16).build()).active=!imported;
for(int i=0;i<4;i++){int at=page*4+i;if(at>=count)break;
var label=imported?Component.literal(files.get(at).getFileName().toString()):animals.get(at).getDescription();
var b=addRenderableWidget(Button.builder(label,p->{if(imported)load(files.get(at));else load(animals.get(at));}).bounds(leftPos+10,topPos+36+i*17,128,17).build());b.setTooltip(Tooltip.create(label));}
addRenderableWidget(Button.builder(Component.literal(""),b->{page--;rebuildWidgets();}).bounds(leftPos+10,topPos+106,22,18).build()).active=page>0;
addRenderableWidget(Button.builder(text("folder"),b->folder()).bounds(leftPos+35,topPos+106,78,18).build());
addRenderableWidget(Button.builder(Component.literal(""),b->{page++;rebuildWidgets();}).bounds(leftPos+116,topPos+106,22,18).build()).active=(page+1)*4<files.size();
addRenderableWidget(Button.builder(Component.literal(""),b->{page++;rebuildWidgets();}).bounds(leftPos+116,topPos+106,22,18).build()).active=(page+1)*4<count;
addRenderableWidget(Button.builder(text("refresh"),b->rebuildWidgets()).bounds(leftPos+10,topPos+127,128,18).build());
rotate=addRenderableWidget(Button.builder(text("rotate"),b->{if(source!=null){source=source.rotate();pending=true;}}).bounds(leftPos+148,topPos+127,152,18).build());rotate.active=source!=null&&!busy;
rotate.setTooltip(Tooltip.create(text("placement")));
@@ -58,6 +67,24 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
source=sculpture;pending=true;status=text("ready",sculpture.name());
}));
}
public void load(net.minecraft.world.entity.EntityType<?> type){
var id=net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getKey(type).toString();
if(busy||!menu.view.statues().contains(id))return;
try{
var entity=type.create(minecraft.level,net.minecraft.world.entity.EntitySpawnReason.MOB_SUMMONED);
if(entity==null)throw new IllegalArgumentException("unsupported_model");
entity.setId(Integer.MIN_VALUE+125);entity.setPos(minecraft.player.position());
// Native renderers and textures are read on the client thread; only voxelization runs in the worker.
var faces=StatueModels.capture(entity);String name=type.getDescription().getString();
busy=true;status=text("working");int token=++generation;
CompletableFuture.supplyAsync(()->VoxelSculpture.of(StatueDiscoveries.modelId(id),name,StatueVoxelizer.miniature(faces)),WORKER)
.whenComplete((sculpture,error)->minecraft.execute(()->{
if(token!=generation||minecraft.gui.screen()!=this||minecraft.player==null||minecraft.player.containerMenu!=menu)return;
busy=false;if(error!=null){fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Unable to sculpt creature {}",id,error);status=text("animal_error");return;}
source=sculpture;pending=true;status=text("ready",name);
}));
}catch(Exception e){fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Unable to capture creature {}",id,e);status=text("animal_error");}
}
@Override protected void containerTick(){
super.containerTick();rotate.active=source!=null&&!busy;
var material=menu.getSlot(0).getItem();var resources=minecraft.getModelManager().getBlockStateModelSet();
@@ -65,9 +92,9 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
if(previewSource!=source||changedMaterial){
previewSource=source;lastMaterial=material.copy();sampledResources=resources;
if(changedMaterial)palette=SculptureMaterialSampler.sample(material);
var colored=source==null?null:palette==null?source:palette.apply(source);
var colored=source==null?null:palette==null?source:ClayWorkshopMenu.appearance(source,material.getItem(),palette);
preview=colored==null?ItemStack.EMPTY:StatuaryEntity.item(colored);pending=true;
if(source!=null&&!busy)status=palette==null?text("choose_material"):text("material_ready",material.getHoverName());
if(source!=null&&!busy)status=palette==null?text("choose_material"):material.is(Items.CLAY)?text("clay_ready"):text("material_ready",material.getHoverName());
}
// Retry a selection that raced the input packet or server rate limit. The result is the
// acknowledgement; never predict consumption or manufacture an item on the client.
@@ -94,10 +121,10 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
g.text(font,Component.literal(""),leftPos+254,topPos+111,0xFF404040,false);
var detail=font.split(status,290);for(int i=0;i<Math.min(2,detail.size());i++)g.text(font,detail.get(i),leftPos+10,topPos+150+i*font.lineHeight,0xFF404040,false);
if(detail.size()>2&&mx>=leftPos+10&&mx<leftPos+300&&my>=topPos+148&&my<topPos+174)g.setTooltipForNextFrame(font,status,mx,my);
if(files.isEmpty())g.text(font,text("empty"),leftPos+10,topPos+32,0xFF404040,false);
if(imported?files.isEmpty():animals.isEmpty()){var lines=font.split(text(imported?"empty":"no_animals"),128);for(int i=0;i<Math.min(6,lines.size());i++)g.text(font,lines.get(i),leftPos+10,topPos+38+i*font.lineHeight,0xFF404040,false);}
}
@Override protected void extractLabels(GuiGraphicsExtractor g,int mx,int my){
g.text(font,title,10,8,0xFF303030,false);g.text(font,text("models"),10,22,0xFF404040,false);
g.text(font,title,10,8,0xFF303030,false);
g.text(font,text("input"),230-font.width(text("input"))/2,95,0xFF303030,false);g.text(font,text("output"),imageWidth-10-font.width(text("output")),95,0xFF303030,false);
}
ItemStack preview(){return preview;}
@@ -0,0 +1,70 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.particle.SingleQuadParticle;
import net.minecraft.core.*;
import net.minecraft.data.AtlasIds;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.*;
/** Chips use the already-colored surface voxels, not the technical block's clay particle material. */
public final class SculptureParticles {
private record Removed(long tick,BlockState state,List<StatuaryRenderer.Quad> mesh){}
// A break event can arrive after the block update. Keep a short, bounded snapshot in that world only.
private static final Map<ClientLevel,LinkedHashMap<BlockPos,Removed>> REMOVED=new WeakHashMap<>();
public static void remember(ClientLevel level,BlockPos pos){
if(!(level.getBlockEntity(pos) instanceof StatuaryEntity e)||e.sculpture()==null)return;
var cache=REMOVED.computeIfAbsent(level,l->new LinkedHashMap<>());
var quads=mesh(e);var sample=new ArrayList<StatuaryRenderer.Quad>();int n=Math.min(64,quads.size());
for(int i=0;i<n;i++)sample.add(quads.get((int)((i+.5)*quads.size()/n)));
cache.put(pos.immutable(),new Removed(level.getGameTime(),e.getBlockState(),List.copyOf(sample)));
while(cache.size()>128)cache.remove(cache.keySet().iterator().next());
}
private static List<StatuaryRenderer.Quad> mesh(StatuaryEntity e){var s=e.getBlockState();return StatuaryRenderer.mesh(e.sculpture(),s.getValue(Statuary.FACING),s.getValue(Statuary.ANCHOR),s.getValue(Statuary.HEIGHT));}
public static boolean destroy(ClientLevel level,BlockPos pos,BlockState state){
if(!state.is(Statuary.BLOCK))return false;
List<StatuaryRenderer.Quad> quads=List.of();
if(level.getBlockEntity(pos) instanceof StatuaryEntity e)quads=mesh(e);
var cache=REMOVED.get(level);var old=cache==null?null:cache.remove(pos);
if(quads.isEmpty()&&old!=null&&old.state==state&&level.getGameTime()-old.tick<=40)quads=old.mesh;
int count=Math.min(64,quads.size());var random=level.getRandom();
for(int i=0;i<count;i++){
// Stratified surface sampling covers colors across the sculpture without 4096 particles.
int start=i*quads.size()/count,end=(i+1)*quads.size()/count;
var q=quads.get(start+random.nextInt(end-start));var point=center(q);
spawn(level,pos,point,q.color(),(random.nextDouble()-.5)*.12,random.nextDouble()*.12,(random.nextDouble()-.5)*.12);
}
return true;
}
public static boolean hit(ClientLevel level,BlockPos pos,Direction side,BlockState state){
if(!state.is(Statuary.BLOCK))return false;
if(!(level.getBlockEntity(pos) instanceof StatuaryEntity entity))return true;
var quads=mesh(entity);if(quads.isEmpty())return true;
var mc=Minecraft.getInstance();Vec3 aim=mc.hitResult instanceof BlockHitResult h&&h.getBlockPos().equals(pos)?h.getLocation().subtract(Vec3.atLowerCornerOf(pos)):new Vec3(.5,.5,.5);
StatuaryRenderer.Quad nearest=null;double distance=Double.POSITIVE_INFINITY;
// The selection box can cover a hole: use the nearest actual surface, never empty space.
for(var q:quads){double d=center(q).distanceToSqr(aim);if(d<distance){distance=d;nearest=q;}}
if(nearest!=null)spawn(level,pos,center(nearest),nearest.color(),side.getStepX()*.035,.03+side.getStepY()*.035,side.getStepZ()*.035);
return true;
}
static Vec3 center(StatuaryRenderer.Quad q){
double x=q.x()+1/32d,y=q.y()+1/32d,z=q.z()+1/32d;
return switch(q.side()){case 0->new Vec3(x,y,q.z());case 1->new Vec3(x,y,q.z()+1/16d);case 2->new Vec3(q.x(),y,z);case 3->new Vec3(q.x()+1/16d,y,z);case 4->new Vec3(x,q.y()+1/16d,z);default->new Vec3(x,q.y(),z);};
}
private static void spawn(ClientLevel level,BlockPos pos,Vec3 point,int color,double dx,double dy,double dz){
Minecraft.getInstance().particleEngine.add(new Fragment(level,Vec3.atLowerCornerOf(pos).add(point),color,dx,dy,dz));
}
static final class Fragment extends SingleQuadParticle {
Fragment(ClientLevel level,Vec3 pos,int color,double dx,double dy,double dz){
super(level,pos.x,pos.y,pos.z,Minecraft.getInstance().getAtlasManager().getAtlasOrThrow(AtlasIds.BLOCKS).getSprite(SanctuaryMod.id("sculpture_particle")));
setColor(((color>>16)&255)/255f,((color>>8)&255)/255f,(color&255)/255f);
quadSize=1/32f;gravity=1;friction=.96f;lifetime=12+random.nextInt(12);setParticleSpeed(dx,dy,dz);
}
@Override protected Layer getLayer(){return Layer.OPAQUE_TERRAIN;}
}
private SculptureParticles(){}
}
@@ -24,7 +24,7 @@ import org.joml.Vector3fc;
/** Shared block/item render geometry: opaque visible faces, generated once per cached sculpture. */
public final class StatuaryRenderer implements BlockEntityRenderer<StatuaryEntity,StatuaryRenderer.State> {
record Quad(float x,float y,float z,int side,int color){}
private record Placement(VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor){}
private record Placement(VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor,SculptureHeight height){}
private static final Map<Placement,List<Quad>> MESHES=new LinkedHashMap<>(32,.75f,true);
private static final Map<TypedEntityData<?>,VoxelSculpture> ITEMS=new LinkedHashMap<>(32,.75f,true);
private static final float[][] FACES={
@@ -40,20 +40,20 @@ public final class StatuaryRenderer implements BlockEntityRenderer<StatuaryEntit
public State createRenderState(){return new State();}
@Override public void extractRenderState(StatuaryEntity entity,State state,float delta,Vec3 camera,ModelFeatureRenderer.CrumblingOverlay breakProgress){
BlockEntityRenderer.super.extractRenderState(entity,state,delta,camera,breakProgress);
state.mesh=mesh(entity.sculpture(),entity.getBlockState().getValue(Statuary.FACING),entity.getBlockState().getValue(Statuary.ANCHOR));
state.mesh=mesh(entity.sculpture(),entity.getBlockState().getValue(Statuary.FACING),entity.getBlockState().getValue(Statuary.ANCHOR),entity.getBlockState().getValue(Statuary.HEIGHT));
}
public void submit(State state,PoseStack pose,SubmitNodeCollector collector,CameraRenderState camera){
draw(state.mesh,pose,collector,state.lightCoords,OverlayTexture.NO_OVERLAY);
}
private static List<Quad> mesh(VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor){
if(sculpture==null)return List.of();var key=new Placement(sculpture,facing,anchor);var cached=MESHES.get(key);if(cached!=null)return cached;
var placed=SculptureGeometry.place(sculpture,facing,anchor);sculpture=placed.sculpture();
static List<Quad> mesh(VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor,SculptureHeight height){
if(sculpture==null)return List.of();var key=new Placement(sculpture,facing,anchor,height);var cached=MESHES.get(key);if(cached!=null)return cached;
var placed=SculptureGeometry.place(sculpture,facing,anchor,height);sculpture=placed.sculpture();
var occupied=new BitSet(4096);for(var cell:sculpture.cells())occupied.set(cell.key());var quads=new ArrayList<Quad>();
float ox=placed.offsetX()/16f,oz=placed.offsetZ()/16f;
float ox=placed.offsetX()/16f,oy=placed.offsetY()/16f,oz=placed.offsetZ()/16f;
for(var cell:sculpture.cells())for(int side=0;side<6;side++){
var s=STEP[side];int x=cell.x()+s[0],y=cell.y()+s[1],z=cell.z()+s[2];
if(x>=0&&x<16&&y>=0&&y<16&&z>=0&&z<16&&occupied.get(x|y<<4|z<<8))continue;
quads.add(new Quad(ox+cell.x()/16f,cell.y()/16f,oz+cell.z()/16f,side,cell.color()));
quads.add(new Quad(ox+cell.x()/16f,oy+cell.y()/16f,oz+cell.z()/16f,side,cell.color()));
}
cached=List.copyOf(quads);MESHES.put(key,cached);if(MESHES.size()>32)MESHES.remove(MESHES.keySet().iterator().next());return cached;
}
@@ -69,7 +69,7 @@ public final class StatuaryRenderer implements BlockEntityRenderer<StatuaryEntit
var data=stack.get(DataComponents.BLOCK_ENTITY_DATA);if(data==null)return List.of();
var sculpture=ITEMS.get(data);
if(sculpture==null){sculpture=StatuaryEntity.sculpture(stack);if(sculpture==null)return List.of();ITEMS.put(data,sculpture);if(ITEMS.size()>64)ITEMS.remove(ITEMS.keySet().iterator().next());}
return mesh(sculpture,Direction.SOUTH,SculptureAnchor.CENTER);
return mesh(sculpture,Direction.SOUTH,SculptureAnchor.CENTER,SculptureHeight.ORIGINAL);
}
public void submit(List<Quad> mesh,PoseStack pose,SubmitNodeCollector collector,int light,int overlay,boolean foil,int outline){draw(mesh,pose,collector,light,overlay);}
public void getExtents(java.util.function.Consumer<Vector3fc> out){out.accept(new Vector3f(0,0,0));out.accept(new Vector3f(1,1,1));}
@@ -34,13 +34,17 @@ public final class CompanionQueries {
&&familiar.egg().getItem() instanceof net.minecraft.world.item.SpawnEggItem?net.minecraft.world.item.SpawnEggItem.getType(familiar.egg()):entity.getType();
var id=BuiltInRegistries.ENTITY_TYPE.getKey(type).getPath();
if(CompanionType.find(id)==null)return;
var seen=new TreeSet<>(Arrays.asList(record.notes().getOrDefault("seen_mobs","").split(",")));seen.remove("");
if(seen.add(id))record=record.note("seen_mobs",String.join(",",seen));
record=discoverSpecies(record,List.of(id));
if(entity instanceof net.minecraft.world.entity.animal.fish.TropicalFish fish){
var variants=fishVariants(record);variants.set(fishVariant(fish));record=record.note("seen_fish_v1",Base64.getEncoder().encodeToString(variants.toByteArray()));
}
if(!record.equals(CompanionService.record(p)))CompanionService.save(p,record);
}
/** The same encounter record is used by natural observation and administrative discoveries. */
public static CompanionRecord discoverSpecies(CompanionRecord record,Collection<String> ids){
var seen=new TreeSet<>(Arrays.asList(record.notes().getOrDefault("seen_mobs","").split(",")));seen.remove("");
return seen.addAll(ids)?record.note("seen_mobs",String.join(",",seen)):record;
}
private static int fishVariant(net.minecraft.world.entity.animal.fish.TropicalFish fish){
int packed=fish.getPattern().getPackedId();return ((packed>>>8)*2+(packed&1))*256+fish.getBaseColor().getId()*16+fish.getPatternColor().getId();
}
@@ -0,0 +1,19 @@
package fr.koka.sanctuary.gems;
import com.mojang.serialization.MapCodec;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import net.minecraft.core.BlockPos;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.feature.*;
/** Native seeded ore veins, scoped to Sanctuary even when its biomes are shared with vanilla. */
public record GemOreFeature(OreFeature ore) implements Feature {
public static final MapCodec<GemOreFeature> CODEC=OreFeature.CODEC.xmap(GemOreFeature::new,GemOreFeature::ore);
@Override public MapCodec<GemOreFeature> codec(){return CODEC;}
@Override public boolean place(WorldGenLevel level,ChunkGenerator generator,RandomSource random,BlockPos origin){
if(!(level.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator))return false;
return ore.place(level,generator,random,origin);
}
}
@@ -0,0 +1,50 @@
package fr.koka.sanctuary.gems;
import fr.koka.sanctuary.SanctuaryMod;
import java.util.List;
import net.fabricmc.fabric.api.biome.v1.BiomeModifications;
import net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.*;
import net.minecraft.resources.ResourceKey;
import net.minecraft.tags.TagKey;
import net.minecraft.util.valueproviders.UniformInt;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.levelgen.GenerationStep;
/** Ordinary gem items and blocks; their distribution lives in data, not this registry. */
public final class Gems {
public record Family(String id,Item gem,Block block,Block ore,Block deepslateOre){}
public static final Family RUBY=family("ruby",DyeColor.RED);
public static final Family SAPPHIRE=family("sapphire",DyeColor.BLUE);
public static final List<Family> FAMILIES=List.of(RUBY,SAPPHIRE);
public static void register(){
Registry.register(BuiltInRegistries.FEATURE_TYPE,SanctuaryMod.id("gem_ore"),GemOreFeature.CODEC);
for(var f:FAMILIES){
var biomes=TagKey.create(Registries.BIOME,SanctuaryMod.id("has_ore/"+f.id));
BiomeModifications.addFeature(c->c.hasTag(biomes),GenerationStep.Decoration.UNDERGROUND_ORES,
ResourceKey.create(Registries.PLACED_FEATURE,SanctuaryMod.id("ore_"+f.id)));
}
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.INGREDIENTS).register(out->out.insertAfter(Items.EMERALD,RUBY.gem,SAPPHIRE.gem));
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.BUILDING_BLOCKS).register(out->out.insertAfter(Blocks.EMERALD_BLOCK,RUBY.block,SAPPHIRE.block));
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.NATURAL_BLOCKS).register(out->{
out.insertAfter(Blocks.EMERALD_ORE,RUBY.ore,SAPPHIRE.ore);
out.insertAfter(Blocks.DEEPSLATE_EMERALD_ORE,RUBY.deepslateOre,SAPPHIRE.deepslateOre);
});
}
private static Family family(String id,DyeColor color){
var key=SanctuaryMod.id(id);var item=Registry.register(BuiltInRegistries.ITEM,key,new Item(new Item.Properties().setId(ResourceKey.create(Registries.ITEM,key))));
var block=block(id+"_block",new Block(properties(id+"_block",Blocks.EMERALD_BLOCK).mapColor(color.getMapColor())));
var ore=block(id+"_ore",new DropExperienceBlock(UniformInt.of(3,7),properties(id+"_ore",Blocks.EMERALD_ORE)));
var deep=block("deepslate_"+id+"_ore",new DropExperienceBlock(UniformInt.of(3,7),properties("deepslate_"+id+"_ore",Blocks.DEEPSLATE_EMERALD_ORE)));
return new Family(id,item,block,ore,deep);
}
private static BlockBehaviour.Properties properties(String id,Block source){return BlockBehaviour.Properties.ofFullCopy(source).setId(ResourceKey.create(Registries.BLOCK,SanctuaryMod.id(id)));}
private static Block block(String id,Block block){
var key=SanctuaryMod.id(id);Registry.register(BuiltInRegistries.BLOCK,key,block);
Registry.register(BuiltInRegistries.ITEM,key,new BlockItem(block,new Item.Properties().setId(ResourceKey.create(Registries.ITEM,key)).useBlockDescriptionPrefix()));return block;
}
private Gems(){}
}
@@ -47,6 +47,7 @@ public final class BlockKnowledgeService {
private BlockKnowledgeService() {}
public static void register() {
DiscoveryCommands.register();
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> observeInventory(handler.player));
// Fabric also reaches DISCONNECT through Netty's channelInactive callback.
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> server.execute(() -> {
@@ -97,6 +98,12 @@ public final class BlockKnowledgeService {
public static void recordSeen(ServerPlayer player, Block block) {
if (block.defaultBlockState().isAir() || player.isSpectator()) return;
discover(player,block);
}
/** Explicit discoveries use the same record and events, including for a spectator command target. */
public static void discover(ServerPlayer player,Block block) {
if(block.defaultBlockState().isAir())return;
Session session = session(player);
if (session.failed || !session.memory.recordSeen(id(block))) return;
session.dirty = true;
@@ -0,0 +1,75 @@
package fr.koka.sanctuary.knowledge;
import com.mojang.brigadier.builder.ArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.operator.OperatorService;
import fr.koka.sanctuary.progression.ProgressEffects;
import fr.koka.sanctuary.recipes.*;
import java.util.*;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.minecraft.commands.*;
import net.minecraft.commands.arguments.*;
import net.minecraft.core.registries.*;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Block;
/** One command routes through the existing discovery services and their dependent recipe/catalogue rules. */
public final class DiscoveryCommands {
private record Selection(List<Block> blocks,List<Item> items,List<EntityType<?>> mobs){}
@FunctionalInterface private interface SelectionArgument {Selection get(CommandContext<CommandSourceStack> context)throws CommandSyntaxException;}
public static void register(){
CommandRegistrationCallback.EVENT.register((dispatcher,registries,environment)->{
var root=Commands.literal("discovery").requires(OperatorService::canCommand);
root.then(targets(Commands.literal("all"),c->all(true,true,true)));
root.then(Commands.literal("blocks").then(targets(Commands.literal("all"),c->all(true,false,false))));
root.then(Commands.literal("items").then(targets(Commands.literal("all"),c->all(false,true,false))));
root.then(Commands.literal("mobs").then(targets(Commands.literal("all"),c->all(false,false,true))));
root.then(Commands.literal("block").then(targets(Commands.argument("block",ResourceArgument.resource(registries,Registries.BLOCK)),c->new Selection(List.of(ResourceArgument.getResource(c,"block",Registries.BLOCK).value()),List.of(),List.of()))));
root.then(Commands.literal("item").then(targets(Commands.argument("item",ResourceArgument.resource(registries,Registries.ITEM)),c->new Selection(List.of(),List.of(ResourceArgument.getResource(c,"item",Registries.ITEM).value()),List.of()))));
root.then(Commands.literal("mob").then(targets(Commands.argument("mob",ResourceArgument.resource(registries,Registries.ENTITY_TYPE)),c->new Selection(List.of(),List.of(),List.of(ResourceArgument.getResource(c,"mob",Registries.ENTITY_TYPE).value())))));
dispatcher.register(Commands.literal("sanctuary").then(root));
});
}
private static <T extends ArgumentBuilder<CommandSourceStack,T>> T targets(T node,SelectionArgument selection){
return node.executes(c->discover(c,selection.get(c),List.of(c.getSource().getPlayerOrException())))
.then(Commands.argument("players",EntityArgument.players()).executes(c->discover(c,selection.get(c),EntityArgument.getPlayers(c,"players"))));
}
private static Selection all(boolean blocks,boolean items,boolean mobs){
return new Selection(blocks?BuiltInRegistries.BLOCK.stream().filter(b->!b.defaultBlockState().isAir()).toList():List.of(),
items?BuiltInRegistries.ITEM.stream().filter(i->i!=Items.AIR).toList():List.of(),
mobs?BuiltInRegistries.ENTITY_TYPE.stream().filter(t->SpawnEggItem.byId(t).isPresent()).toList():List.of());
}
private static int discover(CommandContext<CommandSourceStack> context,Selection selection,Collection<ServerPlayer> players){
var source=context.getSource();
if(selection.blocks.stream().anyMatch(b->b.defaultBlockState().isAir())||selection.items.contains(Items.AIR)||selection.mobs.stream().anyMatch(t->SpawnEggItem.byId(t).isEmpty())){
source.sendFailure(Component.translatable("sanctuary.discovery.invalid"));return 0;
}
var species=selection.mobs.stream().map(BuiltInRegistries.ENTITY_TYPE::getKey).map(id->id.getNamespace().equals("minecraft")?id.getPath():id.toString()).toList();
int changed=0;
for(var player:players){
var record=CompanionService.record(player);
if(ProgressEffects.get(player)==null||record==null||!BlockKnowledgeService.healthy(player)||!RecipeService.healthy(player)){
source.sendFailure(Component.translatable("sanctuary.discovery.unavailable",player.getDisplayName()));continue;
}
// Validate bounded records before any write; no new save schema or unlock flag.
CompanionRecord next;
var discoveredItems=new HashSet<String>(RecipeService.knowledge(player).items);
for(var item:selection.items)discoveredItems.add(BuiltInRegistries.ITEM.getKey(item).toString());
for(var block:selection.blocks)if(block.asItem()!=Items.AIR)discoveredItems.add(BuiltInRegistries.ITEM.getKey(block.asItem()).toString());
try{next=CompanionQueries.discoverSpecies(record,species);if(discoveredItems.size()>RecipeKnowledge.MAX_IDS)throw new IllegalArgumentException("Discovery capacity");}
catch(IllegalArgumentException invalid){source.sendFailure(Component.translatable("sanctuary.discovery.unavailable",player.getDisplayName()));continue;}
CompanionService.save(player,next);
for(var block:selection.blocks)BlockKnowledgeService.discover(player,block);
for(var item:selection.items){if(item instanceof BlockItem b)BlockKnowledgeService.discover(player,b.getBlock());RecipeService.discover(player,item);}
RecipeService.refresh(player);
source.sendSuccess(()->Component.translatable("sanctuary.discovery.granted",player.getDisplayName(),selection.blocks.size(),selection.items.size(),selection.mobs.size()),true);changed++;
}
return changed;
}
private DiscoveryCommands(){}
}
@@ -0,0 +1,26 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.SculptureParticles;
import fr.koka.sanctuary.plans.Statuary;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.*;
import net.minecraft.world.level.block.state.BlockState;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.*;
@Mixin(ClientLevel.class)
public abstract class SculptureParticlesMixin {
@Inject(method="setBlock",at=@At("HEAD"))
private void sanctuary$remember(BlockPos pos,BlockState state,int flags,int recursion,CallbackInfoReturnable<Boolean> cir){
if(!state.is(Statuary.BLOCK))SculptureParticles.remember((ClientLevel)(Object)this,pos);
}
@Inject(method="addDestroyBlockEffect",at=@At("HEAD"),cancellable=true)
private void sanctuary$chips(BlockPos pos,BlockState state,CallbackInfo ci){
if(SculptureParticles.destroy((ClientLevel)(Object)this,pos,state))ci.cancel();
}
@Inject(method="addBreakingParticles",at=@At("HEAD"),cancellable=true)
private void sanctuary$chip(BlockPos pos,Direction side,BlockState state,CallbackInfo ci){
if(SculptureParticles.hit((ClientLevel)(Object)this,pos,side,state))ci.cancel();
}
}
@@ -20,8 +20,11 @@ import net.minecraft.world.phys.*;
/** Native input/result slots. Server validates the material, applies the bounded palette and charges each take. */
public final class ClayWorkshopMenu extends AbstractContainerMenu {
public static final int HOTBAR_START=2,HOTBAR_END=HOTBAR_START+Inventory.SELECTION_SIZE,HOTBAR_X=75,HOTBAR_Y=176;
public record View(BlockPos pos,UUID token){
public static final StreamCodec<RegistryFriendlyByteBuf,View> CODEC=StreamCodec.of((b,p)->{b.writeBlockPos(p.pos);b.writeUUID(p.token);},b->new View(b.readBlockPos(),b.readUUID()));
public record View(BlockPos pos,UUID token,List<String> statues){
public View { statues=List.copyOf(statues); }
public static final StreamCodec<RegistryFriendlyByteBuf,View> CODEC=StreamCodec.of((b,p)->{
b.writeBlockPos(p.pos);b.writeUUID(p.token);b.writeVarInt(p.statues.size());for(var id:p.statues)b.writeUtf(id,128);
},b->{var pos=b.readBlockPos();var token=b.readUUID();int n=b.readVarInt();if(n<0||n>1024)throw new IllegalArgumentException("statue_discoveries");var ids=new ArrayList<String>(n);for(int i=0;i<n;i++)ids.add(b.readUtf(128));return new View(pos,token,ids);});
}
/** At most 24 KiB of cells plus 256 palette colors in one atomic, bounded selection, scoped to one open menu. */
public record Select(int menu,UUID token,VoxelSculpture sculpture,Item material,SculpturePalette palette) implements CustomPacketPayload {
@@ -68,14 +71,18 @@ public final class ClayWorkshopMenu extends AbstractContainerMenu {
var contents=item.get(DataComponents.CONTAINER);
return contents==null||!contents.nonEmptyItems().iterator().hasNext();
}
/** Plain clay retains the model's sampled colors; other blocks transfer their texture palette. */
public static VoxelSculpture appearance(VoxelSculpture source,Item material,SculpturePalette palette){
return material==Items.CLAY?source:palette.apply(source);
}
public void select(ServerPlayer p,VoxelSculpture sculpture,Item material,SculpturePalette palette){
if(level==null||!stillValid(p)||level.getGameTime()-lastSelection<4)return;
var hit=new BlockHitResult(Vec3.atCenterOf(view.pos),Direction.UP,view.pos,false);
if(UseBlockCallback.EVENT.invoker().interact(p,level,InteractionHand.MAIN_HAND,hit)!=InteractionResult.PASS||!stillValid(p))return;
lastSelection=level.getGameTime();
if(sculpture!=null&&(!material(input.getItem(0))||!input.getItem(0).is(material)||palette==null))return;
if(!Objects.equals(selected,sculpture)||!Objects.equals(selectedPalette,palette))
selectedResult=sculpture==null||palette==null?null:palette.apply(sculpture);
if(sculpture!=null&&(!StatueDiscoveries.allowedModel(sculpture.modelId(),view.statues())||!material(input.getItem(0))||!input.getItem(0).is(material)||palette==null))return;
if(!Objects.equals(selected,sculpture)||selectedMaterial!=material||!Objects.equals(selectedPalette,palette))
selectedResult=sculpture==null||palette==null?null:appearance(sculpture,material,palette);
selected=sculpture;selectedMaterial=material;selectedPalette=palette;refresh();broadcastChanges();
}
private void refresh(){
@@ -7,27 +7,29 @@ import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.block.Mirror;
import net.minecraft.world.level.block.Rotation;
/** World-space anchor of the occupied block, always on the whole-voxel grid. */
/** World-space horizontal anchor; old names remain valid in saved blockstates. */
public enum SculptureAnchor implements StringRepresentable {
CENTER(null), NORTH(Direction.NORTH), SOUTH(Direction.SOUTH), EAST(Direction.EAST), WEST(Direction.WEST);
CENTER(0,0), NORTH(0,-1), SOUTH(0,1), EAST(1,0), WEST(-1,0),
NORTH_WEST(-1,-1), NORTH_EAST(1,-1), SOUTH_WEST(-1,1), SOUTH_EAST(1,1);
private final Direction direction;
SculptureAnchor(Direction direction){this.direction=direction;}
private final int x,z;
SculptureAnchor(int x,int z){this.x=x;this.z=z;}
public String getSerializedName(){return name().toLowerCase(Locale.ROOT);}
public SculptureAnchor rotate(Rotation rotation){return direction==null?this:of(rotation.rotate(direction));}
public SculptureAnchor mirror(Mirror mirror){return direction==null?this:of(mirror.mirror(direction));}
public SculptureAnchor rotate(Rotation rotation){return switch(rotation){
case NONE -> this;case CLOCKWISE_90 -> of(-z,x);case CLOCKWISE_180 -> of(-x,-z);case COUNTERCLOCKWISE_90 -> of(z,-x);
};}
public SculptureAnchor mirror(Mirror mirror){return switch(mirror){case NONE->this;case LEFT_RIGHT->of(x,-z);case FRONT_BACK->of(-x,z);};}
public static SculptureAnchor of(Direction direction){return switch(direction){
case NORTH -> NORTH;case SOUTH -> SOUTH;case EAST -> EAST;case WEST -> WEST;
default -> throw new IllegalArgumentException("Horizontal anchor required");
};}
private static SculptureAnchor of(int x,int z){for(var anchor:values())if(anchor.x==x&&anchor.z==z)return anchor;throw new IllegalArgumentException("Anchor outside grid");}
public static SculptureAnchor placement(BlockPlaceContext context){
var pos=context.getClickedPos();var hit=context.getClickLocation();
double x=hit.x-pos.getX()-.5,z=hit.z-pos.getZ()-.5;
// The middle 8 x 8 pixels select the existing grid-aligned center.
if(Math.abs(x)<=.25&&Math.abs(z)<=.25)return CENTER;
if(Math.abs(x)>Math.abs(z))return x<0?WEST:EAST;
return z<0?NORTH:SOUTH;
return of(band(hit.x-pos.getX()),band(hit.z-pos.getZ()));
}
public int originX(int width){return this==WEST?0:this==EAST?VoxelSculpture.EDGE-width:(VoxelSculpture.EDGE-width)/2;}
public int originZ(int depth){return this==NORTH?0:this==SOUTH?VoxelSculpture.EDGE-depth:(VoxelSculpture.EDGE-depth)/2;}
// The middle eight pixels, including the boundaries, keep the grid-aligned center.
static int band(double value){return value<.25?-1:value>.75?1:0;}
public int originX(int width){return x<0?0:x>0?VoxelSculpture.EDGE-width:(VoxelSculpture.EDGE-width)/2;}
public int originZ(int depth){return z<0?0:z>0?VoxelSculpture.EDGE-depth:(VoxelSculpture.EDGE-depth)/2;}
}
@@ -6,24 +6,24 @@ import net.minecraft.world.phys.shapes.VoxelShape;
/** Integer placement shared by rendering and tight, single-box selection/collision bounds. */
public final class SculptureGeometry {
public record Placement(VoxelSculpture sculpture, int offsetX, int offsetZ) {}
public record Placement(VoxelSculpture sculpture, int offsetX, int offsetY, int offsetZ) {}
private SculptureGeometry() {}
public static Placement place(VoxelSculpture sculpture, Direction facing, SculptureAnchor anchor) {
public static Placement place(VoxelSculpture sculpture, Direction facing, SculptureAnchor anchor, SculptureHeight height) {
int turns = switch (facing) { case WEST -> 1; case NORTH -> 2; case EAST -> 3; default -> 0; };
for (int i = 0; i < turns; i++) sculpture = sculpture.rotate();
int minX = 16, minZ = 16, maxX = 0, maxZ = 0;
int minX = 16, minY = 16, minZ = 16, maxX = 0, maxY = 0, maxZ = 0;
for (var cell : sculpture.cells()) {
minX = Math.min(minX, cell.x()); minZ = Math.min(minZ, cell.z());
maxX = Math.max(maxX, cell.x() + 1); maxZ = Math.max(maxZ, cell.z() + 1);
minX = Math.min(minX, cell.x()); minZ = Math.min(minZ, cell.z()); minY = Math.min(minY, cell.y());
maxX = Math.max(maxX, cell.x() + 1); maxZ = Math.max(maxZ, cell.z() + 1); maxY = Math.max(maxY, cell.y() + 1);
}
return new Placement(sculpture, anchor.originX(maxX - minX) - minX,
return new Placement(sculpture, anchor.originX(maxX - minX) - minX, height.originY(minY,maxY) - minY,
anchor.originZ(maxZ - minZ) - minZ);
}
/** Tight integer bounds, without rotating/copying cells or constructing a voxel grid. */
public static VoxelShape shape(VoxelSculpture sculpture, Direction facing, SculptureAnchor anchor) {
public static VoxelShape shape(VoxelSculpture sculpture, Direction facing, SculptureAnchor anchor, SculptureHeight height) {
int minX=16,minY=16,minZ=16,maxX=0,maxY=0,maxZ=0;
for(var cell:sculpture.cells()) {
minX=Math.min(minX,cell.x()); minY=Math.min(minY,cell.y()); minZ=Math.min(minZ,cell.z());
@@ -32,6 +32,7 @@ public final class SculptureGeometry {
int width=maxX-minX,depth=maxZ-minZ;
if(facing.getAxis()==Direction.Axis.X){int swap=width;width=depth;depth=swap;}
int x=anchor.originX(width),z=anchor.originZ(depth);
return Block.box(x,minY,z,x+width,maxY,z+depth);
int y=height.originY(minY,maxY);
return Block.box(x,y,z,x+width,y+maxY-minY,z+depth);
}
}
@@ -0,0 +1,19 @@
package fr.koka.sanctuary.plans;
import java.util.Locale;
import net.minecraft.util.StringRepresentable;
import net.minecraft.world.item.context.BlockPlaceContext;
/** ORIGINAL is the missing-property default: pre-beta.125 sculptures retain their exact Y offsets. */
public enum SculptureHeight implements StringRepresentable {
ORIGINAL, BOTTOM, CENTER, TOP;
public String getSerializedName(){return name().toLowerCase(Locale.ROOT);}
public int originY(int minY,int maxY){return switch(this){
case ORIGINAL -> minY;case BOTTOM -> 0;case CENTER -> (VoxelSculpture.EDGE-(maxY-minY))/2;case TOP -> VoxelSculpture.EDGE-(maxY-minY);
};}
public static SculptureHeight placement(BlockPlaceContext context){
return switch(SculptureAnchor.band(context.getClickLocation().y-context.getClickedPos().getY())){
case -1 -> BOTTOM;case 1 -> TOP;default -> CENTER;
};
}
}
@@ -30,6 +30,7 @@ import net.minecraft.network.chat.Component;
public final class Statuary {
public static final EnumProperty<Direction> FACING=BlockStateProperties.HORIZONTAL_FACING;
public static final EnumProperty<SculptureAnchor> ANCHOR=EnumProperty.create("anchor",SculptureAnchor.class);
public static final EnumProperty<SculptureHeight> HEIGHT=EnumProperty.create("vertical_anchor",SculptureHeight.class);
public static final Block BLOCK=block("statuary",new SculptureBlock());
public static final Item ITEM=blockItem("statuary",BLOCK);
public static final Block WORKSHOP=block("clay_workshop",new WorkshopBlock());
@@ -50,7 +51,7 @@ public final class Statuary {
}
public static void open(ServerPlayer p,BlockPos pos){
if(!allowed(p,p.level(),pos))return;
var view=new ClayWorkshopMenu.View(pos,UUID.randomUUID());var level=p.level();
var view=new ClayWorkshopMenu.View(pos,UUID.randomUUID(),StatueDiscoveries.known(p));var level=p.level();
p.openMenu(new ExtendedMenuProvider<ClayWorkshopMenu.View>(){
public ClayWorkshopMenu.View getScreenOpeningData(ServerPlayer player){return view;}
public Component getDisplayName(){return WORKSHOP.getName();}
@@ -69,11 +70,11 @@ public final class Statuary {
if(!super.canPlace(context,state))return false;
// The destination has no block entity yet: use the carried model for the native entity-overlap check.
var model=StatuaryEntity.sculpture(context.getItemInHand());
return model==null||context.getLevel().isUnobstructed(null,SculptureGeometry.shape(model,state.getValue(FACING),state.getValue(ANCHOR)).move(context.getClickedPos()));
return model==null||context.getLevel().isUnobstructed(null,SculptureGeometry.shape(model,state.getValue(FACING),state.getValue(ANCHOR),state.getValue(HEIGHT)).move(context.getClickedPos()));
}
}
private static final class SculptureBlock extends Block implements EntityBlock {
SculptureBlock(){super(Statuary.properties("statuary").dynamicShape().isSuffocating((s,l,p)->false).isViewBlocking((s,l,p,box)->false));registerDefaultState(stateDefinition.any().setValue(FACING,Direction.SOUTH).setValue(ANCHOR,SculptureAnchor.CENTER));}
SculptureBlock(){super(Statuary.properties("statuary").dynamicShape().isSuffocating((s,l,p)->false).isViewBlocking((s,l,p,box)->false));registerDefaultState(stateDefinition.any().setValue(FACING,Direction.SOUTH).setValue(ANCHOR,SculptureAnchor.CENTER).setValue(HEIGHT,SculptureHeight.ORIGINAL));}
@Override protected VoxelShape getShape(BlockState state,BlockGetter level,BlockPos pos,CollisionContext context){
return level.getBlockEntity(pos) instanceof StatuaryEntity entity?entity.shape(state):Shapes.empty();
}
@@ -83,8 +84,8 @@ public final class Statuary {
@Override protected boolean propagatesSkylightDown(BlockState state){return true;}
@Override protected int getLightDampening(BlockState state){return 0;}
@Override protected float getShadeBrightness(BlockState state,BlockGetter level,BlockPos pos){return 1;}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block,BlockState> builder){builder.add(FACING,ANCHOR);}
@Override public BlockState getStateForPlacement(BlockPlaceContext context){return defaultBlockState().setValue(FACING,context.getHorizontalDirection()).setValue(ANCHOR,SculptureAnchor.placement(context));}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block,BlockState> builder){builder.add(FACING,ANCHOR,HEIGHT);}
@Override public BlockState getStateForPlacement(BlockPlaceContext context){return defaultBlockState().setValue(FACING,context.getHorizontalDirection()).setValue(ANCHOR,SculptureAnchor.placement(context)).setValue(HEIGHT,SculptureHeight.placement(context));}
@Override protected BlockState rotate(BlockState state,Rotation rotation){return state.setValue(FACING,rotation.rotate(state.getValue(FACING))).setValue(ANCHOR,state.getValue(ANCHOR).rotate(rotation));}
@Override protected BlockState mirror(BlockState state,Mirror mirror){return state.setValue(FACING,mirror.mirror(state.getValue(FACING))).setValue(ANCHOR,state.getValue(ANCHOR).mirror(mirror));}
public BlockEntity newBlockEntity(BlockPos pos,BlockState state){return new StatuaryEntity(pos,state);}
@@ -26,7 +26,7 @@ public final class StatuaryEntity extends BlockEntity {
var model=sculpture;if(model==null)return MISSING_SHAPE;
var cached=shapeCache;
if(cached!=null&&cached.sculpture()==model&&cached.state()==state)return cached.shape();
var shape=SculptureGeometry.shape(model,state.getValue(Statuary.FACING),state.getValue(Statuary.ANCHOR));
var shape=SculptureGeometry.shape(model,state.getValue(Statuary.FACING),state.getValue(Statuary.ANCHOR),state.getValue(Statuary.HEIGHT));
shapeCache=new ShapeCache(model,state,shape);return shape;
}
@Override protected void saveAdditional(ValueOutput out){
@@ -18,5 +18,15 @@ public final class StatueDiscoveries {
||player.getStats().getValue(Stats.ENTITY_KILLED,type)>0||player.getStats().getValue(Stats.ENTITY_KILLED_BY,type)>0;
}).map(type->BuiltInRegistries.ENTITY_TYPE.getKey(type).toString()).sorted().toList();
}
/** Stable identifiers share the existing sculpture schema; imported models keep their content hash. */
public static String modelId(String entityId){
try{return "sanctuary:model/"+HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(("sanctuary:statue/"+entityId).getBytes(java.nio.charset.StandardCharsets.UTF_8)));}
catch(java.security.NoSuchAlgorithmException impossible){throw new AssertionError(impossible);}
}
private static final class Models {
static final Map<String,String> IDS=BuiltInRegistries.ENTITY_TYPE.stream().filter(t->SpawnEggItem.byId(t).isPresent())
.map(t->BuiltInRegistries.ENTITY_TYPE.getKey(t).toString()).collect(java.util.stream.Collectors.toUnmodifiableMap(StatueDiscoveries::modelId,id->id));
}
public static boolean allowedModel(String model,List<String> known){var entity=Models.IDS.get(model);return entity==null||known.contains(entity);}
private StatueDiscoveries(){}
}
@@ -35,6 +35,7 @@ public final class RecipeService {
}
private static Session session(ServerPlayer p){return SESSIONS.computeIfAbsent(p,Session::new);}
public static RecipeKnowledge knowledge(ServerPlayer p){return session(p).data;}
public static boolean healthy(ServerPlayer p){return !session(p).invalid;}
public static void discover(ServerPlayer p,Item item){
if(ProgressEffects.get(p)==null||item==Items.AIR)return;var s=session(p);if(s.invalid)return;
s.dirty|=RecipeKnowledge.add(s.data.items,BuiltInRegistries.ITEM.getKey(item).toString());
@@ -0,0 +1,5 @@
{
"sources": [
{"type": "minecraft:single", "resource": "sanctuary:misc/sculpture_white", "sprite": "sanctuary:sculpture_particle"}
]
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "sanctuary:block/deepslate_ruby_ore"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "sanctuary:block/deepslate_sapphire_ore"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "sanctuary:block/ruby_block"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "sanctuary:block/ruby_ore"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "sanctuary:block/sapphire_block"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "sanctuary:block/sapphire_ore"
}
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:block/deepslate_ruby_ore"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:block/deepslate_sapphire_ore"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:item/ruby"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:block/ruby_block"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:block/ruby_ore"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:item/sapphire"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:block/sapphire_block"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:block/sapphire_ore"
}
}
@@ -1,4 +1,5 @@
{
"sanctuary.statuary.clay_ready": "Clay: original model colors.",
"sanctuary.weather.selected": "Real Time weather: %s for %s (%s). The daily forecast resumes at midnight.",
"sanctuary.weather.overcast": "Overcast",
"sanctuary.weather.fog": "Fog",
@@ -1972,7 +1973,7 @@
"sanctuary.discovery.item_filter": "Show carried items or every item already discovered.",
"sanctuary.discovery.catalogue_required": "The Catalogue ability lets you browse discovered items you no longer carry.",
"sanctuary.discovery.loading": "Loading statistics…",
"sanctuary.discovery.unavailable": "Statistics unavailable. Refresh to try again.",
"sanctuary.discovery.unavailable": "Unable to update discoveries for %s; no changes made.",
"sanctuary.discovery.mobs_hint": "Mobs you have killed or that have killed you. Sightings alone are not recorded yet.",
"sanctuary.discovery.count": "%s entries",
"sanctuary.discovery.empty": "No entries match this filter.",
@@ -2241,5 +2242,19 @@
"sanctuary.wrench.no_rotation": "This block has no vanilla orientation.",
"sanctuary.wrench.linked": "This block has linked parts: separate or retract it before rotating.",
"sanctuary.wrench.unsupported": "This orientation does not fit the neighbouring blocks or support.",
"sanctuary.wrench.steve_power": "Free editing of block states and textures"
"sanctuary.wrench.steve_power": "Free editing of block states and textures",
"sanctuary.statuary.animals": "Animals",
"sanctuary.statuary.imports": "Imports",
"sanctuary.statuary.no_animals": "Encounter creatures to unlock their sculptures.",
"sanctuary.statuary.animal_error": "This creatures model could not be sculpted.",
"item.sanctuary.ruby": "Ruby",
"block.sanctuary.ruby_block": "Block of Ruby",
"block.sanctuary.ruby_ore": "Ruby Ore",
"block.sanctuary.deepslate_ruby_ore": "Deepslate Ruby Ore",
"item.sanctuary.sapphire": "Sapphire",
"block.sanctuary.sapphire_block": "Block of Sapphire",
"block.sanctuary.sapphire_ore": "Sapphire Ore",
"block.sanctuary.deepslate_sapphire_ore": "Deepslate Sapphire Ore",
"sanctuary.discovery.granted": "Discoveries added for %s: %s blocks, %s items, %s species. Linked recipes updated; reopen catalogues.",
"sanctuary.discovery.invalid": "Choose a discoverable block, item or species."
}
@@ -1,4 +1,5 @@
{
"sanctuary.statuary.clay_ready": "Argile : couleurs originales du modèle.",
"sanctuary.weather.selected": "Météo Real Time : %s pour la journée du %s (%s). Le tirage quotidien reprend à minuit.",
"sanctuary.weather.overcast": "Ciel gris",
"sanctuary.weather.fog": "Brouillard",
@@ -1972,7 +1973,7 @@
"sanctuary.discovery.item_filter": "Afficher les objets portés ou tous les objets déjà découverts.",
"sanctuary.discovery.catalogue_required": "Laptitude Catalogue permet de consulter les objets découverts que tu ne portes plus.",
"sanctuary.discovery.loading": "Chargement des statistiques…",
"sanctuary.discovery.unavailable": "Statistiques indisponibles. Actualise pour réessayer.",
"sanctuary.discovery.unavailable": "Impossible de mettre à jour les découvertes de %s ; aucune modification effectuée.",
"sanctuary.discovery.mobs_hint": "Créatures déjà tuées ou responsables de ta mort. Les simples observations ne sont pas encore enregistrées.",
"sanctuary.discovery.count": "%s entrées",
"sanctuary.discovery.empty": "Aucune entrée pour ce filtre.",
@@ -2241,5 +2242,19 @@
"sanctuary.wrench.no_rotation": "Ce bloc ne possède pas dorientation vanilla.",
"sanctuary.wrench.linked": "Bloc lié à une autre partie : dissocie-le ou rétracte-le avant de le tourner.",
"sanctuary.wrench.unsupported": "Cette orientation ne convient pas aux blocs voisins ou au support.",
"sanctuary.wrench.steve_power": "Édition libre des états et des textures"
"sanctuary.wrench.steve_power": "Édition libre des états et des textures",
"sanctuary.statuary.animals": "Animaux",
"sanctuary.statuary.imports": "Imports",
"sanctuary.statuary.no_animals": "Rencontrez des créatures pour débloquer leurs sculptures.",
"sanctuary.statuary.animal_error": "Impossible de sculpter le modèle de cette créature.",
"item.sanctuary.ruby": "Rubis",
"block.sanctuary.ruby_block": "Bloc de rubis",
"block.sanctuary.ruby_ore": "Minerai de rubis",
"block.sanctuary.deepslate_ruby_ore": "Minerai de rubis des abîmes",
"item.sanctuary.sapphire": "Saphir",
"block.sanctuary.sapphire_block": "Bloc de saphir",
"block.sanctuary.sapphire_ore": "Minerai de saphir",
"block.sanctuary.deepslate_sapphire_ore": "Minerai de saphir des abîmes",
"sanctuary.discovery.granted": "Découvertes ajoutées pour %s : %s blocs, %s objets, %s espèces. Recettes liées actualisées ; rouvre les catalogues.",
"sanctuary.discovery.invalid": "Choisis un bloc, un objet ou une espèce découvrable."
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:block/cube_all",
"textures": {
"all": "sanctuary:block/deepslate_ruby_ore"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:block/cube_all",
"textures": {
"all": "sanctuary:block/deepslate_sapphire_ore"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:block/cube_all",
"textures": {
"all": "sanctuary:block/ruby_block"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:block/cube_all",
"textures": {
"all": "sanctuary:block/ruby_ore"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:block/cube_all",
"textures": {
"all": "sanctuary:block/sapphire_block"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:block/cube_all",
"textures": {
"all": "sanctuary:block/sapphire_ore"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "sanctuary:item/ruby"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "sanctuary:item/sapphire"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 596 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

@@ -0,0 +1,7 @@
{
"replace": false,
"values": [
"#c:ores/ruby",
"#c:ores/sapphire"
]
}
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"#sanctuary:ruby_ores"
]
}
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"#sanctuary:sapphire_ores"
]
}
@@ -0,0 +1,7 @@
{
"replace": false,
"values": [
"sanctuary:deepslate_ruby_ore",
"sanctuary:deepslate_sapphire_ore"
]
}
@@ -0,0 +1,7 @@
{
"replace": false,
"values": [
"sanctuary:ruby_ore",
"sanctuary:sapphire_ore"
]
}
@@ -0,0 +1,7 @@
{
"replace": false,
"values": [
"#c:storage_blocks/ruby",
"#c:storage_blocks/sapphire"
]
}
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"sanctuary:ruby_block"
]
}
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"sanctuary:sapphire_block"
]
}
@@ -0,0 +1,7 @@
{
"replace": false,
"values": [
"#c:gems/ruby",
"#c:gems/sapphire"
]
}
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"sanctuary:ruby"
]
}
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"sanctuary:sapphire"
]
}
@@ -0,0 +1,7 @@
{
"replace": false,
"values": [
"#c:ores/ruby",
"#c:ores/sapphire"
]
}
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"#sanctuary:ruby_ores"
]
}
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"#sanctuary:sapphire_ores"
]
}
@@ -0,0 +1,7 @@
{
"replace": false,
"values": [
"sanctuary:deepslate_ruby_ore",
"sanctuary:deepslate_sapphire_ore"
]
}
@@ -0,0 +1,7 @@
{
"replace": false,
"values": [
"sanctuary:ruby_ore",
"sanctuary:sapphire_ore"
]
}
@@ -0,0 +1,7 @@
{
"replace": false,
"values": [
"#c:storage_blocks/ruby",
"#c:storage_blocks/sapphire"
]
}
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"sanctuary:ruby_block"
]
}
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"sanctuary:sapphire_block"
]
}
@@ -0,0 +1,7 @@
{
"replace": false,
"values": [
"sanctuary:ruby_block",
"sanctuary:sapphire_block"
]
}
@@ -64,6 +64,12 @@
"sanctuary:brown_brick_wall",
"sanctuary:green_brick_wall",
"sanctuary:red_brick_wall",
"sanctuary:black_brick_wall"
"sanctuary:black_brick_wall",
"sanctuary:ruby_block",
"sanctuary:ruby_ore",
"sanctuary:deepslate_ruby_ore",
"sanctuary:sapphire_block",
"sanctuary:sapphire_ore",
"sanctuary:deepslate_sapphire_ore"
]
}
@@ -0,0 +1,11 @@
{
"replace": false,
"values": [
"sanctuary:ruby_block",
"sanctuary:ruby_ore",
"sanctuary:deepslate_ruby_ore",
"sanctuary:sapphire_block",
"sanctuary:sapphire_ore",
"sanctuary:deepslate_sapphire_ore"
]
}
@@ -0,0 +1,7 @@
{
"replace": false,
"values": [
"sanctuary:ruby",
"sanctuary:sapphire"
]
}
@@ -0,0 +1,32 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_emerald": {
"conditions": {
"items": [
{
"items": "sanctuary:ruby"
}
]
},
"trigger": "minecraft:inventory_changed"
},
"has_the_recipe": {
"conditions": {
"recipes": "sanctuary:ruby_block"
},
"trigger": "minecraft:recipe_unlocked"
}
},
"requirements": [
[
"has_the_recipe",
"has_emerald"
]
],
"rewards": {
"recipes": [
"sanctuary:ruby_block"
]
}
}
@@ -0,0 +1,32 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_emerald": {
"conditions": {
"items": [
{
"items": "sanctuary:sapphire"
}
]
},
"trigger": "minecraft:inventory_changed"
},
"has_the_recipe": {
"conditions": {
"recipes": "sanctuary:sapphire_block"
},
"trigger": "minecraft:recipe_unlocked"
}
},
"requirements": [
[
"has_the_recipe",
"has_emerald"
]
],
"rewards": {
"recipes": [
"sanctuary:sapphire_block"
]
}
}
@@ -0,0 +1,32 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_emerald_block": {
"conditions": {
"items": [
{
"items": "sanctuary:ruby_block"
}
]
},
"trigger": "minecraft:inventory_changed"
},
"has_the_recipe": {
"conditions": {
"recipes": "sanctuary:ruby"
},
"trigger": "minecraft:recipe_unlocked"
}
},
"requirements": [
[
"has_the_recipe",
"has_emerald_block"
]
],
"rewards": {
"recipes": [
"sanctuary:ruby"
]
}
}
@@ -0,0 +1,32 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_deepslate_ruby_ore": {
"conditions": {
"items": [
{
"items": "sanctuary:deepslate_ruby_ore"
}
]
},
"trigger": "minecraft:inventory_changed"
},
"has_the_recipe": {
"conditions": {
"recipes": "sanctuary:ruby_from_blasting_deepslate_ruby_ore"
},
"trigger": "minecraft:recipe_unlocked"
}
},
"requirements": [
[
"has_the_recipe",
"has_deepslate_ruby_ore"
]
],
"rewards": {
"recipes": [
"sanctuary:ruby_from_blasting_deepslate_ruby_ore"
]
}
}
@@ -0,0 +1,32 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_ruby_ore": {
"conditions": {
"items": [
{
"items": "sanctuary:ruby_ore"
}
]
},
"trigger": "minecraft:inventory_changed"
},
"has_the_recipe": {
"conditions": {
"recipes": "sanctuary:ruby_from_blasting_ruby_ore"
},
"trigger": "minecraft:recipe_unlocked"
}
},
"requirements": [
[
"has_the_recipe",
"has_ruby_ore"
]
],
"rewards": {
"recipes": [
"sanctuary:ruby_from_blasting_ruby_ore"
]
}
}
@@ -0,0 +1,32 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_deepslate_ruby_ore": {
"conditions": {
"items": [
{
"items": "sanctuary:deepslate_ruby_ore"
}
]
},
"trigger": "minecraft:inventory_changed"
},
"has_the_recipe": {
"conditions": {
"recipes": "sanctuary:ruby_from_smelting_deepslate_ruby_ore"
},
"trigger": "minecraft:recipe_unlocked"
}
},
"requirements": [
[
"has_the_recipe",
"has_deepslate_ruby_ore"
]
],
"rewards": {
"recipes": [
"sanctuary:ruby_from_smelting_deepslate_ruby_ore"
]
}
}
@@ -0,0 +1,32 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_ruby_ore": {
"conditions": {
"items": [
{
"items": "sanctuary:ruby_ore"
}
]
},
"trigger": "minecraft:inventory_changed"
},
"has_the_recipe": {
"conditions": {
"recipes": "sanctuary:ruby_from_smelting_ruby_ore"
},
"trigger": "minecraft:recipe_unlocked"
}
},
"requirements": [
[
"has_the_recipe",
"has_ruby_ore"
]
],
"rewards": {
"recipes": [
"sanctuary:ruby_from_smelting_ruby_ore"
]
}
}
@@ -0,0 +1,32 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_emerald_block": {
"conditions": {
"items": [
{
"items": "sanctuary:sapphire_block"
}
]
},
"trigger": "minecraft:inventory_changed"
},
"has_the_recipe": {
"conditions": {
"recipes": "sanctuary:sapphire"
},
"trigger": "minecraft:recipe_unlocked"
}
},
"requirements": [
[
"has_the_recipe",
"has_emerald_block"
]
],
"rewards": {
"recipes": [
"sanctuary:sapphire"
]
}
}
@@ -0,0 +1,32 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_deepslate_sapphire_ore": {
"conditions": {
"items": [
{
"items": "sanctuary:deepslate_sapphire_ore"
}
]
},
"trigger": "minecraft:inventory_changed"
},
"has_the_recipe": {
"conditions": {
"recipes": "sanctuary:sapphire_from_blasting_deepslate_sapphire_ore"
},
"trigger": "minecraft:recipe_unlocked"
}
},
"requirements": [
[
"has_the_recipe",
"has_deepslate_sapphire_ore"
]
],
"rewards": {
"recipes": [
"sanctuary:sapphire_from_blasting_deepslate_sapphire_ore"
]
}
}

Some files were not shown because too many files have changed in this diff Show More