Compare commits

...
Author SHA1 Message Date
koka 0f1efdec83 Sample texture grain for sculptures and preserve worn models (beta.117)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 12:18:10 +02:00
koka 5c666a4ebc Record verified beta.116 publication and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 07:50:34 +02:00
koka e3ca9fcd63 Apply block texture palettes to workshop sculptures (beta.116)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 07:49:00 +02:00
koka 22e58b4b91 Fix multiblock furnace slot mapping and explain shared cooking (beta.115)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 07:28:40 +02:00
24 changed files with 910 additions and 83 deletions
+28 -5
View File
@@ -20,11 +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.114** ajoute une fuite chaotique aux animaux volants portés :
un coup les fait décoller avec leur porteur, dans une direction aléatoire,
pendant deux à trois secondes. Le [ticket](docs/carried-flight-panic-beta114.md)
décrit le comportement et ses vérifications. La mise à jour est installée
dans **Sanctuary Beta**.
La version **beta.117** répartit les couleurs exactes des textures de blocs
sur les sculptures, y compris les modèles unis, avec un calcul Java borné
et conservé. Elle corrige aussi les sculptures devenues invisibles après
équipement en chapeau.
Le [ticket de livraison](docs/sculpture-texture-grain-beta117.md) conserve les
contrôles et le suivi de distribution. La livraison est en préparation ;
l'instance **Sanctuary Beta** publiée reste beta.116 jusqu'à sa synchronisation.
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.
@@ -35,6 +37,27 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
l’étude de Xaero et des alternatives libres.
## beta.117 — Grain des textures et sculptures en chapeau
Les petits cubes d'une sculpture reprennent plusieurs nuances des textures
de la matière. Le modèle reste intact dans le slot chapeau et au retrait.
[Contrat et vérifications](docs/sculpture-texture-grain-beta117.md).
## beta.116 — Matières de sculpture et Fourneau
Un bloc de matière devient une sculpture aux couleurs de sa texture. Hotbar,
orientation et ancrage conservés. Le Fourneau accepte les dépôts dans chacune
de ses 27 cases et explique sa chauffe commune.
[Contrat et vérifications](docs/sculpture-materials-beta116.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.116/Sanctuary-beta.116.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.116/Sanctuary-Test-beta.116.mrpack).
## beta.115 — Fourneau : dépôts et chauffe
Correctif du décalage par trois cases, combustible stable et progression
par cuisson. [Diagnostic et essais](docs/fourneau-transfers-beta115.md).
Étape locale intégrée à la livraison commune beta.116.
## beta.114 — Animaux portés et fuite en vol
Un animal volant porté entraîne son porteur après un coup. La trajectoire
+74
View File
@@ -0,0 +1,74 @@
# beta.115 — Dépôts et chauffe du Fourneau
Ticket sur `codex/fourneau-transfers-beta115`, après le signalement de piles
qui se réalignent pendant les dépôts rapides avec Maj, dans Ingrédients et
Combustibles, et d'une chauffe difficile à comprendre.
## Diagnostic
La méthode `mayPlace` de la case serveur utilisait `index`, masqué par le
champ hérité `Slot.index` (position affichée). Elle validait donc successivement
une entrée, un combustible et une sortie, au lieu des 27 entrées ou des
27 combustibles. Le client acceptait le dépôt puis le serveur le refusait.
Le test reproduit le refus dun déplacement de la case 1 vers la case 23.
Le correctif utilise explicitement `physicalIndex` pour la validation.
## Comportement
Les transferts Maj dans les vues complètes du Fourneau sont anticipés sur le
client, avec les mêmes destinations et règles de combustible que le serveur.
Les cases se remplissent dans l'ordre, après fusion des piles compatibles.
Un dépôt manuel conserve la case choisie ; aucun tri périodique des ingrédients
n'est ajouté. Les recherches masquent une partie du stock : leurs transferts
entrants continuent d'attendre la réponse du serveur, qui connaît tout le stock.
La chauffe emprunte du combustible à une autre case uniquement pendant le tick
natif. La pile non brûlée retourne immédiatement à sa case d'origine, avec tous
ses composants. Les restes natifs restent associés à leur cuisson : un seau de
lave devient un seau, puis un seau d'eau avec une éponge mouillée.
Les 27 entrées ont chacune une barre et une infobulle : cuisson en cours,
manque de combustible avec progression conservée, résultat bloqué, absence de
recette. Le panneau indique le nombre de cuissons et la réserve allumée en
secondes de travail cumulées. Ce n'est pas une durée réelle restante : les
cuissons parallèles se partagent la réserve. « Fonctionnement » explique les
onglets, les gestes et le rendement normal du combustible en FR/EN.
Les recettes, résultats, expérience et règles de trémies restent natifs.
Aucun changement de format, de monde ou de génération ; les 27 fours physiques
conservent l'unique copie des objets et de leurs temps de chauffe.
## Vérifications
Le test natif `Furnace115ClientChecks` passe avec un serveur intégré, sur un
nouveau monde plat de graine 115 : vrai glisser avec Maj sur neuf piles nommées,
remplissage consécutif, déplacement manuel vers la case 23 et répartition native
sur six cases de colonnes différentes. Les 27 cases de chaque onglet valident
leur rôle physique. Le combustible nommé conserve sa position et ses composants.
La cuisson parallèle, ses états synchronisés, la progression conservée à court
de combustible, les 27 cuissons ravitaillées, le seau de lave et l'éponge,
l'extraction des résultats et la sauvegarde native passent. Captures FR/EN
relues dans `build/furnace115-evidence/`. Marqueur `FURNACE115_PASS` dans
`build/furnace115-client.log`.
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussit
en 2 min 29 s, 125 tâches. Le panneau d'aide reçoit ensuite une dernière
vérification client et une reconstruction ; la livraison commune beta.116
repasse les contrôles complets en 2 min 23 s et le scénario Fourneau en 42 s.
Le GameTest dédié reste exclu conformément au refus antérieur de son EULA.
Pas d'essai Windows ou de réseau distant avec latence simulée. Aucun monde
personnel ouvert. Cette étape reste locale et rejoint la livraison commune
avec les matériaux de sculpture : [beta.116](sculpture-materials-beta116.md).
Archives locales vérifiées (même JAR Sanctuary, sources concordantes, aucune
classe de test ni changement de production extérieur au ticket) :
- `Sanctuary-beta.115.mrpack` : 10184430 octets, SHA-256
`8fcac63b1bf5a01f680fb46666924705db3a2675e102037278e9d650d04cbf22`.
- `Sanctuary-Test-beta.115.mrpack` : 10203354 octets, SHA-256
`5b98fdb8842690158a20b422d6aa5496d74e572b3429498ffb25476d24e87f8a`.
- JAR Sanctuary : `2066d3191d7ee27904927fab269d8160136f048742c3223713e8ed4fe90dd8f5`.
Reçu : `build/furnace115-artifact.json`.
+12
View File
@@ -14,6 +14,18 @@ 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.116 — palettes de sculpture et Fourneau
Le Clay Workshop utilise les nuances des textures de blocs ; le Fourneau
corrige les décalages de cases et explique la chauffe.
[Contrat et vérifications](sculpture-materials-beta116.md).
[Release beta.116](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.116)
publiée ; canal `c741870160f8a21f0a9793f20f0e624ebdbacdb6`. 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.116/`.
## beta.110 — version commune
Clay Workshop, Statuaire et import GLB intégrés aux œufs et à la neige saisonnière.
+102
View File
@@ -0,0 +1,102 @@
# beta.116 — Sculptures à partir des textures de blocs
Ticket sur `codex/sculpture-materials-beta116`, comprenant aussi le correctif
Fourneau beta.115 dans la livraison commune.
Le Clay Workshop accepte les objets-blocs comme matière. Le client prélève une
palette bornée dans la texture du bloc issue du pack actif, avec sa teinte, puis
le modèle conserve sa géométrie et transpose ses valeurs claires/sombres dans
cette palette. L'aperçu et le résultat utilisent le même calcul. Un bloc est
consommé par sculpture ; les neuf cases de hotbar restent les seules accessibles.
## Contrat des données et du réseau
Les sculptures existantes conservent exactement leurs voxels sauvegardés :
schéma 1 inchangé, identifiants `sanctuary:*` stables. Les nouvelles sculptures
stockent elles aussi leurs couleurs finales dans ce format. Aucun chargement
ne recolore les objets ou constructions existants. Orientation et ancrage
beta.113 inchangés, sans migration ni modification d'un monde personnel.
La sélection du menu ajoute l'identité de la matière et une palette de 256
couleurs opaques au maximum. Ce sont des données d'apparence fournies par le
client, comme le modèle déjà importé ; elles n'octroient ni objets ni propriétés
de bloc. Le serveur vérifie le menu, son jeton, l'accès à l'atelier, les limites
et l'objet effectivement présent. Il applique lui-même la palette et facture
chaque résultat. Un changement de matière invalide immédiatement l'ancien
résultat jusqu'à réception de la sélection correspondante. Les deux côtés
doivent utiliser beta.116. Aucun nouveau format de sauvegarde.
## Vérifications natives
`Sculpture116ClientChecks` passe en 1 min 7 s, sur un nouveau monde plat de
Minecraft 26.3, graine 116, puis la régression complète de l'atelier :
- Les 1 207 objets-blocs enregistrés ont une palette opaque, non vide et bornée.
- Fabrication payée avec pierre, planches de chêne, or, laine rouge, verre,
feuilles de chêne, argile et ardoise ; aperçu identique au résultat serveur.
- Conservation des tons clairs/sombres du modèle et de ses coordonnées, sans
convertir les 16 argiles colorées en une simple couleur unie.
- Une matière différente efface aussitôt l'ancien résultat ; une sélection
périmée ou un jeton incorrect ne fabrique rien et ne consomme aucun objet.
- Les conteneurs contenant des objets sont refusés ; ils peuvent servir de
matière une fois vidés. Les objets qui ne sont pas des blocs sont refusés.
- Bornes et opacité des palettes, véritable rechargement des ressources,
neuf cases de hotbar, stock plein, consommation exacte et restitution.
- Poses selon les quatre regards, accroche au bord, grille de voxels,
casse/repose, sauvegarde et reconnexion après suppression du fichier GLB.
- Interface FR/EN aux échelles 2/3, génération de plan au Métabli inchangée.
Marqueurs `SCULPTURE116_PALETTES`, `SCULPTURE116_MATERIALS_PASS`,
`SCULPTURE116_PASS` et régressions 106/111/112/113 dans
`build/sculpture116-client.log`. Captures relues dans
`build/sculpture116-evidence/`. Un libellé est raccourci en « Bloc » pour éviter
le contact avec « Sculpture » dans le cadre compact.
La palette vient de la texture représentative du modèle de bloc du pack actif,
avec la teinte locale ; une texture animée utilise sa première image. Les blocs
sans texture lisible utilisent leur couleur de carte. Il s'agit d'un transfert
de couleurs sur les voxels opaques, sans transparence, émission ou fonction
spéciale du bloc d'origine. Les sculptures existantes gardent leurs couleurs.
Essais natifs macOS avec serveur intégré ; pas de client Windows ni de test
LAN à plusieurs joueurs. Le serveur dédié de GameTest reste exclu selon le
refus antérieur de son EULA. Aucun monde personnel ouvert.
La régression native du Fourneau passe également dans beta.116, en 42 s :
`build/sculpture116-furnace.log`, captures FR/EN dans
`build/sculpture116-furnace-evidence/`. Les 27 cases, le glisser-déposer, les
combustibles et les états de cuisson restent conformes au ticket beta.115.
## Construction et archives
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussit
en 2 min 23 s : 125 tâches, dont 105 exécutées. Sources Java concordantes avec
les JAR sources, versions alignées et intégrité des deux MRpack vérifiées.
Le JAR conserve à l'identique le correctif Fourneau beta.115 ; seuls les
classes de matière de sculpture et libellés FR/EN changent dans la production.
Les classes de test et modèles GLB de test sont absents des archives.
- `Sanctuary-beta.116.mrpack` : 10189831 octets, SHA-256
`7df04a6e2a57e76a55ca57bd7520b05cbb573392690b401b868837498807d252`.
- `Sanctuary-Test-beta.116.mrpack` : 10208754 octets, SHA-256
`f4c9293e20384b05366d092fc7ee1abea275940a02373ee4036f73f0caa43747`.
- JAR Sanctuary :
`76a9205783c8b18506c32c9eef6c481d95fd3ba0746a2bdd558a194534a51a32`.
Reçu local : `build/sculpture116-artifact.json`.
## Publication et instance
La [release beta.116](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.116)
est publiée avec le JAR, les deux MRpack et l'amorçage Prism. Le tag immuable
désigne `e3ca9fcd6383f7646e4fea7e5098e7097ebb30aa` ; téléchargements publics
et empreintes vérifiés. Le canal packwiz avance au commit
`c741870160f8a21f0a9793f20f0e624ebdbacdb6`.
Deux synchronisations isolées, puis deux dans l'unique instance **Sanctuary
Beta**, réussissent. La seconde passe laisse les fichiers gérés inchangés,
avec un seul JAR Sanctuary beta.116. Minecraft 26.3, Loader 0.19.5 et Fabric
API 0.160.5+26.3 conservés. Les 923 fichiers personnels et réglages suivis
restent identiques ; aucun monde personnel n'a été ouvert. Sauvegarde des
fichiers remplacés dans `sanctuary-backups/before-beta.116/` de cette instance.
Reçus : `build/sculpture116-isolated.json`, `build/sculpture116-prism.json`.
+96
View File
@@ -0,0 +1,96 @@
# beta.117 — Grain des textures et sculptures en chapeau
Ticket sur `codex/sculpture-texture-grain-beta117`, Minecraft 26.3.
En beta.116, une couleur du modèle source était associée à une seule nuance
de matière. Un modèle uni restait donc uni malgré les pixels différents de la
texture. La sélection utilise maintenant les textures des faces du modèle de
bloc, avec leurs propres teintes du pack actif. Un échantillonnage de pixels
répartit leurs couleurs exactes dans les voxels, sans couleur interpolée.
## Coût et comportement
Au maximum 32 matériaux de faces et 16 × 16 échantillons par matériau sont lus,
puis ramenés à la palette existante de 256 entrées. Les répétitions conservent
la fréquence des nuances. Les pixels transparents sont écartés ; les textures
animées utilisent la première image. Les blocs sans texture lisible gardent
le repli antérieur sur la texture représentative, puis la couleur de carte.
La sélection d'une nuance utilise un mélange entier déterministe des coordonnées
de chaque voxel. Un modèle uni utilise toute la palette ; un modèle ombré
utilise des plages de nuances qui suivent ses zones claires et sombres.
Ce grain est un transfert de palette, pas une projection UV conservant les
motifs ou les veinures du bloc. Les voxels restent opaques.
Le calcul utilise Java standard et les modèles/textures déjà chargés par
Minecraft, sans bibliothèque supplémentaire. Aucun calcul de matière dans
le rendu par image. Le client ne rééchantillonne qu'au changement de matière
ou de ressources ; une rotation réutilise la palette. Le serveur conserve
le modèle recoloré pour les fabrications suivantes avec la même sélection.
## Chapeaux et données
Le système de blocs portés retirait `BLOCK_ENTITY_DATA` des objets pour isoler
les inventaires des coffres et fourneaux. Sur une sculpture, ce composant est
la géométrie elle-même : le slot contenait encore un objet, devenu invisible.
Les sculptures conservent désormais ce composant à l'équipement et au retrait.
Si une ancienne sculpture équipée a perdu ce composant, mais que sa session
de chapeau possède encore le modèle, il est restauré depuis cette donnée
serveur. La restauration ne fabrique aucune géométrie manquante. Les objets
déjà retirés et dépourvus de toute copie du modèle ne sont pas reconstructibles
par ce correctif. Les règles de contenu des coffres restent inchangées.
Identifiants, schéma 1 des sculptures, structure des accessoires et paquet
de sélection inchangés. Les couleurs déjà sauvegardées restent intactes.
Les deux côtés utilisent beta.117 pour le même calcul de prévisualisation et
de fabrication. Aucun monde personnel ouvert ni migration de sauvegarde.
## Vérifications
Le scénario client natif complet passe en 2 min 1 s sur macOS avec serveur
intégré, monde plat jetable de graine 116. Il reprend le scénario beta.116
avec un GLB entièrement uni, plus les régressions de l'atelier 106/111/112/113 :
- 1 207 objets-blocs échantillonnés, huit fabrications payées avec aperçu
identique au résultat serveur, consommation exacte d'un bloc.
- Répartition 25 % / 75 % de deux couleurs de texture sur un modèle uni,
aucune couleur inventée, conservation des zones claires et sombres.
- Couleurs réellement présentes dans les textures natives des faces, avec
plusieurs matériaux d'une bûche ; grain présent sur pierre, bois et minerai.
- Motif indépendant de l'ordre des voxels, stable et sauvegardé ; rechargement
des ressources, palette bornée, inventaire plein et sélections périmées.
- Vrais clics des slots hotbar/chapeau, conservation exacte des composants
et du nom personnalisé, icône et modèle porté visibles, retrait/rééquipement.
- Restauration d'un objet privé de sa géométrie depuis une ancienne session
équipée, puis sauvegarde et reconnexion du client avec le chapeau intact.
- Pose, bord visé, orientation, casse/repose, reconnexion sans fichier source,
menus FR/EN et génération du plan du Métabli inchangés.
Mesure indicative après échauffement, modèle maximal de 4 096 voxels,
101 applications sur le Mac de développement : médiane **0,283 ms**, p95
**0,971 ms**. Ce n'est pas une garantie de temps sur d'autres machines ; le
calcul n'est exécuté ni chaque image ni à chaque objet fabriqué identique.
Marqueurs `SCULPTURE117_GRAIN_PASS`, `SCULPTURE117_HAT_PASS`,
`SCULPTURE117_HAT_RECONNECT_PASS`, `SCULPTURE116_PASS` dans
`build/sculpture117-final-client.log`. Captures relues dans
`build/sculpture117-evidence/`. Pas d'essai Windows ou LAN. Le GameTest dédié
reste exclu selon le refus antérieur de son EULA.
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussit
en **3 min 7 s**, 125 tâches dont 105 exécutées. JAR sources concordants,
versions, contenu et intégrité des deux archives vérifiés. La comparaison
avec beta.116 limite les changements de production au calcul des matières,
à son cache dans le menu et au traitement des sculptures en chapeau.
Les modèles de test et classes de GameTest sont absents de la livraison.
- `Sanctuary-beta.117.mrpack` : 10191137 octets, SHA-256
`adbe5a40bbd050ebc84ff8aca61a7a3643dcf25ec8f2ac59b91561e48275cbbb`.
- `Sanctuary-Test-beta.117.mrpack` : 10210059 octets, SHA-256
`0dacb12b30bde66c647871101a4f4c6b0b58e7ceb29f7743398f8a569b958a6e`.
- JAR Sanctuary :
`1ce63f97f17c6098f3c86c6753f9b912bce39d88e05ba240423c38bdc97307ee`.
Reçu : `build/sculpture117-artifact.json`. Publication et synchronisation
finales en cours.
+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.114
pack_version=beta.114
mod_version=beta.117
pack_version=beta.117
resource_pack_version=beta.090
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+6
View File
@@ -93,6 +93,12 @@ tasks.named('runGameTest') {
}
tasks.named('processGametestResources') {
def sculpture116Client = providers.gradleProperty('sanctuarySculpture116ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySculpture116ClientTests', sculpture116Client)
if (sculpture116Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Sculpture116ClientChecks') }
def furnace115Client = providers.gradleProperty('sanctuaryFurnace115ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryFurnace115ClientTests', furnace115Client)
if (furnace115Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Furnace115ClientChecks') }
def carryPanic114Client = providers.gradleProperty('sanctuaryCarryPanic114ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryCarryPanic114ClientTests', carryPanic114Client)
if (carryPanic114Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.CarryPanic114ClientChecks') }
@@ -0,0 +1,103 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import fr.koka.sanctuary.mixin.MultiblockFurnaceAccess;
import fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor;
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.*;
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.*;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.*;
/** Native quick-transfer, arbitrary slot placement, shared cooking and synchronized feedback. */
public final class Furnace115ClientChecks implements FabricClientGameTest {
private static final BlockPos ORIGIN=new BlockPos(2,1,2);
private static MultiblockData.Machine oven;
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);
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 net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Furnace115 "+UUID.randomUUID());s.setSeed("115");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);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=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.onUpdateAbilities();p.getInventory().clearContent();p.teleportTo(1.5,1,1.5);
for(int x=-2;x<8;x++)for(int z=-2;z<8;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
oven=new MultiblockData.Machine(UUID.randomUUID(),ORIGIN,true,Direction.NORTH);for(var pos:oven.members())p.level().setBlockAndUpdate(pos,Blocks.FURNACE.defaultBlockState());check(Multiblocks.assemble(p,oven),"Assemble");
for(int i=0;i<9;i++){var stack=new ItemStack(Items.RAW_IRON,8);stack.set(DataComponents.CUSTOM_NAME,Component.literal("Batch "+i));p.getInventory().setItem(i,stack);}Multiblocks.open(p,oven,ORIGIN,0,"",0);
});
c.waitFor(m->m.gui.screen() instanceof MultiblockScreen&&m.player.containerMenu.getSlot(54).hasItem(),200);
// One native Shift-drag crosses nine hotbar stacks, with no mouse release between them.
c.getInput().holdKey(o->o.keyShift);move(c,54);c.runOnClient(m->m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,InputConstants.MOD_SHIFT),1));
for(int i=55;i<63;i++)move(c,i);
c.runOnClient(m->m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,InputConstants.MOD_SHIFT),0));c.getInput().releaseKey(o->o.keyShift);
c.waitTicks(8);c.takeScreenshot("furnace115-inputs");
c.waitFor(m->m.player.containerMenu.getSlot(8).hasItem()&&!m.player.containerMenu.getSlot(62).hasItem(),150);
c.runOnClient(m->{for(int i=0;i<9;i++)check(m.player.containerMenu.getSlot(i).getItem().getHoverName().getString().equals("Batch "+i),"Shift-drag fills consecutive UI slots, no three-column gaps");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);for(int i=0;i<9;i++)check(stock.getItem(i*3).getCount()==8,"Physical inputs correspond to visible slots");});
// Move an ingredient to a distant, arbitrary slot using real inventory packets.
click(c,1,0,ContainerInput.PICKUP);click(c,23,0,ContainerInput.PICKUP);c.waitTicks(10);
c.runOnClient(m->{check(m.player.containerMenu.getSlot(1).getItem().isEmpty()&&m.player.containerMenu.getSlot(23).getItem().getCount()==8,"Manual placement remains in chosen cell: "+m.player.containerMenu.slots.stream().filter(slot->slot.hasItem()).map(slot->slot.index+":"+slot.getItem()).toList()+", cursor="+m.player.containerMenu.getCarried());});
click(c,23,0,ContainerInput.PICKUP);
click(c,-999,net.minecraft.world.inventory.AbstractContainerMenu.getQuickcraftMask(0,0),ContainerInput.QUICK_CRAFT);
for(int i:new int[]{1,9,10,13,14,22})click(c,i,net.minecraft.world.inventory.AbstractContainerMenu.getQuickcraftMask(1,0),ContainerInput.QUICK_CRAFT);
click(c,-999,net.minecraft.world.inventory.AbstractContainerMenu.getQuickcraftMask(2,0),ContainerInput.QUICK_CRAFT);click(c,23,0,ContainerInput.PICKUP);c.waitTicks(6);
c.runOnClient(m->{for(int i:new int[]{1,9,10,13,14,22})check(m.player.containerMenu.getSlot(i).getItem().getCount()==1,"Native drag distribution keeps every chosen column");check(m.player.containerMenu.getSlot(23).getItem().getCount()==2&&m.player.containerMenu.getCarried().isEmpty(),"Distributed count conserved");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);check(stock.getItem(69).getCount()==2&&stock.getItem(3).getCount()==1,"Manual distribution matches server");for(int i=0;i<27;i++)check(p.containerMenu.getSlot(i).mayPlace(new ItemStack(Items.RAW_IRON)),"Every visible input validates its physical input slot, not Slot.index");reset(stock);stock.setItem(0,new ItemStack(Items.RAW_IRON,4));stock.setItem(18,new ItemStack(Items.RAW_GOLD,4));
var coal=new ItemStack(Items.COAL,5);coal.set(DataComponents.CUSTOM_NAME,Component.literal("Stationary fuel"));stock.setItem(79,coal);
for(int tick=0;tick<205;tick++)MultiblockCooking.tick(p.level(),oven);
check(stock.getItem(79).getCount()==4&&stock.getItem(79).getHoverName().getString().equals("Stationary fuel"),"Named unburnt fuel stays in original cell");
for(int i=1;i<79;i+=3)check(stock.getItem(i).isEmpty(),"No displaced coal in other slots");
check(stock.getItem(2).is(Items.IRON_INGOT)&&stock.getItem(20).is(Items.GOLD_INGOT),"Shared heat cooks both arbitrary lanes");
Multiblocks.open(p,oven,ORIGIN,0,"",0);
});
c.waitTicks(6);c.runOnClient(m->{var menu=(MultiblockMenu)m.player.containerMenu;check(menu.cooking==2&&menu.heat>0&&menu.laneState(6)==MultiblockMenu.COOKING,"Real per-lane feedback synchronizes");});c.takeScreenshot("furnace115-cooking");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);reset(stock);
stock.setItem(0,new ItemStack(Items.RAW_IRON));stock.setItem(3,new ItemStack(Items.DIAMOND));stock.setItem(6,new ItemStack(Items.RAW_GOLD));stock.setItem(8,new ItemStack(Items.GOLD_INGOT,64));
var a=(MultiblockFurnaceAccess)stock.cell(0);a.sanctuary$progress(80);a.sanctuary$total(200);p.containerMenu.broadcastChanges();
});c.waitTicks(6);
c.runOnClient(m->{var menu=(MultiblockMenu)m.player.containerMenu;check(menu.laneState(0)==MultiblockMenu.NO_FUEL&&menu.laneProgress(0)==40,"Paused progress is visible and preserved");check(menu.laneState(1)==MultiblockMenu.NO_RECIPE,"Invalid recipe explained");check(menu.laneState(2)==MultiblockMenu.BLOCKED,"Blocked output explained");check(menu.cooking==0&&menu.heat==0,"Cold furnace clearly shown");});c.takeScreenshot("furnace115-paused");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();cookingRegressions(p);var stock=new MultiblockContainer(p.level(),oven);reset(stock);
for(int i=0;i<9;i++){var stack=new ItemStack(Items.COAL,5);stack.set(DataComponents.CUSTOM_NAME,Component.literal("Fuel "+i));p.getInventory().setItem(i,stack);}Multiblocks.open(p,oven,ORIGIN,0,"",1);
});c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.section()==1&&menu.getSlot(54).hasItem(),100);
// No cooking in this fixture: compare the predicted UI in the same call, before any server reply.
c.runOnClient(m->{var menu=m.player.containerMenu;for(int i=0;i<9;i++){
m.gameMode.handleContainerInput(menu.containerId,54+i,0,ContainerInput.QUICK_MOVE,m.player);
check(menu.getSlot(54+i).getItem().isEmpty()&&menu.getSlot(i).getItem().getCount()==5,"Immediate Shift prediction uses consecutive fuel cells");}
check(!menu.getSlot(9).mayPlace(new ItemStack(Items.DIAMOND)),"Client rejects invalid fuel like server");});
c.waitTicks(8);server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);for(int i=0;i<9;i++)check(stock.getItem(i*3+1).getCount()==5,"Fuel server mapping matches prediction");for(int i=0;i<27;i++){check(p.containerMenu.getSlot(i).mayPlace(new ItemStack(Items.COAL)),"All 27 fuel cells accept fuel");check(!p.containerMenu.getSlot(i).mayPlace(new ItemStack(Items.DIAMOND)),"All 27 fuel cells refuse other items");}});c.takeScreenshot("furnace115-fuel");
c.runOnClient(m->{var screen=(MultiblockScreen)m.gui.screen();screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(Multiblocks.text("how_to_cook").getString())).findFirst().orElseThrow().onPress(enter());check(screen.helpVisible(),"Help button opens its explanation");});c.waitTicks(5);c.takeScreenshot("furnace115-help");
var reload=c.computeOnClient(m->{m.getLanguageManager().setSelected("fr_fr");return m.reloadResourcePacks();});c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(3);c.takeScreenshot("furnace115-help-fr");
System.out.println("FURNACE115_PASS native Shift-drag, consecutive input/fuel mapping, immediate prediction, arbitrary placement, stationary named fuel, heat/output/pause/recipe feedback, fuel budget, refuelling, buckets, save and result extraction");
}finally{intro.set(false);}
}
private static void cookingRegressions(ServerPlayer p){
var stock=new MultiblockContainer(p.level(),oven);reset(stock);stock.setItem(0,new ItemStack(Items.WET_SPONGE));stock.setItem(79,new ItemStack(Items.LAVA_BUCKET));for(int t=0;t<205;t++)MultiblockCooking.tick(p.level(),oven);
check(stock.getItem(2).is(Items.SPONGE)&&stock.getItem(1).is(Items.WATER_BUCKET)&&stock.getItem(79).isEmpty(),"Native lava and wet sponge remainder");
reset(stock);for(int i=0;i<81;i+=3)stock.setItem(i,new ItemStack(Items.RAW_IRON));stock.setItem(79,new ItemStack(Items.COAL));for(int t=0;t<90;t++)MultiblockCooking.tick(p.level(),oven);
int partial=((MultiblockFurnaceAccess)stock.cell(0)).sanctuary$progress();check(partial>0&&partial<200,"One coal cannot cook all 27 items");for(int t=0;t<30;t++)MultiblockCooking.tick(p.level(),oven);check(((MultiblockFurnaceAccess)stock.cell(0)).sanctuary$progress()==partial,"No lost paid progress when out of fuel");
stock.setItem(79,new ItemStack(Items.COAL,4));for(int t=0;t<205;t++)MultiblockCooking.tick(p.level(),oven);int produced=0;for(int i=2;i<81;i+=3)produced+=stock.getItem(i).getCount();check(produced==27,"Refuelling completes all lanes exactly once");
var f=(AbstractFurnaceBlockEntity)stock.cell(0);var restored=(AbstractFurnaceBlockEntity)BlockEntity.loadStatic(f.getBlockPos(),f.getBlockState(),f.saveWithFullMetadata(p.level().registryAccess()),p.level().registryAccess());check(((MultiblockFurnaceAccess)restored).sanctuary$heat()==((MultiblockFurnaceAccess)f).sanctuary$heat(),"Native heat survives save");
Multiblocks.open(p,oven,ORIGIN,0,"",2);var menu=(MultiblockMenu)p.containerMenu;check(!menu.getSlot(0).mayPlace(new ItemStack(Items.STONE)),"Results refuse input");check(!menu.quickMoveStack(p,0).isEmpty()&&stock.getItem(2).isEmpty(),"Native result slot can extract");
}
private static void reset(MultiblockContainer stock){stock.clearContent();for(int i=0;i<81;i+=3){var a=(MultiblockFurnaceAccess)stock.cell(i);a.sanctuary$heat(0);a.sanctuary$progress(0);}}
private static void click(ClientGameTestContext c,int slot,int button,ContainerInput type){c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,slot,button,type,m.player));}
private static void move(ClientGameTestContext c,int index){c.runOnClient(m->{var a=(AbstractContainerScreenAccessor)m.gui.screen();var slot=m.player.containerMenu.getSlot(index);var w=m.getWindow();double x=a.sanctuary$getLeftPos()+slot.x+8,y=a.sanctuary$getTopPos()+slot.y+8;double sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();m.mouseHandler.onMove(w.handle(),x*sx,y*sy,(x-m.mouseHandler.getScaledXPos(w))*sx,(y-m.mouseHandler.getScaledYPos(w))*sy);m.mouseHandler.handleAccumulatedMovement();});c.waitTick();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,84 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.plans.*;
import java.nio.file.*;
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.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
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.component.DataComponents;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.ItemContainerContents;
import net.minecraft.world.level.block.Blocks;
/** All registered block materials, native paid crafts, stale input rejection and pack reload. */
public final class Sculpture116ClientChecks implements FabricClientGameTest {
private static final BlockPos WORKSHOP=new BlockPos(2,1,2);
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);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 net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
Path file=PlanClient.directory().resolve("sculpture116-"+UUID.randomUUID()+".glb");
try{
Files.createDirectories(file.getParent());Files.write(file,Statuary106ClientChecks.fixture(true));var asset=GlbModel.load(file);var source=VoxelSculpture.of(asset.id(),asset.name(),StatueVoxelizer.miniature(asset.faces()));
try{new SculpturePalette(List.of());throw new AssertionError("Empty palette accepted");}catch(IllegalArgumentException expected){}
try{new SculpturePalette(Collections.nCopies(257,-1));throw new AssertionError("Oversized palette accepted");}catch(IllegalArgumentException expected){}
try{new SculpturePalette(List.of(0));throw new AssertionError("Transparent color accepted");}catch(IllegalArgumentException expected){}
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;ItemStack hat;
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sculpture116 "+UUID.randomUUID());s.setSeed("116");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);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 server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.onUpdateAbilities();p.getInventory().clearContent();p.teleportTo(.5,1,.5);for(int x=-4;x<8;x++)for(int z=-4;z<8;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.level().setBlockAndUpdate(WORKSHOP,Statuary.WORKSHOP.defaultBlockState());Statuary.open(p,WORKSHOP);});
c.waitForScreen(ClayWorkshopScreen.class);c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();((ClayWorkshopScreen)m.gui.screen()).load(file);});
c.waitFor(m->!((ClayWorkshopScreen)m.gui.screen()).busy()&&((ClayWorkshopScreen)m.gui.screen()).selected()!=null,200);
c.runOnClient(m->{int count=0;for(var item:BuiltInRegistries.ITEM)if(item instanceof BlockItem){var stack=new ItemStack(item);check(ClayWorkshopMenu.material(stack),"Empty registered block accepted: "+item);var palette=SculptureMaterialSampler.sample(stack);check(palette!=null&&palette.colors().size()<=256&&palette.colors().stream().allMatch(v->(v>>>24)==255),"Bounded opaque palette: "+item);count++;}check(count>1000,"Whole block registry sampled");System.out.println("SCULPTURE116_PALETTES "+count+" registered block items");
var dark=new ItemStack(Items.SHULKER_BOX);dark.set(DataComponents.CONTAINER,ItemContainerContents.fromItems(List.of(new ItemStack(Items.DIAMOND))));check(!ClayWorkshopMenu.material(dark),"Filled container cannot lose contents");check(!ClayWorkshopMenu.material(new ItemStack(Items.DIAMOND)),"Non-block item refused");
check(source.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()==1,"Native paid crafts start from a uniform model");Sculpture117Checks.run(source);});
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);
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");
c.takeScreenshot("sculpture116-"+BuiltInRegistries.ITEM.getKey(item).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()==1,100);
server.waitFor(s->{var menu=s.getPlayerList().getPlayers().getFirst().containerMenu;return menu.getCarried().is(Statuary.ITEM)&&menu.getSlot(0).getItem().getCount()==1;},100);
server.runOnServer(s->{var menu=(ClayWorkshopMenu)s.getPlayerList().getPlayers().getFirst().containerMenu;check(expected.equals(StatuaryEntity.sculpture(menu.getCarried()))&&menu.getSlot(0).getItem().getCount()==1,"Exactly one actual block consumed per sculpture");});
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,ClayWorkshopMenu.HOTBAR_START,0,ContainerInput.PICKUP,m.player));c.waitFor(m->m.player.containerMenu.getCarried().isEmpty(),100);server.waitFor(s->s.getPlayerList().getPlayers().getFirst().containerMenu.getCarried().isEmpty(),100);
}
check(rendered.size()>=7,"Distinct materials produce distinct sculptures");
c.waitTicks(6);var stone=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(Items.STONE)));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var menu=(ClayWorkshopMenu)p.containerMenu;menu.getSlot(0).set(new ItemStack(Items.DIAMOND_BLOCK));menu.select(p,source,Items.STONE,stone);check(menu.getSlot(1).getItem().isEmpty(),"Stale material palette rejected by server");});
var diamond=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(Items.DIAMOND_BLOCK)).apply(source));c.waitFor(m->diamond.equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())),180);
c.runOnClient(m->{var menu=(ClayWorkshopMenu)m.player.containerMenu;ClientPlayNetworking.send(new ClayWorkshopMenu.Select(menu.containerId,UUID.randomUUID(),source,Items.DIAMOND_BLOCK,stone));});c.waitTicks(6);
server.runOnServer(s->{var menu=(ClayWorkshopMenu)s.getPlayerList().getPlayers().getFirst().containerMenu;check(diamond.equals(StatuaryEntity.sculpture(menu.getSlot(1).getItem()))&&menu.getSlot(0).getItem().getCount()==1,"Forged menu token cannot change recipe or spend input");});
var before=c.computeOnClient(m->m.getModelManager().getBlockStateModelSet());var reload=c.computeOnClient(m->m.reloadResourcePacks());c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(8);
c.runOnClient(m->{check(before!=m.getModelManager().getBlockStateModelSet(),"Actual resource reload replaces sampling generation");check(diamond.equals(StatuaryEntity.sculpture(((ClayWorkshopScreen)m.gui.screen()).preview())),"Preview refreshes after pack reload");});
hat=SculptureHat117Checks.exercise(c,server,diamond);
System.out.println("SCULPTURE116_MATERIALS_PASS all block palettes, eight native paid materials, preview equality, shading, stale selections, input identity, token, palette bounds, containers and resource reload");
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);c.waitTicks(12);
SculptureHat117Checks.verifyEquipped(c,world.getServer(),hat);
System.out.println("SCULPTURE117_HAT_RECONNECT_PASS equipped sculpture survives native save and fresh client reconnect");
}
}catch(java.io.IOException e){throw new AssertionError(e);}finally{intro.set(false);try{Files.deleteIfExists(file);}catch(java.io.IOException ignored){}}
new Statuary106ClientChecks().runTest(c);
System.out.println("SCULPTURE116_PASS materials plus full workshop/hotbar, placement, anchoring, drops, reconnect, FR/EN and Metabli regression");
}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,59 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart;
import net.minecraft.core.*;
import net.minecraft.util.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
/** Regression for flat-color models, exact texture colors, deterministic grain and bounded CPU work. */
final class Sculpture117Checks {
static void run(VoxelSculpture source){
var cells=new ArrayList<VoxelSculpture.Cell>();
for(int z=0;z<16;z++)for(int y=0;y<16;y++)for(int x=0;x<16;x++)cells.add(new VoxelSculpture.Cell(x,y,z,0xFF808080));
var flat=new VoxelSculpture(source.modelId(),"Flat",16,16,16,cells);
var colors=new ArrayList<Integer>();colors.addAll(Collections.nCopies(64,0xFF223344));colors.addAll(Collections.nCopies(192,0xFFCCBBAA));
var weighted=new SculpturePalette(colors);var out=weighted.apply(flat);
long dark=out.cells().stream().filter(v->v.color()==0xFF223344).count();
check(dark>900&&dark<1150,"Uniform source retains sampled 25% dark / 75% light texture distribution");
check(out.cells().stream().allMatch(v->colors.contains(v.color())),"Never invent an averaged color");
var reversed=new ArrayList<>(flat.cells());Collections.reverse(reversed);
var shuffled=weighted.apply(new VoxelSculpture(flat.modelId(),flat.name(),16,16,16,reversed));
check(Arrays.equals(out.packed(),shuffled.cells().reversed().stream().flatMapToInt(v->java.util.stream.IntStream.of(v.key(),v.color())).toArray()),"Sampling is independent of cell iteration order");
check(out.equals(weighted.apply(flat))&&out.equals(StatuaryEntity.decode(StatuaryEntity.encode(out))),"Stable pattern and persisted final colors");
var gradient=new ArrayList<Integer>();for(int i=0;i<256;i++)gradient.add(0xFF000000|i<<16|i<<8|i);
var palette=new SculpturePalette(gradient);
var shaded=new VoxelSculpture(flat.modelId(),flat.name(),16,16,16,cells.stream().map(v->new VoxelSculpture.Cell(v.x(),v.y(),v.z(),v.x()<8?0xFF000000:0xFFFFFFFF)).toList());
var result=palette.apply(shaded);
check(result.cells().stream().filter(v->v.x()<8).allMatch(v->SculpturePalette.lightness(v.color())<.5),"Dark regions sample dark shades");
check(result.cells().stream().filter(v->v.x()>=8).allMatch(v->SculpturePalette.lightness(v.color())>=.5),"Light regions sample light shades");
check(result.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>200,"Grain remains inside source shading regions");
var single=new SculpturePalette(List.of(0xFF123456));check(single.apply(flat).cells().stream().allMatch(v->v.color()==0xFF123456),"Truly uniform block textures remain uniform");
for(var item:List.of(Items.STONE,Items.OAK_PLANKS,Items.COAL_ORE,Items.OAK_LOG)){
var sampled=SculptureMaterialSampler.sample(new ItemStack(item));var sculpture=sampled.apply(flat);
check(sculpture.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>2,"Actual texture grain on flat model: "+item);
check(sculpture.cells().stream().allMatch(v->sampled.colors().contains(v.color())),"Exact sampled pixels: "+item);
}
// Log tops and bark are separate native face materials; both must supply exact colors.
var mc=Minecraft.getInstance();var state=Blocks.OAK_LOG.defaultBlockState();var parts=new ArrayList<BlockStateModelPart>();
mc.getModelManager().getBlockStateModelSet().get(state).collectParts(RandomSource.create(state.getSeed(BlockPos.ZERO)),parts);
var bySprite=new HashMap<String,Set<Integer>>();
for(var part:parts)for(var face:Direction.values())for(var quad:part.getQuads(face)){
var sample=PlanMaterials.faceSample(state,quad.materialInfo());var set=bySprite.computeIfAbsent(quad.materialInfo().sprite().contents().name().toString(),k->new HashSet<>());
for(int pixel:sample.pixels().argb()){int color=ARGB.multiply(pixel,sample.tint());if((color>>>24)>=128)set.add(color|0xFF000000);}
}
check(bySprite.size()>=2,"Native log has distinct face textures");var sampled=SculptureMaterialSampler.sample(new ItemStack(Items.OAK_LOG));
var union=new HashSet<Integer>();bySprite.values().forEach(union::addAll);
check(sampled.colors().stream().allMatch(union::contains),"All palette colors belong to baked face textures");
for(var faceColors:bySprite.values())check(sampled.colors().stream().anyMatch(faceColors::contains),"Each log texture contributes pixels");
for(int i=0;i<100;i++)palette.apply(flat);
long[] times=new long[101];for(int i=0;i<times.length;i++){long start=System.nanoTime();var measured=palette.apply(flat);times[i]=System.nanoTime()-start;check(measured.cells().size()==4096,"Full bounded model");}
Arrays.sort(times);
System.out.printf(java.util.Locale.ROOT,"SCULPTURE117_PERF 4096 voxels median %.3f ms, p95 %.3f ms%n",times[50]/1e6,times[95]/1e6);
System.out.println("SCULPTURE117_GRAIN_PASS flat model, weighted exact pixels, dark/light regions, deterministic cells, persistence and native face materials");
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,50 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.plans.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.client.gui.screens.inventory.InventoryScreen;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
/** Actual accessory-slot transfers and recovery from a saved beta.116 head session. */
final class SculptureHat117Checks {
static ItemStack exercise(ClientGameTestContext c,TestServerContext server,VoxelSculpture sculpture){
var original=StatuaryEntity.item(sculpture);original.set(DataComponents.CUSTOM_NAME,Component.literal("Sculpture chapeau 117"));
c.runOnClient(m->m.gui.screen().onClose());
server.waitFor(s->{var p=s.getPlayerList().getPlayers().getFirst();return p.containerMenu==p.inventoryMenu;},100);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();p.getInventory().setItem(0,original.copy());p.inventoryMenu.broadcastFullState();});
c.waitFor(m->ItemStack.matches(m.player.inventoryMenu.getSlot(36).getItem(),original),100);
c.runOnClient(m->m.gui.setScreen(new InventoryScreen(m.player)));
click(c,36);c.waitFor(m->m.player.inventoryMenu.getCarried().is(Statuary.ITEM),100);
click(c,66);c.waitTicks(12);verifyEquipped(c,server,original);
c.takeScreenshot("sculpture117-hat-inventory");
click(c,66);c.waitFor(m->m.player.inventoryMenu.getCarried().is(Statuary.ITEM),100);
click(c,36);
server.waitFor(s->{var p=s.getPlayerList().getPlayers().getFirst();return p.inventoryMenu.getCarried().isEmpty()&&HeadEquipment.get(p).isEmpty()&&ItemStack.matches(p.getInventory().getItem(0),original);},100);
c.waitFor(m->ItemStack.matches(m.player.inventoryMenu.getSlot(36).getItem(),original)&&m.player.inventoryMenu.getSlot(66).getItem().isEmpty(),100);
c.runOnClient(m->check(!new StatuaryRenderer.ItemRenderer().extractArgument(m.player.inventoryMenu.getSlot(36).getItem()).isEmpty(),"Returned sculpture still renders in hotbar"));
click(c,36);click(c,66);c.waitTicks(8);verifyEquipped(c,server,original);
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var saved=HeadBlockService.save(p);check(saved!=null,"Authoritative worn model is saved");
var damaged=original.copy();damaged.remove(DataComponents.BLOCK_ENTITY_DATA);
AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,damaged);HeadEquipment.retainData(p,saved);
HeadBlockService.session(p);p.inventoryMenu.broadcastChanges();
check(ItemStack.matches(HeadEquipment.get(p),original),"Recover already stripped sculpture from its saved worn model");
});
c.waitTicks(8);verifyEquipped(c,server,original);
c.runOnClient(m->m.gui.screen().onClose());
System.out.println("SCULPTURE117_HAT_PASS native equip/remove/re-equip, slot mesh, exact components and old worn-session recovery");
return original;
}
static void verifyEquipped(ClientGameTestContext c,TestServerContext server,ItemStack original){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(ItemStack.matches(HeadEquipment.get(p),original),"Equipped stack retains its geometry and name");check(p.getInventory().getItem(0).isEmpty()&&p.inventoryMenu.getCarried().isEmpty(),"Exactly one sculpture moved into head slot");});
c.waitFor(m->ItemStack.matches(m.player.inventoryMenu.getSlot(66).getItem(),original)&&StatuaryEntity.sculpture(original).equals(StatuaryEntity.sculpture(HeadCosmeticService.appearance(m.player))),150);
c.runOnClient(m->check(!new StatuaryRenderer.ItemRenderer().extractArgument(m.player.inventoryMenu.getSlot(66).getItem()).isEmpty(),"Equipped slot retains visible item geometry"));
}
private static void click(ClientGameTestContext c,int slot){c.runOnClient(m->m.gameMode.handleContainerInput(m.player.inventoryMenu.containerId,slot,0,ContainerInput.PICKUP,m.player));}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -46,8 +46,8 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
try{
System.out.println("STATUARY106_LIBRARY "+file);Files.createDirectories(file.getParent());byte[] fixture=fixture();Files.write(file,fixture);
var asset=GlbModel.load(file);var pixels=StatueVoxelizer.miniature(asset.faces());var source=VoxelSculpture.of(asset.id(),asset.name(),pixels);
MODEL.set(source.modelId());check(source.height()==16&&source.width()<16,"Proportions, 16 voxel longest axis");check(source.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>1,"Embedded PNG retains multiple colors");
var asset=GlbModel.load(file);var pixels=StatueVoxelizer.miniature(asset.faces());var originalSource=VoxelSculpture.of(asset.id(),asset.name(),pixels);
MODEL.set(originalSource.modelId());check(originalSource.height()==16&&originalSource.width()<16,"Proportions, 16 voxel longest axis");check(originalSource.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>1,"Embedded PNG retains multiple colors");
check(CreationLibrary.files(CreationLibrary.Kind.MODEL).contains(file)&&!CreationLibrary.files(CreationLibrary.Kind.SCHEMATIC).contains(file),"One folder, different filters");
var invalid=fixture.clone();ByteBuffer.wrap(invalid).order(ByteOrder.LITTLE_ENDIAN).putInt(8,1);reject(invalid,"GLB header length");
var oversize=new byte[GlbModel.MAX_BYTES+1];reject(oversize,"File bound");
@@ -57,11 +57,12 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
reject(edit(fixture,j->{var a=new JsonArray();a.add(0);j.getAsJsonArray("nodes").get(0).getAsJsonObject().add("children",a);}),"Cyclic scene");
var interop=net.fabricmc.loader.api.FabricLoader.getInstance().getGameDir().resolve("../../../../../build/statuary106-evidence/BoxTextured.glb").normalize();
if(Files.exists(interop)){var box=GlbModel.read(interop);check(box.size()==12,"Independent Khronos indexed GLB");check(!StatueVoxelizer.miniature(box).isEmpty(),"Independent textured box voxelizes");System.out.println("STATUARY106_INTEROP_PASS Khronos BoxTextured GLB");}
check(source.rotate().rotate().rotate().rotate().equals(source),"Four rotations preserve ID and data");
try{new VoxelSculpture(source.modelId(),"bad",16,16,16,List.of(new VoxelSculpture.Cell(0,0,0,-1),new VoxelSculpture.Cell(0,0,0,-1)));throw new AssertionError("Duplicate cell accepted");}catch(IllegalArgumentException expected){}
check(originalSource.rotate().rotate().rotate().rotate().equals(originalSource),"Four rotations preserve ID and data");
try{new VoxelSculpture(originalSource.modelId(),"bad",16,16,16,List.of(new VoxelSculpture.Cell(0,0,0,-1),new VoxelSculpture.Cell(0,0,0,-1)));throw new AssertionError("Duplicate cell accepted");}catch(IllegalArgumentException expected){}
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 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");
@@ -81,7 +82,7 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
c.waitFor(m->m.player.containerMenu.getSlot(ClayWorkshopMenu.HOTBAR_START).getItem().getCount()==3,100);
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,ClayWorkshopMenu.HOTBAR_START,0,ContainerInput.QUICK_MOVE,m.player));
c.waitFor(m->m.player.containerMenu.getSlot(1).getItem().is(Statuary.ITEM),100);
c.runOnClient(m->check(m.player.containerMenu.getSlot(1).getItem().getHoverName().getString().equals("Clay Sculpture"),"Crafted sculpture has translated type name EN"));
c.runOnClient(m->check(m.player.containerMenu.getSlot(1).getItem().getHoverName().getString().equals("Sculpture"),"Crafted sculpture has translated type name EN"));
c.takeScreenshot("statuary106-workshop-en");
checkWorkshopLayout(c);
c.runOnClient(m->{m.options.guiScale().set(3);m.resizeGui();});c.waitTicks(3);checkWorkshopLayout(c);c.takeScreenshot("statuary111-workshop-en-scale3");
@@ -97,8 +98,9 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
p.getInventory().setItem(18,ItemStack.EMPTY);p.getInventory().setItem(Inventory.SLOT_OFFHAND,ItemStack.EMPTY);fr.koka.sanctuary.inventory.SanctuaryOverflowAccess.of(p).setItem(17,ItemStack.EMPTY);
});
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();});
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;ORIGINAL.set(StatuaryEntity.sculpture(menu.getSlot(1).getItem()));check(ORIGINAL.get().equals(source),"Plain clay keeps exact colors and model ID");
for(var family:ColoredBricks.families()){var colored=ClayWorkshopMenu.material(source,new ItemStack(family.clay()));check(colored.modelId().equals(source.modelId())&&colored.cells().size()==source.cells().size()&&colored.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()==1,"All 16 clays keep geometry and ID, recolor uniformly");}});
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;ORIGINAL.set(StatuaryEntity.sculpture(menu.getSlot(1).getItem()));check(ORIGINAL.get().equals(source),"Plain clay applies its sampled palette and preserves model ID");
});
c.runOnClient(m->{for(var family:ColoredBricks.families()){var palette=SculptureMaterialSampler.sample(new ItemStack(family.clay()));var colored=palette.apply(originalSource);check(colored.modelId().equals(source.modelId())&&colored.cells().size()==source.cells().size()&&colored.cells().stream().allMatch(cell->palette.colors().contains(cell.color())),"All 16 clays keep geometry and use actual texture shades");}});
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),100);
c.waitTicks(6);
@@ -140,13 +142,14 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
server.waitFor(s->player(s).level().getBlockState(west).is(Statuary.BLOCK),100);
server.runOnServer(s->check(player(s).level().getBlockState(west).getValue(Statuary.FACING)==Direction.WEST,"Dropped statue takes new gaze"));
server.runOnServer(s->player(s).teleportTo(.5,1,.5));c.waitFor(m->m.player.distanceToSqr(.5,1,.5)<.05,100);
// Reuse the same file with colored clay; server, rather than the client, chooses the tint.
// Reuse the file with colored clay; server applies the palette bound to that actual material.
server.runOnServer(s->player(s).getInventory().setItem(player(s).getInventory().getSelectedSlot(),ItemStack.EMPTY));c.waitTicks(4);open(c);
c.runOnClient(m->((ClayWorkshopScreen)m.gui.screen()).load(file));c.waitFor(m->!((ClayWorkshopScreen)m.gui.screen()).busy()&&((ClayWorkshopScreen)m.gui.screen()).selected()!=null,200);c.waitTicks(10);
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;var blue=ColoredBricks.families().stream().filter(f->f.color()==DyeColor.BLUE).findFirst().orElseThrow();menu.getSlot(0).set(new ItemStack(blue.clay(),2));menu.broadcastChanges();});
c.waitFor(m->m.player.containerMenu.getSlot(1).getItem().is(Statuary.ITEM),100);language(c,"fr_fr");c.runOnClient(m->check(m.player.containerMenu.getSlot(1).getItem().getHoverName().getString().equals("Sculpture dargile"),"Crafted sculpture has translated type name FR"));c.takeScreenshot("statuary106-workshop-fr-blue");
c.waitFor(m->m.player.containerMenu.getSlot(1).getItem().is(Statuary.ITEM),100);language(c,"fr_fr");c.runOnClient(m->check(m.player.containerMenu.getSlot(1).getItem().getHoverName().getString().equals("Sculpture"),"Crafted sculpture has translated type name FR"));c.takeScreenshot("statuary106-workshop-fr-blue");
checkWorkshopLayout(c);
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;var blue=StatuaryEntity.sculpture(menu.getSlot(1).getItem());check(blue.modelId().equals(MODEL.get())&&blue.cells().stream().allMatch(v->v.color()==0xFFA0A7BC),"Blue input overrides uploaded model colors");});
var expectedBlue=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(ColoredBricks.families().stream().filter(f->f.color()==DyeColor.BLUE).findFirst().orElseThrow().clay())).apply(originalSource));
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;var blue=StatuaryEntity.sculpture(menu.getSlot(1).getItem());check(blue.equals(expectedBlue),"Blue input uses sampled palette and model shading");});
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.QUICK_MOVE,m.player));c.waitTicks(10);
server.runOnServer(s->{var p=player(s);check(p.containerMenu.getSlot(0).getItem().isEmpty(),"Shift crafting consumes stock");check(count(p,Statuary.ITEM)==2,"Shift crafting produces exactly paid quantity");});close(c);
// At the Metabli, the same source creates a full-sized construction plan, with no placement in survival.
@@ -224,14 +227,19 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
byte[] text=json.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);int padded=(text.length+3)/4*4;byte[] binary=Arrays.copyOfRange(file,20+length,file.length);var out=ByteBuffer.allocate(20+padded+binary.length).order(ByteOrder.LITTLE_ENDIAN);out.putInt(0x46546C67).putInt(2).putInt(out.capacity()).putInt(padded).putInt(0x4E4F534A).put(text);while(out.position()<20+padded)out.put((byte)' ');out.put(binary);return out.array();
}
/** A tiny authored cat built from triangle boxes, embedded 2×2 texture and a translated scene node. */
private static byte[] fixture()throws IOException{
static byte[] fixture()throws IOException{
return fixture(false);
}
static byte[] fixture(boolean uniform)throws IOException{
var xyz=new ArrayList<Float>();var uv=new ArrayList<Float>();
for(float[] box:List.of(new float[]{.2f,0,.1f,.8f,1,.65f},new float[]{.1f,1,.05f,.9f,1.7f,.7f},new float[]{.1f,1.7f,.1f,.3f,2.1f,.5f},new float[]{.7f,1.7f,.1f,.9f,2.1f,.5f},new float[]{.75f,.1f,.2f,1.1f,.65f,.5f})){
float x=box[0],y=box[1],z=box[2],X=box[3],Y=box[4],Z=box[5];float[][] v={{x,y,z},{X,y,z},{X,Y,z},{x,Y,z},{x,y,Z},{X,y,Z},{X,Y,Z},{x,Y,Z}};
int[][] quads={{0,3,2,1},{5,6,7,4},{4,7,3,0},{1,2,6,5},{3,7,6,2},{4,0,1,5}};
for(var q:quads)for(int i:new int[]{0,1,2,0,2,3}){for(float f:v[q[i]])xyz.add(f);uv.add(i==0||i==1?0f:1f);uv.add(i==0||i==3?1f:0f);}
}
var image=new java.awt.image.BufferedImage(2,2,java.awt.image.BufferedImage.TYPE_INT_ARGB);image.setRGB(0,0,0xFFFF9933);image.setRGB(1,0,0xFF44CCAA);image.setRGB(0,1,0xFF4477EE);image.setRGB(1,1,0xFFFFFFEE);var png=new ByteArrayOutputStream();javax.imageio.ImageIO.write(image,"png",png);
var image=new java.awt.image.BufferedImage(2,2,java.awt.image.BufferedImage.TYPE_INT_ARGB);image.setRGB(0,0,0xFFFF9933);image.setRGB(1,0,0xFF44CCAA);image.setRGB(0,1,0xFF4477EE);image.setRGB(1,1,0xFFFFFFEE);
if(uniform)for(int y=0;y<2;y++)for(int x=0;x<2;x++)image.setRGB(x,y,0xFF808080);
var png=new ByteArrayOutputStream();javax.imageio.ImageIO.write(image,"png",png);
int pbytes=xyz.size()*4,ubytes=uv.size()*4,ibytes=png.size(),total=pbytes+ubytes+ibytes;var binary=ByteBuffer.allocate((total+3)/4*4).order(ByteOrder.LITTLE_ENDIAN);for(float f:xyz)binary.putFloat(f);for(float f:uv)binary.putFloat(f);binary.put(png.toByteArray());
String json="""
{"asset":{"version":"2.0"},"scene":0,"scenes":[{"nodes":[0]}],"nodes":[{"mesh":0,"translation":[3,2,-1]}],
@@ -22,7 +22,8 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
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 List<Path> files=List.of();private int page,generation,ticks;private boolean busy,pending;
private VoxelSculpture source,previewSource;private ItemStack preview=ItemStack.EMPTY,lastClay=ItemStack.EMPTY;
private VoxelSculpture source,previewSource;private ItemStack preview=ItemStack.EMPTY,lastMaterial=ItemStack.EMPTY;
private SculpturePalette palette;private Object sampledResources;
private Component status=text("choose_model");private Button rotate;
public ClayWorkshopScreen(ClayWorkshopMenu menu,Inventory inv,Component title){super(menu,inv,title,310,202);}
public static Component text(String key,Object...args){return Component.translatable("sanctuary.statuary."+key,args);}
@@ -59,12 +60,23 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
}
@Override protected void containerTick(){
super.containerTick();rotate.active=source!=null&&!busy;
if(pending&&++ticks>=5){ticks=0;pending=false;ClientPlayNetworking.send(new ClayWorkshopMenu.Select(menu.containerId,menu.view.token(),source));}
var clay=menu.getSlot(0).getItem();
if(previewSource!=source||!ItemStack.matches(lastClay,clay)){
previewSource=source;lastClay=clay.copy();var colored=ClayWorkshopMenu.material(source,clay);
preview=source==null?ItemStack.EMPTY:StatuaryEntity.item(colored==null?source:colored);
var material=menu.getSlot(0).getItem();var resources=minecraft.getModelManager().getBlockStateModelSet();
boolean changedMaterial=!ItemStack.isSameItemSameComponents(lastMaterial,material)||sampledResources!=resources;
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);
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());
}
// 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.
boolean needsResult=source!=null&&palette!=null&&!ItemStack.matches(preview,menu.getSlot(1).getItem());
if(++ticks>=5&&(pending||needsResult)){
ticks=0;pending=false;
if(source!=null&&palette!=null)ClientPlayNetworking.send(new ClayWorkshopMenu.Select(menu.containerId,menu.view.token(),source,material.getItem(),palette));
}
}
@Override public void onFilesDrop(List<Path> files){if(!files.isEmpty())load(files.getFirst());}
@Override public void removed(){generation++;super.removed();}
@@ -88,6 +100,7 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
g.text(font,title,10,8,0xFF303030,false);g.text(font,text("models"),10,22,0xFF404040,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;}
public boolean busy(){return busy;}
public VoxelSculpture selected(){return source;}
}
@@ -11,6 +11,7 @@ public final class MultiblockScreen extends ContainerScreen {
private EditBox search;
private Button previous,next,apply;
private double wheel;
private boolean showHelp;
private final java.util.List<Button> sections=new java.util.ArrayList<>();
public MultiblockScreen(MultiblockMenu menu,Inventory inv,Component title){super(menu,inv,title);}
private MultiblockMenu machine(){return (MultiblockMenu)menu;}
@@ -26,7 +27,10 @@ public final class MultiblockScreen extends ContainerScreen {
apply=addRenderableWidget(Button.builder(Multiblocks.text("search"),b->change(0,v.section())).bounds(x,topPos+20,w,20).build());
previous=addRenderableWidget(Button.builder(Component.literal("<"),b->change(v.page()-1,v.section())).bounds(x,topPos+44,w/2-1,20).build());
next=addRenderableWidget(Button.builder(Component.literal(">"),b->change(v.page()+1,v.section())).bounds(x+w/2+1,topPos+44,w/2-1,20).build());
if(v.furnace())for(int n=0;n<3;n++){int section=n;var b=addRenderableWidget(Button.builder(Multiblocks.text(new String[]{"inputs","fuel","outputs"}[n]),button->change(0,section)).bounds(x,topPos+84+n*22,w,20).build());sections.add(b);}
if(v.furnace())for(int n=0;n<3;n++){int section=n;var b=addRenderableWidget(Button.builder(Multiblocks.text(new String[]{"inputs","fuel","outputs"}[n]),button->change(0,section)).bounds(x,topPos+44+n*22,w,20).build());sections.add(b);}
if(v.furnace())addRenderableWidget(Button.builder(Multiblocks.text("how_to_cook"),b->showHelp=!showHelp).bounds(x,topPos+142,w,20)
.tooltip(Tooltip.create(Multiblocks.text("cooking_help"))).build());
if(v.furnace()){previous.visible=false;next.visible=false;}
controls();
}
@Override public boolean mouseScrolled(double x,double y,double horizontal,double vertical){
@@ -41,14 +45,46 @@ public final class MultiblockScreen extends ContainerScreen {
@Override protected void containerTick(){super.containerTick();controls();}
@Override protected void extractLabels(net.minecraft.client.gui.GuiGraphicsExtractor g,int mouseX,int mouseY){
super.extractLabels(g,mouseX,mouseY);var m=machine();int x=imageWidth+4;
g.text(font,Multiblocks.text("page",m.view.page()+1,m.view.pages()),x,68,0xFFFFFFFF,false);
if(!m.view.furnace())g.text(font,Multiblocks.text("page",m.view.page()+1,m.view.pages()),x,68,0xFFFFFFFF,false);
if(m.view.furnace()){
g.text(font,Multiblocks.text("heat",m.heat),x,154,0xFFFFFFFF,false);
g.text(font,Multiblocks.text("progress",m.progress),x,166,0xFFFFFFFF,false);
g.text(font,Multiblocks.text("heat",m.heat),x,116,0xFFFFFFFF,false);
g.text(font,Multiblocks.text("cooking_count",m.cooking),x,128,0xFFFFFFFF,false);
}
for(int i=m.view.indices().length;i<m.displaySlots();i++){var s=menu.getSlot(i);g.fill(s.x,s.y,s.x+16,s.y+16,0xB0181818);}
}
public java.util.List<net.minecraft.client.renderer.Rect2i> extraAreas(){return search==null?java.util.List.of():java.util.List.of(new net.minecraft.client.renderer.Rect2i(search.getX()-2,search.getY()-2,search.getWidth()+4,machine().view.furnace()?182:82));}
@Override protected void extractSlot(net.minecraft.client.gui.GuiGraphicsExtractor g,net.minecraft.world.inventory.Slot slot,int x,int y){
super.extractSlot(g,slot,x,y);var m=machine();
if(!m.view.furnace()||m.view.section()!=0||slot.index>=m.view.indices().length)return;
int lane=m.view.indices()[slot.index]/3,state=m.laneState(lane);if(state==MultiblockMenu.IDLE)return;
int color=switch(state){case MultiblockMenu.COOKING->0xFFFFB23F;case MultiblockMenu.NO_FUEL->0xFFE1CE62;default->0xFFE06B5F;};
g.fill(slot.x,slot.y+14,slot.x+16,slot.y+16,0xFF343434);
g.fill(slot.x,slot.y+14,slot.x+Math.max(1,16*m.laneProgress(lane)/100),slot.y+16,color);
}
@Override protected java.util.List<Component> getTooltipFromContainerItem(net.minecraft.world.item.ItemStack stack){
var lines=new java.util.ArrayList<>(super.getTooltipFromContainerItem(stack));var m=machine();
if(m.view.furnace()&&hoveredSlot!=null&&hoveredSlot.index<m.view.indices().length){
int lane=m.view.indices()[hoveredSlot.index]/3;
if(m.view.section()==1)lines.add(Multiblocks.text("shared_fuel").copy().withStyle(net.minecraft.ChatFormatting.GRAY));
else {lines.add(Multiblocks.text("lane",lane+1).copy().withStyle(net.minecraft.ChatFormatting.GRAY));
lines.add(Multiblocks.text("state."+new String[]{"idle","cooking","no_fuel","blocked","no_recipe"}[m.laneState(lane)],m.laneProgress(lane)));}
}
return lines;
}
@Override public void extractRenderState(net.minecraft.client.gui.GuiGraphicsExtractor g,int x,int y,float partial){
super.extractRenderState(g,x,y,partial);
if(showHelp){
g.nextStratum();
g.tooltip(font,font.split(Multiblocks.text("cooking_help"),Math.min(260,width-32)).stream()
.map(net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent::create).toList(),
8,24,net.minecraft.client.gui.screens.inventory.tooltip.DefaultTooltipPositioner.INSTANCE,null,false);
}
}
@Override public boolean mouseClicked(net.minecraft.client.input.MouseButtonEvent event,boolean doubleClick){
if(showHelp){showHelp=false;return true;}
return super.mouseClicked(event,doubleClick);
}
boolean helpVisible(){return showHelp;}
public java.util.List<net.minecraft.client.renderer.Rect2i> extraAreas(){return search==null?java.util.List.of():java.util.List.of(new net.minecraft.client.renderer.Rect2i(search.getX()-2,search.getY()-2,search.getWidth()+4,machine().view.furnace()?166:82));}
public static void register(){
net.minecraft.client.gui.screens.MenuScreens.<net.minecraft.world.inventory.ChestMenu,MultiblockScreen>register(Multiblocks.MENU,(m,i,t)->new MultiblockScreen((MultiblockMenu)m,i,t));
net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback.EVENT.register((stack,context,flags,lines)->{if(stack.is(Multiblocks.WRENCH))lines.add(Multiblocks.text("hint").copy().withStyle(net.minecraft.ChatFormatting.GRAY));});
@@ -0,0 +1,47 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart;
import net.minecraft.client.resources.model.geometry.BakedQuad;
import net.minecraft.core.*;
import net.minecraft.util.ARGB;
import net.minecraft.util.RandomSource;
import net.minecraft.world.item.*;
/** Bounded samples of the baked faces' actual textures, with their own tint from the active pack. */
final class SculptureMaterialSampler {
private static final int MAX_MATERIALS=32,SAMPLE_EDGE=16;
static SculpturePalette sample(ItemStack stack){
if(!ClayWorkshopMenu.material(stack))return null;
var state=((BlockItem)stack.getItem()).getBlock().defaultBlockState();var sample=PlanMaterials.sample(state);
var mc=net.minecraft.client.Minecraft.getInstance();
var parts=new ArrayList<BlockStateModelPart>();
mc.getModelManager().getBlockStateModelSet().get(state).collectParts(RandomSource.create(state.getSeed(BlockPos.ZERO)),parts);
var materials=new LinkedHashSet<BakedQuad.MaterialInfo>();
for(var part:parts)for(int face=0;face<=6;face++)for(var quad:part.getQuads(face==6?null:Direction.values()[face])){
if(materials.size()<MAX_MATERIALS)materials.add(quad.materialInfo());
}
var pixels=new ArrayList<Integer>();
for(var material:materials)add(pixels,PlanMaterials.faceSample(state,material));
if(pixels.isEmpty()){
var tints=mc.getBlockColors().getTintSources(state);
int tint=tints.isEmpty()?0xFFFFFFFF:tints.getFirst().colorInWorld(state,mc.level,mc.player.blockPosition());
add(pixels,new PlanMaterials.Sample(sample.pixels(),tint,sample.average()));
}
if(pixels.isEmpty())pixels.add(sample.average()|0xFF000000);
// Keep repeated pixels: they retain the sampled frequency of each shade.
pixels.sort(Comparator.comparingDouble(SculpturePalette::lightness).thenComparingInt(Integer::intValue));
var palette=new ArrayList<Integer>();int count=Math.min(SculpturePalette.MAX_COLORS,pixels.size());
for(int i=0;i<count;i++)palette.add(pixels.get((int)((i+.5)*pixels.size()/count)));
return new SculpturePalette(palette);
}
private static void add(List<Integer> pixels,PlanMaterials.Sample sample){
var texture=sample.pixels();int width=Math.min(SAMPLE_EDGE,texture.width()),height=Math.min(SAMPLE_EDGE,texture.height());
for(int y=0;y<height;y++)for(int x=0;x<width;x++){
int sx=(2*x+1)*texture.width()/(2*width),sy=(2*y+1)*texture.height()/(2*height);
int color=ARGB.multiply(texture.argb()[sy*texture.width()+sx],sample.tint());
if((color>>>24)>=128)pixels.add(color|0xFF000000);
}
}
}
@@ -264,8 +264,13 @@ public final class HeadBlockService {
var data=stack.get(DataComponents.BLOCK_ENTITY_DATA);
if(data!=null)data.loadInto(entity,level().registryAccess());
}
// Ordinary inventory data belongs to the equipped session, not its transferable item.
if(!keepsContents()){stack.remove(DataComponents.CONTAINER);stack.remove(DataComponents.BLOCK_ENTITY_DATA);}
// Storage belongs to the equipped session. A sculpture's block-entity data is
// its actual geometry, not inventory: keep it on the transferable item.
if(!keepsContents()){
stack.remove(DataComponents.CONTAINER);
if(!(entity instanceof fr.koka.sanctuary.plans.StatuaryEntity))stack.remove(DataComponents.BLOCK_ENTITY_DATA);
}
restoreSculptureData();
}
}
}
@@ -321,6 +326,15 @@ public final class HeadBlockService {
}
public void flushItem(){
if((keepsContents()||entity instanceof BeehiveBlockEntity)&&entity!=null)stack.applyComponents(entity.collectComponents());
restoreSculptureData();
}
private void restoreSculptureData(){
// Older equipped sessions may still hold the model after the stack lost it.
// Repair only from that authoritative data, and only once; never invent geometry.
if(entity instanceof fr.koka.sanctuary.plans.StatuaryEntity sculpture&&!stack.has(DataComponents.BLOCK_ENTITY_DATA)){
var data=sculpture.item().get(DataComponents.BLOCK_ENTITY_DATA);
if(data!=null)stack.set(DataComponents.BLOCK_ENTITY_DATA,data);
}
}
public CompoundTag save(){
flushItem();var tag=new CompoundTag();tag.putInt("schema",1);
@@ -12,6 +12,9 @@ public final class MultiblockCooking {
private static final ThreadLocal<Boolean> NATIVE=ThreadLocal.withInitial(()->false);
public static boolean nativeTick(){return NATIVE.get();}
private static MultiblockFurnaceAccess access(AbstractFurnaceBlockEntity f){return (MultiblockFurnaceAccess)f;}
public static boolean hasRecipe(ServerLevel level,AbstractFurnaceBlockEntity f){
return !f.getItem(0).isEmpty()&&access(f).sanctuary$recipes().getRecipeFor(new SingleRecipeInput(f.getItem(0)),level).isPresent();
}
public static boolean canCook(ServerLevel level,AbstractFurnaceBlockEntity f){
if(f.getItem(0).isEmpty())return false;
var input=new SingleRecipeInput(f.getItem(0));var recipe=access(f).sanctuary$recipes().getRecipeFor(input,level).orElse(null);
@@ -40,17 +43,27 @@ public final class MultiblockCooking {
f.setChanged();donor.setChanged();break;
}
}
AbstractFurnaceBlockEntity fuelSource=null;ItemStack borrowed=ItemStack.EMPTY;
if(a.sanctuary$heat()<=1&&a.sanctuary$fuel(level,f.getItem(1))<=0){
for(var donor:furnaces)if(donor!=f&&a.sanctuary$fuel(level,donor.getItem(1))>0){
// Exchange physical fuel slots, retaining buckets and every component exactly once.
// Lend the stack only for this native tick, then return its unburnt contents
// to the chosen slot. Never leave whole piles wandering through the fuel tab.
fuelSource=donor;borrowed=donor.getItem(1).copy();
var old=f.getItem(1);f.setItem(1,donor.getItem(1));donor.setItem(1,old);donor.setChanged();f.setChanged();break;
}
}
// A batch can be refuelled gradually: do not erase partially paid work when heat runs out.
if(a.sanctuary$heat()<=1&&a.sanctuary$fuel(level,f.getItem(1))<=0)continue;
if(fuelSource==null&&a.sanctuary$heat()<=1&&a.sanctuary$fuel(level,f.getItem(1))<=0)continue;
NATIVE.set(true);
try{AbstractFurnaceBlockEntity.serverTick(level,f.getBlockPos(),f.getBlockState(),f);}
finally{NATIVE.remove();}
finally{
NATIVE.remove();
if(fuelSource!=null&&(f.getItem(1).isEmpty()||ItemStack.isSameItemSameComponents(f.getItem(1),borrowed))){
var remaining=f.getItem(1);f.setItem(1,fuelSource.getItem(1));fuelSource.setItem(1,remaining);fuelSource.setChanged();
}
// A native remainder (e.g. a lava bucket) stays with the cooking lane, so
// wet sponges still fill that bucket and no item is copied or discarded.
}
f.setChanged();used.add(f);
}
// Idle heat still decays. A blocked output never starts a new fuel item.
@@ -11,6 +11,8 @@ import net.minecraft.world.*;
import net.minecraft.world.entity.player.*;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.phys.Vec3;
import java.util.*;
@@ -30,26 +32,35 @@ public final class MultiblockMenu extends ChestMenu {
public final MultiblockContainer stock;
public final BlockPos anchor;
public int displaySlots(){return view.furnace?27:54;}
public int heat,progress;
public int heat,progress,cooking;
public static final int IDLE=0,COOKING=1,NO_FUEL=2,BLOCKED=3,NO_RECIPE=4;
private final int[] lanes=new int[27];
public int laneState(int lane){return lanes[lane]/101;}
public int laneProgress(int lane){return lanes[lane]%101;}
public MultiblockMenu(int id,Inventory inv,View view){this(id,inv,view,null,BlockPos.ZERO);}
public MultiblockMenu(int id,Inventory inv,View view,MultiblockContainer stock,BlockPos anchor){
super(Multiblocks.MENU,id,inv,new SimpleContainer(54),view.furnace?3:6);this.view=view;this.stock=stock;this.anchor=anchor.immutable();
for(int i=0;i<displaySlots();i++){
var old=slots.get(i);boolean enabled=i<view.indices.length;int index=enabled?view.indices[i]:-1;
var old=slots.get(i);boolean enabled=i<view.indices.length;int physicalIndex=enabled?view.indices[i]:-1;
Slot slot;
if(enabled&&stock!=null){
var cell=stock.cell(index);
slot=view.furnace&&view.section==2?new FurnaceResultSlot(inv.player,cell,stock.local(index),old.x,old.y):new Slot(stock,index,old.x,old.y){
@Override public boolean mayPlace(ItemStack s){return stock.canPlaceItem(index,s);}
var cell=stock.cell(physicalIndex);
slot=view.furnace&&view.section==2?new FurnaceResultSlot(inv.player,cell,stock.local(physicalIndex),old.x,old.y):new Slot(stock,physicalIndex,old.x,old.y){
// Slot.index is the displayed menu ID, not the physical furnace slot.
@Override public boolean mayPlace(ItemStack s){return stock.canPlaceItem(physicalIndex,s);}
};
}else slot=new Slot(getContainer(),i,old.x,old.y){
@Override public boolean mayPlace(ItemStack s){return enabled&&(!view.furnace||view.section!=2)&&super.mayPlace(s);}
@Override public boolean mayPlace(ItemStack s){return enabled&&(!view.furnace||view.section==0||view.section==1&&(s.has(DataComponents.COOKING_FUEL)||s.is(Items.BUCKET)&&!getItem().is(Items.BUCKET)))&&super.mayPlace(s);}
@Override public boolean mayPickup(Player p){return enabled;}
};
slot.index=i;slots.set(i,slot);
}
addDataSlot(new DataSlot(){public int get(){return heat;}public void set(int n){heat=n;}});
addDataSlot(new DataSlot(){public int get(){return progress;}public void set(int n){progress=n;}});
if(view.furnace){
addDataSlot(new DataSlot(){public int get(){return cooking;}public void set(int n){cooking=n;}});
for(int i=0;i<27;i++){int lane=i;addDataSlot(new DataSlot(){public int get(){return lanes[lane];}public void set(int n){lanes[lane]=n;}});}
}
}
public static View view(MultiblockContainer stock,int wanted,String query,int section){
return view(stock,wanted,query,section,new int[0]);
@@ -78,20 +89,32 @@ public final class MultiblockMenu extends ChestMenu {
}
@Override public void broadcastChanges(){
if(stock!=null&&stock.machine.furnace()&&Multiblocks.ready(stock.level,stock.machine)){
int total=0,max=0;
for(int i=0;i<81;i+=3){var a=(fr.koka.sanctuary.mixin.MultiblockFurnaceAccess)stock.cell(i);total+=a.sanctuary$heat();max=Math.max(max,a.sanctuary$progress()*100/Math.max(1,a.sanctuary$total()));}
heat=Math.min(32767,total);progress=max;
long total=0;boolean fuel=false;cooking=0;progress=0;
for(int i=0;i<81;i+=3){var a=(fr.koka.sanctuary.mixin.MultiblockFurnaceAccess)stock.cell(i);total+=a.sanctuary$heat();fuel|=a.sanctuary$fuel(stock.level,stock.getItem(i+1))>0;}
heat=(int)Math.min(32767,(total+19)/20); // Seconds of combined burning fuel, not wall-clock time.
for(int i=0;i<27;i++){
var f=(net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity)stock.cell(i*3);var a=(fr.koka.sanctuary.mixin.MultiblockFurnaceAccess)f;
int percent=Math.clamp(a.sanctuary$progress()*100/Math.max(1,a.sanctuary$total()),0,100);
int state=f.getItem(0).isEmpty()?IDLE:!MultiblockCooking.hasRecipe(stock.level,f)?NO_RECIPE:!MultiblockCooking.canCook(stock.level,f)?BLOCKED:total>1||fuel?COOKING:NO_FUEL;
lanes[i]=state*101+percent;if(state==COOKING)cooking++;progress=Math.max(progress,percent);
}
}
super.broadcastChanges();
}
@Override public boolean stillValid(Player p){return p.isAlive()&&(stock==null||p.level()==stock.level&&p.distanceToSqr(Vec3.atCenterOf(anchor))<=64&&stock.stillValid(p)&&p instanceof ServerPlayer server&&Multiblocks.accessible(server,stock.machine));}
@Override public void clicked(int slot,int button,ContainerInput input,Player p){if(stillValid(p))super.clicked(slot,button,input,p);}
@Override public ItemStack quickMoveStack(Player p,int index){
if(index<0||index>=slots.size()||!stillValid(p)||stock==null)return ItemStack.EMPTY;
if(index<0||index>=slots.size()||!stillValid(p))return ItemStack.EMPTY;
var slot=slots.get(index);if(!slot.mayPickup(p))return ItemStack.EMPTY;
var source=slot.getItem();if(source.isEmpty())return ItemStack.EMPTY;var original=source.copy();boolean moved;
if(index<displaySlots())moved=SanctuaryInventoryTransfer.move(source,slots.subList(displaySlots(),slots.size()));
else{
else if(stock==null){
// The complete furnace tab has the same destinations on both sides. Predict the
// native transfer immediately, so rapid gestures do not keep selecting stale stacks.
// Filtered views and barrel pages omit destinations: wait for the authoritative reply.
if(!view.furnace||!view.query.isEmpty())return ItemStack.EMPTY;
moved=SanctuaryInventoryTransfer.move(source,slots.subList(0,displaySlots()));
}else{
var targets=new ArrayList<Slot>();
for(int i=0;i<stock.getContainerSize();i++){
if(stock.machine.furnace()&&i%3!=view.section)continue;
@@ -1,7 +1,8 @@
package fr.koka.sanctuary.plans;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.building.ColoredBricks;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.BuiltInRegistries;
import java.util.*;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.minecraft.core.*;
@@ -16,23 +17,27 @@ import net.minecraft.world.item.*;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.*;
/** Native input/result slots. Server recomputes the color from the actual clay and charges each take. */
/** 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()));
}
/** At most 24 KiB of cells in one atomic, bounded selection, scoped to one open menu. */
public record Select(int menu,UUID token,VoxelSculpture sculpture) implements CustomPacketPayload {
/** 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 {
public static final Type<Select> TYPE=new Type<>(SanctuaryMod.id("clay_model"));
public static final StreamCodec<RegistryFriendlyByteBuf,Select> CODEC=StreamCodec.of((b,p)->{
b.writeVarInt(p.menu);b.writeUUID(p.token);b.writeBoolean(p.sculpture!=null);
b.writeVarInt(p.menu);b.writeUUID(p.token);b.writeVarInt(BuiltInRegistries.ITEM.getId(p.material));b.writeVarIntArray(p.palette==null?new int[0]:p.palette.colors().stream().mapToInt(Integer::intValue).toArray());b.writeBoolean(p.sculpture!=null);
if(p.sculpture!=null){var s=p.sculpture;b.writeUtf(s.modelId(),128);b.writeUtf(s.name(),128);b.writeByte(s.width());b.writeByte(s.height());b.writeByte(s.depth());b.writeVarInt(s.cells().size());for(var c:s.cells()){b.writeShort(c.key());b.writeInt(c.color());}}
},b->{
int menu=b.readVarInt();var token=b.readUUID();if(!b.readBoolean())return new Select(menu,token,null);
int menu=b.readVarInt();var token=b.readUUID();int itemId=b.readVarInt();
if(itemId<0||itemId>=BuiltInRegistries.ITEM.size())throw new IllegalArgumentException("sculpture_material");
var material=BuiltInRegistries.ITEM.byId(itemId);var colors=b.readVarIntArray(SculpturePalette.MAX_COLORS);
var palette=colors.length==0?null:new SculpturePalette(Arrays.stream(colors).boxed().toList());
if(!b.readBoolean())return new Select(menu,token,null,material,palette);
String model=b.readUtf(128),name=b.readUtf(128);int w=b.readUnsignedByte(),h=b.readUnsignedByte(),d=b.readUnsignedByte(),n=b.readVarInt();
if(n<1||n>VoxelSculpture.MAX_CELLS)throw new IllegalArgumentException("sculpture_limit");int[] packed=new int[n*2];for(int i=0;i<n;i++){packed[i*2]=b.readUnsignedShort();packed[i*2+1]=b.readInt();}
return new Select(menu,token,VoxelSculpture.unpack(model,name,w,h,d,packed));
return new Select(menu,token,VoxelSculpture.unpack(model,name,w,h,d,packed),material,palette);
});
public Type<? extends CustomPacketPayload> type(){return TYPE;}
}
@@ -41,41 +46,45 @@ public final class ClayWorkshopMenu extends AbstractContainerMenu {
private final SimpleContainer input=new SimpleContainer(1){@Override public void setChanged(){super.setChanged();ClayWorkshopMenu.this.slotsChanged(this);}};
private final SimpleContainer result=new SimpleContainer(1);
private VoxelSculpture selected;
private ItemStack lastClay=ItemStack.EMPTY;
private VoxelSculpture selectedResult;
private ItemStack lastMaterial=ItemStack.EMPTY;
private Item selectedMaterial=Items.AIR;
private SculpturePalette selectedPalette;
private long lastSelection=-100;
public ClayWorkshopMenu(int id,Inventory inv,View view){this(id,inv,view,null);}
public ClayWorkshopMenu(int id,Inventory inv,View view,Level level){
super(Statuary.MENU,id);this.view=view;this.level=level;
addSlot(new Slot(input,0,222,106){@Override public boolean mayPlace(ItemStack stack){return clay(stack);}});
addSlot(new Slot(input,0,222,106){@Override public boolean mayPlace(ItemStack stack){return material(stack);}});
addSlot(new Slot(result,0,282,106){
@Override public boolean mayPlace(ItemStack stack){return false;}
@Override public boolean mayPickup(Player p){return mayCraft(p)&&clay(input.getItem(0))&&!getItem().isEmpty();}
@Override public boolean mayPickup(Player p){return mayCraft(p)&&material(input.getItem(0))&&(level==null||input.getItem(0).is(selectedMaterial))&&!getItem().isEmpty();}
@Override public void onTake(Player p,ItemStack stack){input.removeItem(0,1);input.setChanged();super.onTake(p,stack);}
});
// Use the nine carried slots without registering a full inventory/overflow range.
addInventoryHotbarSlots(inv,HOTBAR_X,HOTBAR_Y);
}
// Colors match the published clay palette (tools/colored-bricks-palette.json), in native dye order.
private static final int[] CLAY_COLORS={0xC6C4C8,0xD3AA8D,0xD39FD0,0x91B0CA,0xC8BF98,0xA2C69A,0xDAC4DD,0x74767D,0xA9A8AC,0xA8C5C1,0xBE91E3,0xA0A7BC,0xA49A91,0xA0B499,0xB49A99,0x444541};
public static boolean clay(ItemStack item){return item.is(Items.CLAY)||ColoredBricks.families().stream().anyMatch(f->item.is(f.clay().asItem()));}
public static VoxelSculpture material(VoxelSculpture model,ItemStack clay){
if(model==null||!clay(clay))return null;
if(clay.is(Items.CLAY))return model;
for(var family:ColoredBricks.families())if(clay.is(family.clay().asItem()))return model.color(0xFF000000|CLAY_COLORS[family.color().getId()]);
return null;
public static boolean material(ItemStack item){
if(item.isEmpty()||!(item.getItem() instanceof BlockItem))return false;
var contents=item.get(DataComponents.CONTAINER);
return contents==null||!contents.nonEmptyItems().iterator().hasNext();
}
public void select(ServerPlayer p,VoxelSculpture sculpture){
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();selected=sculpture;refresh();broadcastChanges();
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);
selected=sculpture;selectedMaterial=material;selectedPalette=palette;refresh();broadcastChanges();
}
private void refresh(){
if(level==null)return;var sculpture=material(selected,input.getItem(0));lastClay=input.getItem(0).copy();
if(level==null)return;var item=input.getItem(0);
var sculpture=material(item)&&item.is(selectedMaterial)?selectedResult:null;lastMaterial=item.copy();
result.setItem(0,sculpture==null?ItemStack.EMPTY:StatuaryEntity.item(sculpture));
}
@Override public void slotsChanged(Container container){if(container==input)refresh();super.slotsChanged(container);}
@Override public void broadcastChanges(){if(level!=null&&!ItemStack.matches(lastClay,input.getItem(0)))refresh();super.broadcastChanges();}
@Override public void broadcastChanges(){if(level!=null&&!ItemStack.matches(lastMaterial,input.getItem(0)))refresh();super.broadcastChanges();}
@Override public boolean stillValid(Player p){return level==null?p.isAlive():Statuary.allowed(p,level,view.pos);}
private boolean mayCraft(Player p){
if(!stillValid(p))return false;
@@ -94,7 +103,7 @@ public final class ClayWorkshopMenu extends AbstractContainerMenu {
if(index<0||index>=slots.size()||!stillValid(p))return ItemStack.EMPTY;var slot=slots.get(index);if(!slot.mayPickup(p)||!slot.hasItem())return ItemStack.EMPTY;
var stack=slot.getItem();var copy=stack.copy();
if(index<HOTBAR_START){if(!moveItemStackTo(stack,HOTBAR_START,HOTBAR_END,true))return ItemStack.EMPTY;}
else if(clay(stack)){if(!moveItemStackTo(stack,0,1,false))return ItemStack.EMPTY;}
else if(material(stack)){if(!moveItemStackTo(stack,0,1,false))return ItemStack.EMPTY;}
else return ItemStack.EMPTY;
if(stack.isEmpty())slot.set(ItemStack.EMPTY);else slot.setChanged();
if(index==1)slot.onTake(p,copy);return copy;
@@ -0,0 +1,31 @@
package fr.koka.sanctuary.plans;
import java.util.*;
/** Bounded appearance data, applied identically by preview and authoritative crafting. */
public record SculpturePalette(List<Integer> colors) {
public static final int MAX_COLORS=256;
public SculpturePalette {
if(colors==null||colors.isEmpty()||colors.size()>MAX_COLORS||colors.stream().anyMatch(c->c==null||(c>>>24)!=255))
throw new IllegalArgumentException("sculpture_palette");
colors=colors.stream().sorted(Comparator.comparingInt(SculpturePalette::luminance).thenComparingInt(Integer::intValue)).toList();
}
// Integer Rec.709 weights: no color-space arrays or transcendental math per voxel.
private static int luminance(int color){return 54*(color>>16&255)+183*(color>>8&255)+19*(color&255);}
public static double lightness(int color){return luminance(color)/65280.;}
public VoxelSculpture apply(VoxelSculpture source){
if(source==null)return null;
int min=65280,max=0;for(var cell:source.cells()){int l=luminance(cell.color());min=Math.min(min,l);max=Math.max(max,l);}
// Flat models sample the entire texture distribution. Shaded models use overlapping
// half-palette bands: retain large light/dark regions while giving each region grain.
int span=max==min?colors.size():(colors.size()+1)/2;
var cells=new ArrayList<VoxelSculpture.Cell>(source.cells().size());
for(var cell:source.cells()){
int start=max==min?0:(luminance(cell.color())-min)*(colors.size()-span)/(max-min);
int hash=cell.key()+0x9E3779B9;hash=(hash^(hash>>>16))*0x7FEB352D;hash=(hash^(hash>>>15))*0x846CA68B;hash^=hash>>>16;
int offset=(int)((Integer.toUnsignedLong(hash)*span)>>>32);
cells.add(new VoxelSculpture.Cell(cell.x(),cell.y(),cell.z(),colors.get(start+offset)));
}
return new VoxelSculpture(source.modelId(),source.name(),source.width(),source.height(),source.depth(),cells);
}
}
@@ -39,7 +39,7 @@ public final class Statuary {
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.FUNCTIONAL_BLOCKS).register(out->out.accept(WORKSHOP_ITEM));
PayloadTypeRegistry.serverboundPlay().register(ClayWorkshopMenu.Select.TYPE,ClayWorkshopMenu.Select.CODEC);
ServerPlayNetworking.registerGlobalReceiver(ClayWorkshopMenu.Select.TYPE,(packet,c)->c.server().execute(()->{
if(c.player().containerMenu instanceof ClayWorkshopMenu menu&&menu.containerId==packet.menu()&&menu.view.token().equals(packet.token()))menu.select(c.player(),packet.sculpture());
if(c.player().containerMenu instanceof ClayWorkshopMenu menu&&menu.containerId==packet.menu()&&menu.view.token().equals(packet.token()))menu.select(c.player(),packet.sculpture(),packet.material(),packet.palette());
}));
}
public static boolean allowed(Player p,Level l,BlockPos pos){
@@ -1977,7 +1977,7 @@
"sanctuary.multiblock.inputs": "Ingredients",
"sanctuary.multiblock.fuel": "Fuel",
"sanctuary.multiblock.outputs": "Results",
"sanctuary.multiblock.heat": "Heat: %s",
"sanctuary.multiblock.heat": "Reserve: %s s",
"sanctuary.multiblock.progress": "Cooking: %s %%",
"sanctuary.multiblock.hint": "Right click: assemble / open. Sneak: rotate / separate.",
"sanctuary.multiblock.unavailable": "Machine inaccessible, incomplete or partly unloaded.",
@@ -2151,23 +2151,23 @@
"sanctuary.weather.season.summer": "Summer",
"sanctuary.weather.season.autumn": "Autumn",
"sanctuary.weather.season.winter": "Winter",
"block.sanctuary.statuary": "Clay Sculpture",
"block.sanctuary.statuary": "Sculpture",
"block.sanctuary.clay_workshop": "Clay workshop",
"sanctuary.plans.imported_models": "My 3D models",
"sanctuary.plans.native_models": "Discovered creatures",
"sanctuary.plans.imported_model": "3D model → block statue",
"sanctuary.plans.model_files_help": "GLB models in schematics/sanctuary/, alongside your schematics. Create a full-block statue here. Craft 16³ miniatures at the clay workshop.",
"sanctuary.plans.empty_models": "Place a .glb file in the shared folder, then refresh.",
"sanctuary.statuary.choose_model": "Choose a GLB model, then add clay from your hotbar.",
"sanctuary.statuary.choose_model": "Choose a GLB model, then a material block from your hotbar.",
"sanctuary.statuary.folder": "Shared folder",
"sanctuary.statuary.refresh": "Refresh",
"sanctuary.statuary.models": "GLB models",
"sanctuary.statuary.placement": "The sculpture faces your gaze and snaps to the aimed block edge, on the pixel grid. Rotate adjusts the model before crafting.",
"sanctuary.statuary.rotate": "Rotate",
"sanctuary.statuary.working": "Voxelizing to 16 × 16 × 16…",
"sanctuary.statuary.ready": "1 clay = 1 sculpture. Plain: model colors. Colored: solid tint.",
"sanctuary.statuary.ready": "1 block = 1 sculpture. Shades come from your materials texture.",
"sanctuary.statuary.empty": "No .glb files",
"sanctuary.statuary.input": "Clay",
"sanctuary.statuary.input": "Block",
"sanctuary.statuary.output": "Sculpture",
"sanctuary.statuary.model_name": "Model: %s",
"sanctuary.statuary.error.file": "Cannot read this local file.",
@@ -2193,5 +2193,17 @@
"gamerule.sanctuary.snow_accumulation_height": "Sanctuary snow height",
"gamerule.sanctuary.snow_accumulation_height.description": "Maximum height of a snow column, in blocks. 0: no accumulation; -1: up to the world height limit. Eight layers become a solid block. The vanilla rule set to 0 still takes priority.",
"gamerule.sanctuary.seasonal_snow_melt": "Seasonal snow melt",
"gamerule.sanctuary.seasonal_snow_melt.description": "Gradually melts tracked weather deposits outside winter in Real Time weather. Preserves snow constructions."
"gamerule.sanctuary.seasonal_snow_melt.description": "Gradually melts tracked weather deposits outside winter in Real Time weather. Preserves snow constructions.",
"sanctuary.multiblock.cooking_count": "%s / 27 cooking",
"sanctuary.multiblock.how_to_cook": "How it works",
"sanctuary.multiblock.cooking_help": "Ingredients: one slot = one recipe, up to 27 at once. Fuel: shared by all slots. Results: collect cooked items in the matching ingredient positions. Each recipe uses fuel at the normal rate: 1 coal cooks about 8 items in total. The reserve shows seconds of already burning fuel, shared between recipes. Bars beneath ingredients show their progress. Shift-click fills the first available slots; mouse placement keeps your chosen slot.",
"sanctuary.multiblock.shared_fuel": "Fuel shared between all 27 recipes.",
"sanctuary.multiblock.lane": "Cooking slot %s",
"sanctuary.multiblock.state.idle": "No ingredient in this slot.",
"sanctuary.multiblock.state.cooking": "Cooking: %s %%",
"sanctuary.multiblock.state.no_fuel": "Add fuel — %s %% progress kept",
"sanctuary.multiblock.state.blocked": "Empty the matching slot in Results.",
"sanctuary.multiblock.state.no_recipe": "This item has no furnace recipe.",
"sanctuary.statuary.choose_material": "Add a block to apply its palette to the model. Containers must be empty.",
"sanctuary.statuary.material_ready": "1 block = 1 sculpture. Palette: %s."
}
@@ -1977,7 +1977,7 @@
"sanctuary.multiblock.inputs": "Ingrédients",
"sanctuary.multiblock.fuel": "Combustibles",
"sanctuary.multiblock.outputs": "Résultats",
"sanctuary.multiblock.heat": "Chauffe : %s",
"sanctuary.multiblock.heat": "Réserve : %s s",
"sanctuary.multiblock.progress": "Cuisson : %s %%",
"sanctuary.multiblock.hint": "Clic droit : assembler / ouvrir. Maj : tourner / dissocier.",
"sanctuary.multiblock.unavailable": "Machine inaccessible, incomplète ou partiellement déchargée.",
@@ -2151,23 +2151,23 @@
"sanctuary.weather.season.summer": "Été",
"sanctuary.weather.season.autumn": "Automne",
"sanctuary.weather.season.winter": "Hiver",
"block.sanctuary.statuary": "Sculpture dargile",
"block.sanctuary.statuary": "Sculpture",
"block.sanctuary.clay_workshop": "Atelier dargile",
"sanctuary.plans.imported_models": "Mes modèles 3D",
"sanctuary.plans.native_models": "Créatures découvertes",
"sanctuary.plans.imported_model": "Modèle 3D → statue en blocs",
"sanctuary.plans.model_files_help": "Modèles GLB dans schematics/sanctuary/, avec vos schémas. Ici, créez une grande statue en blocs. Les miniatures 16³ se fabriquent à latelier dargile.",
"sanctuary.plans.empty_models": "Déposez un fichier .glb dans le dossier commun, puis actualisez.",
"sanctuary.statuary.choose_model": "Choisissez un modèle GLB, puis déposez de largile depuis votre barre rapide.",
"sanctuary.statuary.choose_model": "Choisissez un modèle GLB, puis un bloc de matière dans votre barre rapide.",
"sanctuary.statuary.folder": "Dossier commun",
"sanctuary.statuary.refresh": "Actualiser",
"sanctuary.statuary.models": "Modèles GLB",
"sanctuary.statuary.placement": "La sculpture suit votre regard et se cale contre le bord visé du bloc, sur la grille de pixels. Tourner ajuste le modèle avant fabrication.",
"sanctuary.statuary.rotate": "Tourner",
"sanctuary.statuary.working": "Voxelisation en 16 × 16 × 16…",
"sanctuary.statuary.ready": "1 argile = 1 sculpture. Normale : couleurs du modèle. Colorée : teinte unie.",
"sanctuary.statuary.ready": "1 bloc = 1 sculpture. Les nuances viennent de la texture de votre matière.",
"sanctuary.statuary.empty": "Aucun fichier .glb",
"sanctuary.statuary.input": "Argile",
"sanctuary.statuary.input": "Bloc",
"sanctuary.statuary.output": "Sculpture",
"sanctuary.statuary.model_name": "Modèle : %s",
"sanctuary.statuary.error.file": "Impossible de lire ce fichier local.",
@@ -2193,5 +2193,17 @@
"gamerule.sanctuary.snow_accumulation_height": "Hauteur de neige Sanctuary",
"gamerule.sanctuary.snow_accumulation_height.description": "Hauteur maximale de la colonne de neige, en blocs. 0 : aucun dépôt ; -1 : jusqu’à la limite du monde. Huit couches deviennent un bloc solide. La règle vanilla à 0 reste prioritaire.",
"gamerule.sanctuary.seasonal_snow_melt": "Fonte saisonnière de la neige",
"gamerule.sanctuary.seasonal_snow_melt.description": "Fait fondre progressivement les dépôts météo suivis hors hiver, en météo Real Time. Préserve les constructions en neige."
"gamerule.sanctuary.seasonal_snow_melt.description": "Fait fondre progressivement les dépôts météo suivis hors hiver, en météo Real Time. Préserve les constructions en neige.",
"sanctuary.multiblock.cooking_count": "%s / 27 en cuisson",
"sanctuary.multiblock.how_to_cook": "Fonctionnement",
"sanctuary.multiblock.cooking_help": "Ingrédients : une case = une cuisson, jusqu’à 27 en parallèle. Combustibles : réserve commune pour toutes les cases. Résultats : récupérer ici les objets cuits, aux mêmes positions que les ingrédients. Chaque cuisson consomme du combustible à vitesse normale : 1 charbon cuit environ 8 objets au total. La réserve affiche les secondes de combustible déjà allumé, partagées entre les cuissons. Les barres sous les ingrédients montrent leur progression. Maj-clic transfère vers les premières cases disponibles ; déposer à la souris conserve la case choisie.",
"sanctuary.multiblock.shared_fuel": "Combustible partagé entre les 27 cuissons.",
"sanctuary.multiblock.lane": "Cuisson n° %s",
"sanctuary.multiblock.state.idle": "Aucun ingrédient dans cette case.",
"sanctuary.multiblock.state.cooking": "En cuisson : %s %%",
"sanctuary.multiblock.state.no_fuel": "Ajouter du combustible — %s %% conservés",
"sanctuary.multiblock.state.blocked": "Vider la case correspondante dans Résultats.",
"sanctuary.multiblock.state.no_recipe": "Cet objet ne possède pas de recette au four.",
"sanctuary.statuary.choose_material": "Ajoutez un bloc pour appliquer sa palette au modèle. Les conteneurs doivent être vides.",
"sanctuary.statuary.material_ready": "1 bloc = 1 sculpture. Palette : %s."
}
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.114"
version = "beta.117"
pack-format = "packwiz:1.1.0"
[index]