diff --git a/README.md b/README.md index 1213ab2..5c0b962 100644 --- a/README.md +++ b/README.md @@ -20,12 +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.119** permet de centrer une sculpture en visant le milieu -du bloc, ou de la caler contre un bord en visant autour. La grille de 1/16 -reste respectée, même pour les modèles de taille impaire. -Le [ticket de livraison](docs/sculpture-center-beta119.md) conserve les contrôles -et le suivi de distribution. La release est publiée et l’instance **Sanctuary Beta** -est synchronisée, sauvegardes et réglages conservés. +La version **beta.120** étend la clé dorée aux états des blocs : clic gauche +pour choisir une propriété, clic droit pour changer sa valeur, Maj pour inverser. +La terre et les modèles de blocs peuvent aussi tourner leurs textures. +Le [ticket de livraison](docs/golden-wrench-beta120.md) détaille les contrôles, +la compatibilité et la distribution. Tests natifs, build complet et deux packs +vérifiés localement. 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. @@ -36,6 +36,11 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après l’étude de Xaero et des alternatives libres. +## beta.120 — Clé dorée et états de blocs + +Gestes du debug stick, rotation de textures et assemblage des machines conservé. +[Contrat et vérifications](docs/golden-wrench-beta120.md). + ## beta.119 — Sculptures au centre ou au bord Pose selon le point visé, centrage sur la grille entière et ancrage conservé diff --git a/docs/golden-wrench-beta120.md b/docs/golden-wrench-beta120.md new file mode 100644 index 0000000..c00d03d --- /dev/null +++ b/docs/golden-wrench-beta120.md @@ -0,0 +1,96 @@ +# TOOL-01 — Clé dorée et états de blocs — beta.120 + +Contrat du 17 septembre 2026, branche `codex/golden-wrench-beta120`. +Le créateur valide les gestes du debug stick : clic gauche choisit la propriété, +clic droit parcourt ses valeurs, Maj inverse le parcours. L'orientation est +proposée en premier, puis les propriétés de forme et les autres états natifs. +La sélection est personnelle, par type de bloc, pour la session courante. +Une indication dans la barre d'action affiche la propriété et sa valeur. +La clé ne casse pas le bloc, y compris en créatif, et ne remplace pas son type. + +Les propriétés natives sont disponibles en survie avec la clé, sans condition +opérateur, dans le respect des droits de construction, protections, portée et +verrous de conteneurs. Comme avec le debug stick, les formes choisies restent +en place immédiatement ; les mises à jour ultérieures du jeu peuvent recalculer +les états dynamiques (redstone, connexions, cultures…). + +Les multiblocs assemblés gardent leurs commandes d'ouverture/dissociation et +leurs propriétés techniques ne sont pas éditées individuellement. Un assemblage +complet garde priorité au clic droit ; Maj permet de régler les composants +non assemblés. Hors d'un assemblage complet, même un four, un baril, un piston +ou un établi reçoit l'action générique. + +## Rotation de texture et contrat de stockage + +La terre n'a pas d'orientation dans son blockstate vanilla. Une propriété +supplémentaire de la clé, « Rotation des textures », parcourt 0°, 90°, 180° et +270° sur les faces des modèles de blocs. La géométrie, l'éclairage, les teintes, +les ressources actives, le bloc, son inventaire et son butin restent natifs. +Les rendus spéciaux d'entités de blocs ne sont pas transformés par ce réglage +des faces ; leur orientation demeure modifiable par leurs états natifs. + +Nouvelle donnée facultative `sanctuary:wrench_textures_v1`, attachée au chunk : +table immuable position entière → quart de tour (1 à 3). Une absence signifie +0°. Elle n'est créée qu'après une action de clé ; revenir à 0° retire l'entrée, +puis l'attachement s'il est vide. Aucun état vanilla ni format existant n'est +modifié ; aucun parcours, conversion ou régénération des anciens mondes. +Les modifications de blockstate du même type conservent la rotation. Casser, +remplacer ou déplacer par piston retire la rotation à l'ancienne position ; +les objets récupérés restent ordinaires. Les nouveaux clients la reçoivent +après le chunk natif, par lots bornés de 1 024 entrées au maximum. +Déconnexion, changement de dimension et déchargement libèrent le cache visuel. +Revenir à une ancienne version du mod supprime seulement cet effet visuel ; +les blocs conservent leurs identifiants, données et états natifs. + +## Vérifications et livraison + +Le parcours `Wrench120ClientChecks` passe en **47 s** sur Minecraft 26.3, +client macOS et serveur intégré, dans un monde plat jetable de graine 120 : + +- **2 522 propriétés natives** parcourues, avec valeurs inverses et type conservé. +- Vrais clics sur les escaliers : orientation, moitié supérieure et forme, + sans casser le bloc en créatif ; gestes inverses et sélection distincte. + Les doubles événements créatifs d'un même clic sont filtrés à la cadence + native de cinq ticks. +- Terre en survie : rotation réelle des UV émis, positions des sommets et nombre + de faces identiques ; retour exact après quatre quarts de tour ou un inverse. +- Rechargement des ressources conservant les UV tournés ; capture de terre relue. +- Coffre tourné avec ses 23 diamants intacts, sans ouverture accidentelle. +- Assemblage/dissociation par vrais clics des quatre familles : Métabli, Fût, + Fourneau et super piston. Un clic gauche sur un assemblage ne modifie pas + les numéros de composants ; barils et pistons isolés tournent normalement. +- Refus en aventure, spectateur ou hors portée ; retrait des données visuelles + après remplacement, maintien lors d'un changement d'état du même bloc. +- Sauvegarde/reconnexion : rotation de terre côté serveur et client, état + natif de l'escalier et inventaire du coffre conservés. + +```sh +./gradlew :sanctuary:runClientGameTest \ + -PsanctuaryClientTests=true -PsanctuaryWrench120ClientTests=true \ + -PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true +``` + +Marqueurs `WRENCH120_PASS` et `WRENCH120_PROPERTIES 2522` dans +`build/wrench120-client.log`, captures dans `build/wrench120-evidence/`. +Aucun monde personnel ouvert ; pas de validation Windows ni depuis deux +ordinateurs. Les rendus de faces natifs et le rechargement du pack sont vérifiés ; +pas de validation spécifique d'un shader tiers. +Le GameTest dédié reste exclu conformément au refus antérieur de son EULA. + +`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest` +passe en **2 min 12 s**, 125 tâches. Les sources JAR correspondent exactement +aux sources du dépôt. La comparaison avec beta.119 limite les différences de +production aux classes de clé/assemblage, aux deux nouveaux hooks, au rendu +et aux libellés prévus ; toutes les autres ressources sont identiques. +Les packs normal/Test contiennent le même JAR Sanctuary et aucune sauvegarde +ni fixture de test. + +- Sanctuary-beta.120.mrpack : 10237973 octets, SHA-256 + `86b5e480a12ba20b5c1e308c77dbb54bdf8a56806915fb32ca2d16f9a72cc9c9`. +- Sanctuary-Test-beta.120.mrpack : 10256895 octets, SHA-256 + `2e3818483ba7dfa6e5652b97933e105a44e7cc4b269fc567116bf895d54bd8d9`. +- JAR Sanctuary : SHA-256 + `8e35ef0f134a46b7760604175ad768264946d31fdd0694982e049e7a928fde2a`. + +Reçu local : `build/wrench120-artifact.json`. Publication et synchronisation +Prism à consigner après leur réussite. diff --git a/gradle.properties b/gradle.properties index 79417fe..f2382e2 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.119 -pack_version=beta.119 +mod_version=beta.120 +pack_version=beta.120 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 dd12151..7acd51c 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -93,6 +93,10 @@ tasks.named('runGameTest') { } tasks.named('processGametestResources') { + def wrench120Client = providers.gradleProperty('sanctuaryWrench120ClientTests').map { it.toBoolean() }.getOrElse(false) + inputs.property('sanctuaryWrench120ClientTests', wrench120Client) + if (wrench120Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Wrench120ClientChecks') } + 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') } diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Wrench120ClientChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Wrench120ClientChecks.java new file mode 100644 index 0000000..60a6b55 --- /dev/null +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Wrench120ClientChecks.java @@ -0,0 +1,140 @@ +package fr.koka.sanctuary.client; + +import com.mojang.blaze3d.platform.InputConstants; +import fr.koka.sanctuary.gametest.Starter039TestSupport; +import fr.koka.sanctuary.multiblock.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.fabricmc.fabric.api.client.renderer.v1.Renderer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.core.*; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.contents.TranslatableContents; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.RandomSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.*; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.block.*; +import net.minecraft.world.level.block.entity.*; +import net.minecraft.world.level.block.piston.PistonBaseBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.*; +import net.minecraft.world.phys.*; + +/** Real client gestures and persisted server state; rendered UVs must change without moving geometry. */ +public final class Wrench120ClientChecks implements FabricClientGameTest { + private static final BlockPos TARGET=new BlockPos(0,1,0),CHEST=new BlockPos(2,1,0),DIRT=new BlockPos(-2,1,0); + 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 TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false); + }}); + net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved; + try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Wrench120 "+UUID.randomUUID());s.setSeed("120");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){ + c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer(); + server.runOnServer(s->{var p=player(s);var l=p.level();p.teleportTo(.5,1,3.2);p.getInventory().setItem(p.getInventory().getSelectedSlot(),new ItemStack(Multiblocks.WRENCH));p.inventoryMenu.broadcastFullState(); + l.setBlockAndUpdate(TARGET,Blocks.OAK_STAIRS.defaultBlockState());l.setBlockAndUpdate(CHEST,Blocks.CHEST.defaultBlockState());((ChestBlockEntity)l.getBlockEntity(CHEST)).setItem(0,new ItemStack(Items.DIAMOND,23));l.setBlockAndUpdate(DIRT,Blocks.DIRT.defaultBlockState()); + s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon"); + int tested=0;for(var block:BuiltInRegistries.BLOCK)for(var property:block.getStateDefinition().getProperties()){ + var before=block.defaultBlockState();var after=GoldenWrench.cycle(before,property,false); + check(after.is(block)&&GoldenWrench.cycle(after,property,true)==before,"Every registered native property has reversible values");tested++; + } + check(tested>1000,"Broad block property coverage");System.out.println("WRENCH120_PROPERTIES "+tested); + }); + c.waitFor(m->m.player.getMainHandItem().is(Multiblocks.WRENCH)&&m.player.distanceToSqr(.5,1,3.2)<.1&&m.level.getBlockState(TARGET).is(Blocks.OAK_STAIRS),150); + var stairs=c.computeOnClient(m->m.level.getBlockState(TARGET));click(c,TARGET,false,false); + server.waitFor(s->player(s).level().getBlockState(TARGET)!=stairs,100);c.waitFor(m->m.level.getBlockState(TARGET)!=stairs,100); + server.runOnServer(s->{var actual=player(s).level().getBlockState(TARGET);check(actual.getValue(StairBlock.FACING)!=stairs.getValue(StairBlock.FACING)&&actual.getValue(StairBlock.HALF)==stairs.getValue(StairBlock.HALF),"Right click changes facing only");}); + click(c,TARGET,false,true);server.waitFor(s->player(s).level().getBlockState(TARGET)==stairs,100); + click(c,TARGET,true,false);server.runOnServer(s->check(GoldenWrench.selected(player(s),stairs).equals("half"),"Left click selects half without breaking creative block: "+GoldenWrench.selected(player(s),stairs))); + click(c,TARGET,false,false);server.waitFor(s->player(s).level().getBlockState(TARGET).getValue(StairBlock.HALF)==Half.TOP,100); + click(c,TARGET,true,false);server.runOnServer(s->check(GoldenWrench.selected(player(s),stairs).equals("shape"),"Next property is stair shape")); + click(c,TARGET,false,false);server.waitFor(s->player(s).level().getBlockState(TARGET).getValue(StairBlock.SHAPE)!=StairsShape.STRAIGHT,100); + c.takeScreenshot("wrench120-stairs"); + // A container rotates in place; inventory and block entity survive the full native interaction. + click(c,CHEST,false,false);server.runOnServer(s->{var p=player(s);var chest=(ChestBlockEntity)p.level().getBlockEntity(CHEST);check(chest.getItem(0).is(Items.DIAMOND)&&chest.getItem(0).getCount()==23,"Chest contents retained");check(p.containerMenu==p.inventoryMenu,"Wrench does not open ordinary chest");check(chest.getBlockState().getValue(ChestBlock.FACING)!=Blocks.CHEST.defaultBlockState().getValue(ChestBlock.FACING),"Chest rotates");}); + // Survival uses exactly the same gestures; dirt keeps its native ID, shape and loot. + server.runOnServer(s->player(s).setGameMode(GameType.SURVIVAL));c.waitFor(m->!m.player.isCreative(),100); + var original=c.computeOnClient(m->geometry(m,DIRT));c.takeScreenshot("wrench120-dirt-before"); + click(c,DIRT,false,false);server.waitFor(s->WrenchTextures.rotation(player(s).level().getChunkAt(DIRT),DIRT)==1,100);c.waitFor(m->WrenchTextureModels.rotation(DIRT)==1,100); + var rotated=c.computeOnClient(m->geometry(m,DIRT));checkUvs(original,rotated);c.takeScreenshot("wrench120-dirt-90"); + server.runOnServer(s->check(player(s).level().getBlockState(DIRT)==Blocks.DIRT.defaultBlockState(),"Dirt type and native state unchanged")); + click(c,DIRT,true,false);server.runOnServer(s->check(player(s).level().getBlockState(DIRT).is(Blocks.DIRT),"Left click never breaks dirt in survival")); + click(c,DIRT,false,true);c.waitFor(m->WrenchTextureModels.rotation(DIRT)==0,100);checkGeometry(original,c.computeOnClient(m->geometry(m,DIRT)),"Reverse restores exact UVs"); + for(int i=1;i<=4;i++){click(c,DIRT,false,false);int expected=i%4;c.waitFor(m->WrenchTextureModels.rotation(DIRT)==expected,100);} + checkGeometry(original,c.computeOnClient(m->geometry(m,DIRT)),"Four turns restore exact original geometry and UVs"); + click(c,DIRT,false,false);c.waitFor(m->WrenchTextureModels.rotation(DIRT)==1,100); + var reload=c.computeOnClient(Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();checkGeometry(rotated,c.computeOnClient(m->geometry(m,DIRT)),"Resource reload retains texture rotation"); + // Replacing a block clears the visual receipt; blockstate edits retain it. + server.runOnServer(s->{var l=player(s).level();var q=new BlockPos(-3,1,0);l.setBlockAndUpdate(q,Blocks.OAK_LOG.defaultBlockState());WrenchTextures.turn(l,q,1);l.setBlock(q,Blocks.OAK_LOG.defaultBlockState().setValue(RotatedPillarBlock.AXIS,Direction.Axis.X),18);check(WrenchTextures.rotation(l.getChunkAt(q),q)==1,"State edit preserves texture rotation");l.removeBlock(q,false);l.setBlockAndUpdate(q,Blocks.OAK_LOG.defaultBlockState());check(WrenchTextures.rotation(l.getChunkAt(q),q)==0,"Break/replacement clears rotation"); + var p=player(s);p.setGameMode(GameType.ADVENTURE);check(!GoldenWrench.allowed(p,TARGET,InteractionHand.MAIN_HAND,Direction.UP),"Adventure cannot edit");p.setGameMode(GameType.SPECTATOR);check(!GoldenWrench.allowed(p,TARGET,InteractionHand.MAIN_HAND,Direction.UP),"Spectator cannot edit");p.setGameMode(GameType.SURVIVAL);check(!GoldenWrench.allowed(p,TARGET.offset(100,0,0),InteractionHand.MAIN_HAND,Direction.UP),"Out-of-range edit refused"); + }); + // A full Metabli still assembles with two normal right clicks, then separates with sneak-use. + var table=new BlockPos(6,1,0);server.runOnServer(s->{var p=player(s);for(var q:Metatable.members(table))p.level().setBlockAndUpdate(q,Blocks.CRAFTING_TABLE.defaultBlockState());p.teleportTo(7.5,1,4);}); + c.waitFor(m->m.player.distanceToSqr(7.5,1,4)<.1&&m.level.getBlockState(table).is(Blocks.CRAFTING_TABLE),100); + click(c,table.offset(1,0,2),false,false);click(c,table.offset(1,0,2),false,false); + server.waitFor(s->player(s).level().getBlockState(table).is(Metatable.BLOCK),100);c.waitFor(m->m.level.getBlockState(table).is(Metatable.BLOCK),100); + click(c,table.offset(1,0,2),true,false);server.runOnServer(s->check(Metatable.state(player(s).level(),table,true)==1,"Left click cannot corrupt a multiblock's part states")); + click(c,table.offset(1,0,2),false,true);server.waitFor(s->Metatable.state(player(s).level(),table,false)==1,100); + // Both container assemblers and the piston assembler must win over generic state editing. + for(var component:List.of(Blocks.BARREL,Blocks.FURNACE)){ + server.runOnServer(s->{var l=player(s).level();for(int y=0;y<3;y++)for(int z=0;z<3;z++)for(int x=0;x<3;x++)l.setBlockAndUpdate(table.offset(x,y,z),component.defaultBlockState());}); + c.waitFor(m->m.level.getBlockState(table.offset(1,0,2)).is(component),100); + click(c,table.offset(1,0,2),false,false);click(c,table.offset(1,0,2),false,false); + server.runOnServer(s->check(MultiblockData.get(player(s).level()).at(table)!=null,"Complete container machine assembles before generic rotation")); + click(c,table.offset(1,0,2),false,true); + server.runOnServer(s->check(MultiblockData.get(player(s).level()).at(table)==null,"Container machine still separates")); + } + server.runOnServer(s->{var l=player(s).level();for(int y=2;y>=0;y--)for(int z=0;z<3;z++)for(int x=0;x<3;x++)l.setBlockAndUpdate(table.offset(x,y,z),y==0?Blocks.PISTON.defaultBlockState().setValue(PistonBaseBlock.FACING,Direction.UP):Blocks.AIR.defaultBlockState());}); + c.waitFor(m->m.level.getBlockState(table.offset(1,0,2)).is(Blocks.PISTON),100); + click(c,table.offset(1,0,2),false,false);click(c,table.offset(1,0,2),false,false); + server.waitFor(s->SuperPistons.status(player(s).level(),table.offset(1,0,1),Direction.UP,false)==1,100); + c.waitFor(m->m.level.getBlockState(table.offset(1,0,2)).is(SuperPistons.NORMAL),100); + click(c,table.offset(1,0,2),false,true); + server.runOnServer(s->check(player(s).level().getBlockState(table.offset(1,0,2)).is(Blocks.PISTON),"Super piston still separates before editing its parts")); + // An isolated barrel and piston now use generic states instead of an incomplete-structure error. + server.runOnServer(s->{var p=player(s);p.teleportTo(.5,1,3.2);p.level().setBlockAndUpdate(TARGET,Blocks.BARREL.defaultBlockState());});c.waitFor(m->m.player.distanceToSqr(.5,1,3.2)<.1&&m.level.getBlockState(TARGET).is(Blocks.BARREL),100); + click(c,TARGET,false,false);server.runOnServer(s->check(player(s).level().getBlockState(TARGET).getValue(BarrelBlock.FACING)!=Blocks.BARREL.defaultBlockState().getValue(BarrelBlock.FACING),"Isolated barrel rotates")); + server.runOnServer(s->player(s).level().setBlockAndUpdate(TARGET,Blocks.PISTON.defaultBlockState()));c.waitFor(m->m.level.getBlockState(TARGET).is(Blocks.PISTON),100); + click(c,TARGET,false,false);server.runOnServer(s->check(player(s).level().getBlockState(TARGET).getValue(PistonBaseBlock.FACING)!=Blocks.PISTON.defaultBlockState().getValue(PistonBaseBlock.FACING),"Isolated piston rotates")); + server.runOnServer(s->player(s).level().setBlock(TARGET,stairs.setValue(StairBlock.HALF,Half.TOP),18));saved=world.getWorldSave(); + } + try(var world=saved.open()){ + c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);world.getServer().runOnServer(s->{var l=player(s).level();check(WrenchTextures.rotation(l.getChunkAt(DIRT),DIRT)==1,"Server persisted dirt texture");check(l.getBlockState(TARGET).getValue(StairBlock.HALF)==Half.TOP,"Server persisted native edited state");check(((ChestBlockEntity)l.getBlockEntity(CHEST)).getItem(0).getCount()==23,"Saved chest keeps inventory");}); + c.waitFor(m->WrenchTextureModels.rotation(DIRT)==1,150);c.getInput().lookAt(DIRT);c.takeScreenshot("wrench120-reconnect"); + } + System.out.println("WRENCH120_PASS native creative/survival clicks, reverse, properties, textures and resource reload, unchanged geometry, filled chest, multiblock assembly priority, permissions, cleanup and save/reconnect"); + } + private static void click(ClientGameTestContext c,BlockPos pos,boolean left,boolean sneak){ + if(sneak){c.getInput().holdKey(o->o.keyShift);c.waitTicks(3);} + c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(pos.getX()+.5,pos.getY()+.3,pos.getZ()+.5)));c.waitTicks(3); + c.runOnClient(m->check(m.hitResult instanceof BlockHitResult hit&&hit.getBlockPos().equals(pos),"Native cursor hits target "+pos+" instead of "+m.hitResult)); + if(left){c.getInput().holdKey(o->o.keyAttack);c.waitTicks(1);c.getInput().releaseKey(o->o.keyAttack);}else{c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);} + c.waitTicks(5);if(sneak){c.getInput().releaseKey(o->o.keyShift);c.waitTicks(3);} + } + private static List geometry(Minecraft m,BlockPos pos){ + var result=new ArrayList();var state=m.level.getBlockState(pos); + var emitter=Renderer.get().quadEmitter(q->{var data=new float[20];for(int i=0;i<4;i++){data[i*5]=q.x(i);data[i*5+1]=q.y(i);data[i*5+2]=q.z(i);data[i*5+3]=q.u(i);data[i*5+4]=q.v(i);}result.add(data);}); + m.getModelManager().getBlockStateModelSet().get(state).emitQuads(emitter,m.level,pos,state,RandomSource.create(state.getSeed(pos)),face->false);check(!result.isEmpty(),"Native model emits real textured geometry");return result; + } + private static void checkUvs(List before,List after){ + check(before.size()==after.size(),"Same face count");boolean changed=false; + for(int q=0;q a,List b,String message){check(a.size()==b.size(),message);for(int i=0;i> chunks=Map.of(); + public static int rotation(BlockPos pos){return chunks.getOrDefault(ChunkPos.pack(pos),Map.of()).getOrDefault(pos,0);} + public static void register(){ + ClientPlayNetworking.registerGlobalReceiver(WrenchTextures.Update.TYPE,(packet,context)->context.client().execute(()->apply(packet))); + ClientPlayConnectionEvents.DISCONNECT.register((handler,client)->chunks=Map.of()); + ClientLevelEvents.AFTER_CLIENT_LEVEL_CHANGE.register((client,level)->chunks=Map.of()); + ClientChunkEvents.CHUNK_UNLOAD.register((level,chunk)->{var next=new HashMap<>(chunks);next.remove(chunk.getPos().pack());chunks=Map.copyOf(next);}); + ModelLoadingPlugin.register(context->context.modifyBlockModelAfterBake().register((model,bake)->new Rotated(model))); + } + private static void apply(WrenchTextures.Update packet){ + var client=Minecraft.getInstance();if(client.level==null||!client.level.dimension().identifier().equals(packet.dimension()))return; + long key=packet.chunk().pack();var old=chunks.getOrDefault(key,Map.of());var dirty=new HashSet(); + var entries=packet.clear()?new HashMap():new HashMap<>(old);if(packet.clear())dirty.addAll(old.keySet()); + for(var entry:packet.entries()){ + var before=entry.rotation()==0?entries.remove(entry.pos()):entries.put(entry.pos(),entry.rotation()); + if(!Objects.equals(before,entry.rotation()))dirty.add(entry.pos()); + } + var next=new HashMap<>(chunks);if(entries.isEmpty())next.remove(key);else next.put(key,Map.copyOf(entries));chunks=Map.copyOf(next); + var sections=new HashSet();for(var pos:dirty)sections.add(SectionPos.of(pos)); + for(var section:sections)client.levelExtractor.setSectionDirty(section.x(),section.y(),section.z()); + } + static boolean rotate(MutableQuadView quad,int turns){ + // Rotate texture coordinates only: geometry, light, tints and materials stay native. + for(int turn=0;turn cull){ + int turns=rotation(pos);if(turns==0){wrapped.emitQuads(emitter,view,pos,state,random,cull);return;} + emitter.pushTransform(quad->rotate(quad,turns)); + try{wrapped.emitQuads(emitter,view,pos,state,random,cull);}finally{emitter.popTransform();} + } + @Override public Object createGeometryKey(BlockAndTintGetter view,BlockPos pos,BlockState state,RandomSource random){ + var key=wrapped.createGeometryKey(view,pos,state,random);int turns=rotation(pos); + return turns==0||key==null?key:new Geometry(key,turns); + } + } + private WrenchTextureModels(){} +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/WrenchTextureEditMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/WrenchTextureEditMixin.java new file mode 100644 index 0000000..f410b5e --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/WrenchTextureEditMixin.java @@ -0,0 +1,20 @@ +package fr.koka.sanctuary.mixin; + +import fr.koka.sanctuary.multiblock.WrenchTextures; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.LevelChunk; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.*; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(LevelChunk.class) +public abstract class WrenchTextureEditMixin { + @Inject(method="setBlockState(Lnet/minecraft/core/BlockPos;Lnet/minecraft/world/level/block/state/BlockState;I)Lnet/minecraft/world/level/block/state/BlockState;",at=@At("RETURN")) + private void sanctuary$clearTexture(BlockPos pos,BlockState state,int flags,CallbackInfoReturnable ci){ + var before=ci.getReturnValue(); + if(before!=null&&!before.is(state.getBlock())&&((LevelChunk)(Object)this).getLevel() instanceof ServerLevel) + WrenchTextures.forget((LevelChunk)(Object)this,pos); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/WrenchTextureSyncMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/WrenchTextureSyncMixin.java new file mode 100644 index 0000000..f50f6cc --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/WrenchTextureSyncMixin.java @@ -0,0 +1,17 @@ +package fr.koka.sanctuary.mixin; + +import fr.koka.sanctuary.multiblock.WrenchTextures; +import net.minecraft.server.network.*; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.chunk.LevelChunk; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.*; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(PlayerChunkSender.class) +public abstract class WrenchTextureSyncMixin { + @Inject(method="sendChunk",at=@At("RETURN")) + private static void sanctuary$textures(ServerGamePacketListenerImpl connection,ServerLevel level,LevelChunk chunk,CallbackInfo ci){ + WrenchTextures.sendChunk(connection.player,chunk); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/GoldenWrench.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/GoldenWrench.java new file mode 100644 index 0000000..e2982b6 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/GoldenWrench.java @@ -0,0 +1,91 @@ +package fr.koka.sanctuary.multiblock; + +import java.util.*; +import net.fabricmc.fabric.api.event.player.AttackBlockCallback; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.minecraft.core.*; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.*; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.*; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BaseContainerBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.Property; + +/** Server-owned debug-stick gestures without changing a block's type or its inventory. */ +public final class GoldenWrench { + public static final String TEXTURE="sanctuary_texture"; + private static final Map> SELECTED=new WeakHashMap<>(); + private static final Map LAST_SELECTION=new WeakHashMap<>(); + public static final class Tool extends Item { + public Tool(Properties properties){super(properties);} + @Override public boolean canDestroyBlock(ItemStack stack,BlockState state,Level level,BlockPos pos,LivingEntity entity){return false;} + } + public static void register(){ + WrenchTextures.register(); + AttackBlockCallback.EVENT.register((player,level,hand,pos,face)->{ + if(!player.getItemInHand(hand).is(Multiblocks.WRENCH))return InteractionResult.PASS; + if(player.isSpectator())return InteractionResult.FAIL; + if(player instanceof ServerPlayer p){if(!allowed(p,pos,hand,face))return InteractionResult.FAIL;select(p,pos);} + return InteractionResult.SUCCESS; + }); + ServerPlayConnectionEvents.DISCONNECT.register((handler,server)->{SELECTED.remove(handler.player);LAST_SELECTION.remove(handler.player);}); + } + public static boolean allowed(ServerPlayer p,BlockPos pos,InteractionHand hand,Direction face){ + return p.isAlive()&&!p.isSpectator()&&p.getAbilities().mayBuild&&p.level().hasChunkAt(pos) + &&p.isWithinBlockInteractionRange(pos,0)&&p.level().mayInteract(p,pos) + &&p.mayUseItemAt(pos,face,p.getItemInHand(hand)) + &&!p.blockActionRestricted(p.level(),pos,p.gameMode.getGameModeForPlayer()) + &&(!(p.level().getBlockEntity(pos) instanceof BaseContainerBlockEntity container)||container.canOpen(p)); + } + private static boolean assembled(ServerPlayer p,BlockPos pos,BlockState state){ + return state.is(Metatable.BLOCK)||state.getBlock() instanceof SuperPistonBlocks.Base + ||state.getBlock() instanceof SuperPistonBlocks.Part||MultiblockData.get(p.level()).at(pos)!=null; + } + public static List properties(BlockState state){ + var result=new ArrayList<>(state.getProperties().stream().map(Property::getName).sorted(Comparator.comparingInt(GoldenWrench::priority).thenComparing(s->s)).toList()); + result.add(TEXTURE);return List.copyOf(result); + } + private static int priority(String name){return switch(name){case "facing","axis","rotation","orientation"->0;case "half","type","shape"->1;default->2;};} + public static String selected(ServerPlayer p,BlockState state){ + var choices=properties(state);var value=SELECTED.getOrDefault(p,Map.of()).get(state.getBlock()); + return value!=null&&choices.contains(value)?value:choices.getFirst(); + } + private static void select(ServerPlayer p,BlockPos pos){ + var state=p.level().getBlockState(pos);if(state.isAir())return; + if(assembled(p,pos,state)){p.sendSystemMessage(text("assembled"),true);return;} + // Creative start/continue callbacks can describe the same click twice. Match its native 5-tick repeat cadence. + int tick=p.level().getServer().getTickCount();var last=LAST_SELECTION.get(p); + if(last!=null&&tick-last<5)return;LAST_SELECTION.put(p,tick); + var choices=properties(state);var selected=SELECTED.computeIfAbsent(p,key->new HashMap<>()); + var before=selected.get(state.getBlock());int previous=before==null?-1:choices.indexOf(before); + int next=previous<0?0:Math.floorMod(previous+(p.isShiftKeyDown()?-1:1),choices.size()); + var property=choices.get(next);selected.put(state.getBlock(),property); + p.sendSystemMessage(text("selected",label(property),value(p,pos,state,property)),true); + } + public static void change(ServerPlayer p,BlockPos pos){ + var state=p.level().getBlockState(pos);if(state.isAir()||assembled(p,pos,state))return; + var name=selected(p,state);SELECTED.computeIfAbsent(p,key->new HashMap<>()).put(state.getBlock(),name); + if(name.equals(TEXTURE))WrenchTextures.turn(p.level(),pos,p.isShiftKeyDown()?-1:1); + else { + var property=state.getBlock().getStateDefinition().getProperty(name); + // Same flags as the vanilla debug stick: synchronize, preserve deliberately chosen shapes. + p.level().setBlock(pos,cycle(state,property,p.isShiftKeyDown()),Block.UPDATE_CLIENTS|Block.UPDATE_KNOWN_SHAPE); + } + p.sendSystemMessage(text("changed",label(name),value(p,pos,p.level().getBlockState(pos),name)),true); + } + public static > BlockState cycle(BlockState state,Property property,boolean reverse){ + var values=new ArrayList<>(property.getPossibleValues());int index=values.indexOf(state.getValue(property)); + return state.setValue(property,values.get(Math.floorMod(index+(reverse?-1:1),values.size()))); + } + private static Object value(ServerPlayer p,BlockPos pos,BlockState state,String name){ + return name.equals(TEXTURE)?(WrenchTextures.rotation(p.level().getChunkAt(pos),pos)*90)+"°":propertyValue(state,state.getBlock().getStateDefinition().getProperty(name)); + } + private static > String propertyValue(BlockState state,Property property){return property.getName(state.getValue(property));} + private static Component label(String name){return name.equals(TEXTURE)?text("texture"):Component.literal(name);} + private static Component text(String name,Object...args){return Component.translatable("sanctuary.wrench."+name,args);} + private GoldenWrench(){} +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/Metatable.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/Metatable.java index 92d36a8..98a1bbf 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/Metatable.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/Metatable.java @@ -80,10 +80,11 @@ public final class Metatable { if(p.isShiftKeyDown()){dissolve(level,origin);p.sendSystemMessage(Multiblocks.text("separated"),true);}else open(p,origin); return true; } - if(!s.is(Blocks.CRAFTING_TABLE))return false; + if(!s.is(Blocks.CRAFTING_TABLE)||p.isShiftKeyDown())return false; var candidates=new ArrayList(); for(int z=0;z<3;z++)for(int x=0;x<3;x++){var o=pos.offset(-x,0,-z);if(state(level,o,false)==1)candidates.add(o);} - if(candidates.size()!=1){previews.remove(p);p.sendSystemMessage(Multiblocks.text(candidates.isEmpty()?"incomplete":"ambiguous"),true);return true;} + if(candidates.isEmpty()){previews.remove(p);return false;} + if(candidates.size()!=1){previews.remove(p);p.sendSystemMessage(Multiblocks.text("ambiguous"),true);return true;} var origin=candidates.getFirst();var before=previews.remove(p); if(before!=null&&before.level==level&&before.origin.equals(origin)&&before.until>=level.getGameTime()){ p.sendSystemMessage(Multiblocks.text(assemble(p,origin)?"assembled":"unavailable"),true);return true; diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/Multiblocks.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/Multiblocks.java index 03de6a8..458fb03 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/Multiblocks.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/Multiblocks.java @@ -17,14 +17,13 @@ import net.minecraft.world.inventory.*; import net.minecraft.world.item.*; import net.minecraft.world.level.block.*; import net.minecraft.world.level.block.entity.*; -import net.minecraft.world.level.block.state.properties.BlockStateProperties; import net.minecraft.world.phys.Vec3; import java.util.*; import static fr.koka.sanctuary.multiblock.MultiblockData.Machine; public final class Multiblocks { public static final Item WRENCH=Registry.register(BuiltInRegistries.ITEM,SanctuaryMod.id("golden_wrench"), - new Item(new Item.Properties().stacksTo(1).setId(ResourceKey.create(Registries.ITEM,SanctuaryMod.id("golden_wrench"))))); + new GoldenWrench.Tool(new Item.Properties().stacksTo(1).setId(ResourceKey.create(Registries.ITEM,SanctuaryMod.id("golden_wrench"))))); public static final MenuType MENU=Registry.register(BuiltInRegistries.MENU,SanctuaryMod.id("multiblock"), new ExtendedMenuType<>(MultiblockMenu::new,MultiblockMenu.View.CODEC)); private record Preview(ServerLevel level,BlockPos origin,boolean furnace,long until){} @@ -32,6 +31,7 @@ public final class Multiblocks { public static Component text(String key,Object...args){return Component.translatable("sanctuary.multiblock."+key,args);} public static void register(){ FourneauAppearance.register(); + GoldenWrench.register(); SuperPistons.register(); Metatable.register(); net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.TOOLS_AND_UTILITIES).register(out->out.accept(WRENCH)); @@ -44,7 +44,9 @@ public final class Multiblocks { // Preserve vanilla sneak-placement (for example, attaching a hopper to the facade). if(!wrench&&p.isShiftKeyDown()&&(!p.getMainHandItem().isEmpty()||!p.getOffhandItem().isEmpty()))return InteractionResult.PASS; if(!level.mayInteract(p,pos)||!p.mayUseItemAt(pos,hit.getDirection(),p.getItemInHand(hand)))return InteractionResult.FAIL; - if(wrench){if(!Metatable.wrench(p,pos)&&!SuperPistons.wrench(p,pos,hit.getDirection()))useWrench(p,pos,hit.getDirection());return InteractionResult.SUCCESS;} + if(wrench){if(!GoldenWrench.allowed(p,pos,hand,hit.getDirection()))return InteractionResult.FAIL; + if(!Metatable.wrench(p,pos)&&!SuperPistons.wrench(p,pos,hit.getDirection())&&!useWrench(p,pos,hit.getDirection()))GoldenWrench.change(p,pos); + return InteractionResult.SUCCESS;} if(ready(level,machine)&&accessible(p,machine))open(p,machine,pos,0,"",0); else p.sendSystemMessage(text("unavailable"),true); return InteractionResult.SUCCESS; @@ -102,31 +104,28 @@ public final class Multiblocks { if(!MultiblockData.get(p.level()).add(m))return false; closeViewers(p.level(),m);FourneauAppearance.changed(p.level(),m,true);return true; } - private static void useWrench(ServerPlayer p,BlockPos pos,Direction face){ + private static boolean useWrench(ServerPlayer p,BlockPos pos,Direction face){ var level=p.level();var data=MultiblockData.get(level);var m=data.at(pos); if(m!=null){ - if(!ready(level,m)||!accessible(p,m)){p.sendSystemMessage(text("unavailable"),true);return;} + if(!ready(level,m)||!accessible(p,m)){p.sendSystemMessage(text("unavailable"),true);return true;} if(p.isShiftKeyDown()){dissolve(level,m);p.sendSystemMessage(text("separated"),true);} else open(p,m,pos,0,"",0); - return; + return true; } var state=level.getBlockState(pos); - if(!state.is(Blocks.BARREL)&&!state.is(Blocks.FURNACE)){p.sendSystemMessage(text("hint"),true);return;} - if(!(level.getBlockEntity(pos) instanceof BaseContainerBlockEntity be)||!be.canOpen(p))return; - if(p.isShiftKeyDown()){ - var property=state.hasProperty(BlockStateProperties.HORIZONTAL_FACING)?BlockStateProperties.HORIZONTAL_FACING:BlockStateProperties.FACING; - var current=state.getValue(property);var next=current.getAxis().isVertical()?p.getDirection():current.getClockWise(); - level.setBlockAndUpdate(pos,state.setValue(property,next));p.sendSystemMessage(text("rotated"),true);return; - } + if(!state.is(Blocks.BARREL)&&!state.is(Blocks.FURNACE))return false; + if(!(level.getBlockEntity(pos) instanceof BaseContainerBlockEntity be)||!be.canOpen(p))return true; + if(p.isShiftKeyDown())return false; boolean furnace=state.is(Blocks.FURNACE);var candidates=new ArrayList(); for(int y=0;y<3;y++)for(int z=0;z<3;z++)for(int x=0;x<3;x++){ var candidate=new Machine(UUID.randomUUID(),pos.offset(-x,-y,-z),furnace,p.getDirection().getOpposite()); if(state(level,candidate)==1&&candidate.members().stream().noneMatch(q->data.at(q)!=null))candidates.add(candidate); } - if(candidates.size()!=1){PREVIEWS.remove(p);p.sendSystemMessage(text(candidates.isEmpty()?"incomplete":"ambiguous"),true);return;} + if(candidates.isEmpty()){PREVIEWS.remove(p);return false;} + if(candidates.size()!=1){PREVIEWS.remove(p);p.sendSystemMessage(text("ambiguous"),true);return true;} var candidate=candidates.getFirst();var previous=PREVIEWS.remove(p); if(previous!=null&&previous.level()==level&&previous.origin().equals(candidate.origin())&&previous.furnace()==furnace&&previous.until()>=level.getGameTime()){ - p.sendSystemMessage(text(assemble(p,candidate)?"assembled":"unavailable"),true);return; + p.sendSystemMessage(text(assemble(p,candidate)?"assembled":"unavailable"),true);return true; } PREVIEWS.put(p,new Preview(level,candidate.origin(),furnace,level.getGameTime()+200)); for(int axis=0;axis<3;axis++)for(int a=0;a<=3;a+=3)for(int b=0;b<=3;b+=3)for(int t=0;t<=6;t++){ @@ -134,6 +133,7 @@ public final class Multiblocks { level.sendParticles(p,ParticleTypes.END_ROD,false,false,candidate.origin().getX()+px,candidate.origin().getY()+py,candidate.origin().getZ()+pz,1,0,0,0,0); } p.sendSystemMessage(text("confirm",text(furnace?"furnace":"barrel")),false); + return true; } public static void open(ServerPlayer p,Machine m,BlockPos anchor,int page,String query,int section){ open(p,m,anchor,page,query,section,new int[0]); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/SuperPistons.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/SuperPistons.java index 7cecddd..3be2a19 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/SuperPistons.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/SuperPistons.java @@ -79,11 +79,10 @@ public final class SuperPistons { } if(!s.is(Blocks.PISTON)&&!s.is(Blocks.STICKY_PISTON))return false; var f=s.getValue(FACING);boolean sticky=s.is(Blocks.STICKY_PISTON); - if(p.isShiftKeyDown()){ - if(!s.getValue(EXTENDED)&&!l.hasNeighborSignal(clicked))l.setBlockAndUpdate(clicked,s.setValue(FACING,face));return true; - } + if(p.isShiftKeyDown())return false; var found=new ArrayList();for(int a=-1;a<=1;a++)for(int b=-1;b<=1;b++){var c=clicked.relative(u(f),a).relative(v(f),b);if(candidates(l,c,f,sticky))found.add(c);} - if(found.size()!=1){previews.remove(p);message(p,found.isEmpty()?"incomplete":"ambiguous");return true;} + if(found.isEmpty()){previews.remove(p);return false;} + if(found.size()!=1){previews.remove(p);message(p,"ambiguous");return true;} var c=found.getFirst();for(var pos:tiles(c,f,0))if(!l.mayInteract(p,pos)||!p.mayUseItemAt(pos,face,p.getMainHandItem())){previews.remove(p);return true;} var old=previews.remove(p); if(old!=null&&old.level()==l&&old.center().equals(c)&&old.facing()==f&&old.sticky()==sticky&&old.until()>=l.getGameTime()){ diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/WrenchTextures.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/WrenchTextures.java new file mode 100644 index 0000000..cf7787b --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/WrenchTextures.java @@ -0,0 +1,63 @@ +package fr.koka.sanctuary.multiblock; + +import com.mojang.serialization.Codec; +import fr.koka.sanctuary.SanctuaryMod; +import java.util.*; +import net.fabricmc.fabric.api.attachment.v1.*; +import net.fabricmc.fabric.api.networking.v1.*; +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.*; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.chunk.LevelChunk; + +/** Optional per-chunk presentation data. Absent means original vanilla textures. */ +public final class WrenchTextures { + public static final AttachmentType> ROTATIONS=AttachmentRegistry.createPersistent( + SanctuaryMod.id("wrench_textures_v1"),Codec.unboundedMap(Codec.STRING,Codec.intRange(1,3)).xmap(Map::copyOf,map->map)); + public record Entry(BlockPos pos,int rotation){} + public record Update(Identifier dimension,ChunkPos chunk,boolean clear,List entries) implements CustomPacketPayload { + public Update{entries=List.copyOf(entries);if(entries.size()>1024)throw new IllegalArgumentException("Texture batch too large");} + public static final Type TYPE=new Type<>(SanctuaryMod.id("wrench_textures_v1")); + public static final StreamCodec CODEC=StreamCodec.of((b,p)->{ + b.writeIdentifier(p.dimension);b.writeChunkPos(p.chunk);b.writeBoolean(p.clear);b.writeVarInt(p.entries.size()); + for(var entry:p.entries){b.writeBlockPos(entry.pos);b.writeByte(entry.rotation);} + },b->{ + var dimension=b.readIdentifier();var chunk=b.readChunkPos();boolean clear=b.readBoolean();int n=b.readVarInt(); + if(n<0||n>1024)throw new IllegalArgumentException("Texture batch size");var entries=new ArrayList(n); + for(int i=0;i3||ChunkPos.pack(pos)!=chunk.pack())throw new IllegalArgumentException("Invalid texture entry");entries.add(new Entry(pos,rotation));} + return new Update(dimension,chunk,clear,entries); + }); + @Override public Type type(){return TYPE;} + } + public static void register(){PayloadTypeRegistry.clientboundPlay().register(Update.TYPE,Update.CODEC);} + public static int rotation(LevelChunk chunk,BlockPos pos){var map=chunk.getAttached(ROTATIONS);return map==null?0:map.getOrDefault(Long.toString(pos.asLong()),0);} + public static void turn(ServerLevel level,BlockPos pos,int delta){ + var chunk=level.getChunkAt(pos);set(chunk,pos,Math.floorMod(rotation(chunk,pos)+delta,4)); + } + public static void forget(LevelChunk chunk,BlockPos pos){if(rotation(chunk,pos)!=0)set(chunk,pos,0);} + private static void set(LevelChunk chunk,BlockPos pos,int value){ + if(!(chunk.getLevel() instanceof ServerLevel level))return; + var old=chunk.getAttached(ROTATIONS);var next=old==null?new HashMap():new HashMap<>(old); + if(value==0)next.remove(Long.toString(pos.asLong()));else next.put(Long.toString(pos.asLong()),value); + if(next.isEmpty())chunk.removeAttached(ROTATIONS);else chunk.setAttached(ROTATIONS,Map.copyOf(next));chunk.markUnsaved(); + var packet=new Update(level.dimension().identifier(),chunk.getPos(),false,List.of(new Entry(pos.immutable(),value))); + for(var player:PlayerLookup.tracking(level,chunk.getPos()))send(player,packet); + } + public static void sendChunk(ServerPlayer player,LevelChunk chunk){ + var entries=chunk.getAttached(ROTATIONS);var batch=new ArrayList(1024);boolean clear=true; + if(entries!=null)for(var entry:entries.entrySet()){ + BlockPos pos;try{pos=BlockPos.of(Long.parseLong(entry.getKey()));}catch(NumberFormatException ignored){continue;} + if(ChunkPos.pack(pos)!=chunk.getPos().pack()||chunk.getBlockState(pos).isAir())continue; + batch.add(new Entry(pos,entry.getValue())); + if(batch.size()==1024){send(player,new Update(player.level().dimension().identifier(),chunk.getPos(),clear,batch));clear=false;batch.clear();} + } + if(clear||!batch.isEmpty())send(player,new Update(player.level().dimension().identifier(),chunk.getPos(),clear,batch)); + } + private static void send(ServerPlayer p,Update packet){if(ServerPlayNetworking.canSend(p,Update.TYPE))ServerPlayNetworking.send(p,packet);} + private WrenchTextures(){} +} diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json index e47ca4f..e1b0f4c 100644 --- a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json @@ -1979,7 +1979,7 @@ "sanctuary.multiblock.outputs": "Results", "sanctuary.multiblock.heat": "Reserve: %s s", "sanctuary.multiblock.progress": "Cooking: %s %%", - "sanctuary.multiblock.hint": "Right click: assemble / open. Sneak: rotate / separate.", + "sanctuary.multiblock.hint": "Left click: select a property. Right click: change its value. Sneak: reverse. Complete multiblock: assemble / open; sneak: separate.", "sanctuary.multiblock.unavailable": "Machine inaccessible, incomplete or partly unloaded.", "sanctuary.multiblock.separated": "Machine separated. Components retain their contents.", "sanctuary.multiblock.rotated": "Component rotated.", @@ -2221,5 +2221,9 @@ "block.sanctuary.brown_brick_wall": "Brown Brick Wall", "block.sanctuary.green_brick_wall": "Green Brick Wall", "block.sanctuary.red_brick_wall": "Red Brick Wall", - "block.sanctuary.black_brick_wall": "Black Brick Wall" + "block.sanctuary.black_brick_wall": "Black Brick Wall", + "sanctuary.wrench.selected": "Selected property: %s = %s · right click to change", + "sanctuary.wrench.changed": "%s = %s", + "sanctuary.wrench.texture": "Texture rotation", + "sanctuary.wrench.assembled": "Assembled multiblock: right click to open, sneak-right click to separate before editing its components." } diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json index 381ceec..3a3b282 100644 --- a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json @@ -1979,7 +1979,7 @@ "sanctuary.multiblock.outputs": "Résultats", "sanctuary.multiblock.heat": "Réserve : %s s", "sanctuary.multiblock.progress": "Cuisson : %s %%", - "sanctuary.multiblock.hint": "Clic droit : assembler / ouvrir. Maj : tourner / dissocier.", + "sanctuary.multiblock.hint": "Clic gauche : choisir un état. Clic droit : changer sa valeur. Maj : sens inverse. Multibloc complet : assembler / ouvrir ; Maj : dissocier.", "sanctuary.multiblock.unavailable": "Machine inaccessible, incomplète ou partiellement déchargée.", "sanctuary.multiblock.separated": "Machine dissociée. Les composants conservent leur contenu.", "sanctuary.multiblock.rotated": "Composant tourné.", @@ -2221,5 +2221,9 @@ "block.sanctuary.brown_brick_wall": "Muret de briques marron", "block.sanctuary.green_brick_wall": "Muret de briques vertes", "block.sanctuary.red_brick_wall": "Muret de briques rouges", - "block.sanctuary.black_brick_wall": "Muret de briques noires" + "block.sanctuary.black_brick_wall": "Muret de briques noires", + "sanctuary.wrench.selected": "État choisi : %s = %s · clic droit pour modifier", + "sanctuary.wrench.changed": "%s = %s", + "sanctuary.wrench.texture": "Rotation des textures", + "sanctuary.wrench.assembled": "Multibloc assemblé : clic droit pour ouvrir, Maj-clic droit pour dissocier avant de modifier ses composants." } diff --git a/mods/sanctuary/src/main/resources/sanctuary.mixins.json b/mods/sanctuary/src/main/resources/sanctuary.mixins.json index 9b12414..1bb3500 100644 --- a/mods/sanctuary/src/main/resources/sanctuary.mixins.json +++ b/mods/sanctuary/src/main/resources/sanctuary.mixins.json @@ -3,6 +3,8 @@ "package": "fr.koka.sanctuary.mixin", "compatibilityLevel": "JAVA_25", "mixins": [ + "WrenchTextureEditMixin", + "WrenchTextureSyncMixin", "SuperPistonPickMixin", "SuperPistonPickDataMixin", "BoatPassengerRotationMixin", diff --git a/packwiz/pack.toml b/packwiz/pack.toml index 5141b90..4c83ffd 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "beta.119" +version = "beta.120" pack-format = "packwiz:1.1.0" [index]