Compare commits

...
Author SHA1 Message Date
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
16 changed files with 342 additions and 63 deletions
+17 -6
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.116** ouvre le Clay Workshop aux blocs de matière : les
nuances de leur texture sont appliquées au modèle, avec aperçu et fabrication
payée. Elle réunit aussi le correctif du Fourneau (cases décalées par trois,
combustible stable et explication de la chauffe).
Le [ticket de livraison](docs/sculpture-materials-beta116.md) conserve les
contrôles et le suivi de distribution. La release est publiée et l'instance
**Sanctuary Beta** est synchronisée, avec ses sauvegardes et réglages conservés.
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,11 +37,20 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
l’étude de Xaero et des alternatives libres.
## 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).
Préparation locale pour la prochaine livraison commune avec latelier.
Étape locale intégrée à la livraison commune beta.116.
## beta.114 — Animaux portés et fuite en vol
+3 -3
View File
@@ -55,12 +55,12 @@ relues dans `build/furnace115-evidence/`. Marqueur `FURNACE115_PASS` dans
`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
relancera les contrôles complets sur l'ensemble des changements.
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 est préparée localement pour la livraison commune
avec les matériaux de sculpture ; le canal publié reste beta.114.
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) :
+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`.
+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.115
pack_version=beta.115
mod_version=beta.116
pack_version=beta.116
resource_pack_version=beta.090
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+3
View File
@@ -93,6 +93,9 @@ 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') }
@@ -0,0 +1,77 @@
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());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){}
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()){
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");
var shades=new ArrayList<VoxelSculpture.Cell>();for(int x=0;x<16;x++){int v=x*17;shades.add(new VoxelSculpture.Cell(x,0,0,0xFF000000|v<<16|v<<8|v));}var ramp=new VoxelSculpture(source.modelId(),"Ramp",16,1,1,shades);var palette=SculptureMaterialSampler.sample(new ItemStack(Items.OAK_PLANKS));var recolored=palette.apply(ramp);check(recolored.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>2,"Material retains multiple texture shades");for(int x=1;x<16;x++)check(SculpturePalette.lightness(recolored.cells().get(x).color())>=SculpturePalette.lightness(recolored.cells().get(x-1).color()),"Model shading order retained");});
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");});
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");
}
}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);}
}
@@ -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,7 +227,7 @@ 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{
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}};
@@ -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,22 @@ 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();
if(previewSource!=source||!ItemStack.isSameItemSameComponents(lastMaterial,material)||sampledResources!=resources){
previewSource=source;lastMaterial=material.copy();sampledResources=resources;
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 +99,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;}
}
@@ -0,0 +1,26 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import net.minecraft.util.ARGB;
import net.minecraft.world.item.*;
/** Representative texture pixels from the active pack, including biome tint and animated first frame. */
final class SculptureMaterialSampler {
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 tints=mc.getBlockColors().getTintSources(state);
int tint=tints.isEmpty()?0xFFFFFFFF:tints.getFirst().colorInWorld(state,mc.level,mc.player.blockPosition());
var pixels=new ArrayList<Integer>();
for(int pixel:sample.pixels().argb()){
int color=ARGB.multiply(pixel,tint);
if((color>>>24)>=128)pixels.add(color|0xFF000000);
}
if(pixels.isEmpty())pixels.add(sample.average()|0xFF000000);
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);
}
}
@@ -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,42 @@ 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 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;
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=selected!=null&&selectedPalette!=null&&material(item)&&item.is(selectedMaterial)?selectedPalette.apply(selected):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 +100,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,23 @@
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.comparingDouble(SculpturePalette::lightness).thenComparingInt(Integer::intValue)).toList();
}
public static double lightness(int color){return StatueVoxelizer.colorSpace(color)[0];}
public VoxelSculpture apply(VoxelSculpture source){
if(source==null)return null;
var shades=new HashMap<Integer,Double>();for(var cell:source.cells())shades.computeIfAbsent(cell.color(),SculpturePalette::lightness);
double min=shades.values().stream().mapToDouble(Double::doubleValue).min().orElse(0),max=shades.values().stream().mapToDouble(Double::doubleValue).max().orElse(0);
var matches=new HashMap<Integer,Integer>();
shades.forEach((color,l)->{double fraction=max-min<.00001?.5:(l-min)/(max-min);matches.put(color,colors.get(Math.clamp((int)Math.round(fraction*(colors.size()-1)),0,colors.size()-1)));});
return new VoxelSculpture(source.modelId(),source.name(),source.width(),source.height(),source.depth(),source.cells().stream()
.map(c->new VoxelSculpture.Cell(c.x(),c.y(),c.z(),matches.get(c.color()))).toList());
}
}
@@ -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){
@@ -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.",
@@ -2203,5 +2203,7 @@
"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.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."
}
@@ -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.",
@@ -2203,5 +2203,7 @@
"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.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.115"
version = "beta.116"
pack-format = "packwiz:1.1.0"
[index]