From e3ca9fcd6383f7646e4fea7e5098e7097ebb30aa Mon Sep 17 00:00:00 2001 From: koka Date: Thu, 17 Sep 2026 07:49:00 +0200 Subject: [PATCH] Apply block texture palettes to workshop sculptures (beta.116) --- README.md | 22 +++-- docs/fourneau-transfers-beta115.md | 6 +- docs/sculpture-materials-beta116.md | 87 +++++++++++++++++++ gradle.properties | 4 +- mods/sanctuary/build.gradle | 3 + .../client/Sculpture116ClientChecks.java | 77 ++++++++++++++++ .../client/Statuary106ClientChecks.java | 25 +++--- .../sanctuary/client/ClayWorkshopScreen.java | 24 +++-- .../client/SculptureMaterialSampler.java | 26 ++++++ .../sanctuary/plans/ClayWorkshopMenu.java | 52 ++++++----- .../sanctuary/plans/SculpturePalette.java | 23 +++++ .../fr/koka/sanctuary/plans/Statuary.java | 2 +- .../assets/sanctuary/lang/en_us.json | 12 +-- .../assets/sanctuary/lang/fr_fr.json | 12 +-- packwiz/pack.toml | 2 +- 15 files changed, 314 insertions(+), 63 deletions(-) create mode 100644 docs/sculpture-materials-beta116.md create mode 100644 mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture116ClientChecks.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SculptureMaterialSampler.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/SculpturePalette.java diff --git a/README.md b/README.md index 8e9c36c..f8b5da0 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,12 @@ 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 [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà l’atelier, les œufs et la neige saisonnière dans l’instance **Sanctuary Beta**. Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées. @@ -35,11 +36,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 l’atelier. +Étape locale intégrée à la livraison commune beta.116. ## beta.114 — Animaux portés et fuite en vol diff --git a/docs/fourneau-transfers-beta115.md b/docs/fourneau-transfers-beta115.md index f88a880..bab290c 100644 --- a/docs/fourneau-transfers-beta115.md +++ b/docs/fourneau-transfers-beta115.md @@ -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) : diff --git a/docs/sculpture-materials-beta116.md b/docs/sculpture-materials-beta116.md new file mode 100644 index 0000000..00595f8 --- /dev/null +++ b/docs/sculpture-materials-beta116.md @@ -0,0 +1,87 @@ +# 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 synchronisation +de l'instance en cours ; leurs résultats seront consignés après vérification. diff --git a/gradle.properties b/gradle.properties index 5970bf5..3399be3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/mods/sanctuary/build.gradle b/mods/sanctuary/build.gradle index a1d9a5e..dd12151 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -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') } diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture116ClientChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture116ClientChecks.java new file mode 100644 index 0000000..2adef86 --- /dev/null +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture116ClientChecks.java @@ -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();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(); + 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);} +} diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Statuary106ClientChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Statuary106ClientChecks.java index 022ef7f..4a0b9ef 100644 --- a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Statuary106ClientChecks.java +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Statuary106ClientChecks.java @@ -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 d’argile"),"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();var uv=new ArrayList(); 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}}; diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/ClayWorkshopScreen.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/ClayWorkshopScreen.java index 1e71eca..9ae29d2 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/ClayWorkshopScreen.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/ClayWorkshopScreen.java @@ -22,7 +22,8 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen{var t=new Thread(r,"Sanctuary clay models");t.setDaemon(true);return t;}); private List 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=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 files){if(!files.isEmpty())load(files.getFirst());} @Override public void removed(){generation++;super.removed();} @@ -88,6 +99,7 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen(); + 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();int count=Math.min(SculpturePalette.MAX_COLORS,pixels.size()); + for(int i=0;i 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