diff --git a/README.md b/README.md index 0cf3c03..531250c 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,10 @@ 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.112** limite l’atelier d’argile à la hotbar active du joueur, -dans un cadre compact. Les statuaires suivent toujours le regard à la pose. -Le [ticket](docs/clay-workshop-hotbar-beta112.md) décrit les essais, la publication -et la mise à jour de l’instance **Sanctuary Beta**. +La version **beta.113** nomme les miniatures « sculptures d’argile » et les +cale contre le bord visé du bloc, sur la grille exacte de 1/16 de bloc. +Le [ticket](docs/clay-sculpture-grid-beta113.md) décrit l’accroche, sa sauvegarde +et les vérifications. L’atelier conserve son menu limité à la hotbar. 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. @@ -34,6 +34,12 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après l’étude de Xaero et des alternatives libres. +## beta.113 — Sculptures d’argile et alignement + +Pose contre le bord visé, orientation selon le regard et coordonnées entières +pour chaque voxel, y compris les modèles de largeur impaire. L’accroche est +conservée à la reconnexion. [Contrat et vérifications](docs/clay-sculpture-grid-beta113.md). + ## beta.112 — Atelier d’argile et hotbar Neuf cases de hotbar et deux emplacements de fabrication, dans un cadre fixe. diff --git a/docs/clay-sculpture-grid-beta113.md b/docs/clay-sculpture-grid-beta113.md new file mode 100644 index 0000000..edb6999 --- /dev/null +++ b/docs/clay-sculpture-grid-beta113.md @@ -0,0 +1,78 @@ +# STAT-04 — Sculptures d’argile et accroche au bord — beta.113 + +Contrat du 17 septembre 2026, branche `codex/clay-sculpture-grid-beta113`. + +Les miniatures de l’atelier deviennent des « sculptures d’argile » / « Clay +sculptures ». Le nom du modèle reste dans leur infobulle. Les statues en blocs +du Métabli restent une catégorie distincte. + +À la pose, le bord horizontal le plus proche du point visé accueille la +sculpture. L’autre axe est centré au pixel entier le plus proche, sur la grille +de 1/16 de bloc. Sur une face latérale, elle touche le bord du bloc support. +À égalité exacte entre deux bords, l’axe Z départage ; au centre exact, +le bord côté joueur est retenu. L’orientation suit toujours le regard. +Le bouton Tourner prépare le modèle avant fabrication. Les marges horizontales vides du modèle ne créent pas de décalage au bord. +Tous les sommets restent +sur la grille entière, même avec des dimensions impaires et après rotation. + +## Compatibilité préalable + +La propriété native additive `anchor` du bloc `sanctuary:statuary` mémorise +`center`, `north`, `south`, `east` ou `west`. Un ancien état sans cette propriété +prend `center` : il reste centré, avec la correction visuelle du demi-voxel. +Le schéma 1, les voxels, noms de modèles et identifiants ne changent pas. +Il n’y a aucun parcours ni réécriture de chunks existants. La sauvegarde native +conserve l’accroche ; les rotations et miroirs de structures la transforment. +Casser et reposer choisit une nouvelle accroche sans modifier le modèle. +Les nouveaux objets emploient le nom traduit de leur type ; les noms déjà +enregistrés et les noms personnalisés restent conservés. La collision cubique +et la limite d’une sculpture par bloc restent inchangées. Client et serveur +doivent utiliser ensemble beta.113. + +## Vérifications natives + +Le parcours `Statuary106ClientChecks`, étendu par `Sculpture113ClientChecks`, +passe en **1 min 25 s**, sur Minecraft 26.3 et un monde plat jetable de graine 106 : + +- 5 120 combinaisons de largeur/profondeur, orientation et accroche : contrôle + de la géométrie émise, grille exacte de 1/16 et absence de dépassement du bloc. +- Modèles avec marges transparentes, repères colorés pour vérifier le sens du + rendu et égalité de la géométrie centrée entre l’objet et le bloc. +- Vrais clics de pose sur les quatre bords : orientation, bord côté serveur, + réception côté client, butin/repose et conservation après reconnexion. +- Contextes natifs de pose sur les faces latérales et coordonnées négatives. +- Rotation/miroir de chaque combinaison d’accroche et d’orientation ; lecture + des anciens états sans `anchor`, avec et sans `facing`. +- Noms français/anglais des objets fabriqués, menu aux échelles 2 et 3, + fabrication payée, Maj-clic limité à la hotbar et régression Métabli. + +```sh +./gradlew :sanctuary:runClientGameTest \ + -PsanctuaryClientTests=true -PsanctuaryStatuary106ClientTests=true \ + -PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true +``` + +Marqueurs `STATUARY113_PASS` et `SCULPTURE113_GEOMETRY_PASS` dans +`build/clay-grid113-client.log`. Captures relues dans +`build/clay-grid113-evidence/`. Le GameTest dédié reste exclu conformément au +refus antérieur de son EULA ; ces essais utilisent le serveur intégré macOS. +Pas de validation Windows ni depuis deux ordinateurs. Aucun monde personnel +n’a été ouvert. + +## Livraison + +`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest` +passe en **3 min 11 s**, 125 tâches. Les sources JAR correspondent exactement +aux sources du dépôt. Les classes et ressources hors du ticket sont identiques +à beta.112. Les deux packs contiennent le même JAR, sans monde, GLB ni test. + +- Sanctuary-beta.113.mrpack : 10173807 octets, SHA-256 + `ed082ff29a0e9a70fd6786bc024241b276cb516bc09fb5151d6cb324604429bb`. +- Sanctuary-Test-beta.113.mrpack : 10192733 octets, SHA-256 + `387f54dc77884a9c9bccdcc1904778ca7f0b2fbda126754fdc49b10a492e4602`. +- JAR Sanctuary : SHA-256 + `e242d9fd2edeede922a72a10e988e29c2b49dea52105cfc9fc8573154849ead3`. + +Reçu local : `build/clay-grid113-artifact.json`. + +Publication et synchronisation de l’instance à effectuer après le commit source. diff --git a/gradle.properties b/gradle.properties index 7f8753e..f467341 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.112 -pack_version=beta.112 +mod_version=beta.113 +pack_version=beta.113 resource_pack_version=beta.090 maven_group=fr.koka.sanctuary jei_version=30.32.0-sanctuary.3 diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture113ClientChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture113ClientChecks.java new file mode 100644 index 0000000..311deec --- /dev/null +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture113ClientChecks.java @@ -0,0 +1,90 @@ +package fr.koka.sanctuary.client; + +import fr.koka.sanctuary.plans.*; +import java.util.*; +import net.minecraft.client.Minecraft; +import net.minecraft.core.*; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.block.*; +import net.minecraft.world.level.storage.TagValueInput; +import net.minecraft.util.ProblemReporter; +import net.minecraft.world.phys.*; + +/** Checks emitted render geometry, not a duplicate of the placement implementation. */ +final class Sculpture113ClientChecks { + static void placementContexts(ServerPlayer player){ + for(var pos:List.of(new BlockPos(2,0,-2),new BlockPos(-2,0,2))){ + for(var edge:Direction.Plane.HORIZONTAL){ + var top=new Vec3(pos.getX()+.5+edge.getStepX()*.4,pos.getY()+1,pos.getZ()+.5+edge.getStepZ()*.4); + var context=new BlockPlaceContext(player,InteractionHand.MAIN_HAND,new ItemStack(Statuary.ITEM),new BlockHitResult(top,Direction.UP,pos,false)); + var state=Statuary.BLOCK.getStateForPlacement(context); + check(context.getClickedPos().equals(pos.above())&&state.getValue(Statuary.ANCHOR)==SculptureAnchor.of(edge),"Top hit selects aimed edge at positive/negative coordinates"); + var side=Vec3.atCenterOf(pos).add(edge.getStepX()*.5,0,edge.getStepZ()*.5); + context=new BlockPlaceContext(player,InteractionHand.MAIN_HAND,new ItemStack(Statuary.ITEM),new BlockHitResult(side,edge,pos,false)); + check(Statuary.BLOCK.getStateForPlacement(context).getValue(Statuary.ANCHOR)==SculptureAnchor.of(edge.getOpposite()),"Side hit anchors against supporting block"); + } + } + for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:SculptureAnchor.values()){ + var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor); + for(var rotation:Rotation.values()){ + var actual=state.rotate(rotation); + check(actual.getValue(Statuary.ANCHOR)==(anchor==SculptureAnchor.CENTER?anchor:SculptureAnchor.of(rotation.rotate(Direction.valueOf(anchor.name())))),"Structure rotates anchor independently of facing"); + } + for(var mirror:Mirror.values()){ + var actual=state.mirror(mirror); + check(actual.getValue(Statuary.ANCHOR)==(anchor==SculptureAnchor.CENTER?anchor:SculptureAnchor.of(mirror.mirror(Direction.valueOf(anchor.name())))),"Structure mirrors anchor independently of facing"); + } + var legacy=net.minecraft.nbt.NbtUtils.writeBlockState(state);legacy.getCompoundOrEmpty("properties").remove("anchor"); + var loaded=net.minecraft.nbt.NbtUtils.readBlockState(net.minecraft.core.registries.BuiltInRegistries.BLOCK,legacy); + check(loaded.getValue(Statuary.FACING)==facing&&loaded.getValue(Statuary.ANCHOR)==SculptureAnchor.CENTER,"Beta.111/112 state keeps facing and defaults anchor"); + } + } + static void geometry(Minecraft client,String model){ + // Every footprint, odd and even, through all orientations and saved anchor states. + for(int w=1;w<=16;w++)for(int d=1;d<=16;d++){ + var sculpture=new VoxelSculpture(model,"Grid fixture",w,2,d,List.of(new VoxelSculpture.Cell(0,0,0,0xFFFF0000),new VoxelSculpture.Cell(w-1,1,d-1,0xFF0000FF))); + for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:SculptureAnchor.values())rendered(entity(client,sculpture,facing,anchor)); + } + // Transparent margins in imported models must not leave a gap at the aimed edge. + var padded=new VoxelSculpture(model,"Padded fixture",13,2,15,List.of(new VoxelSculpture.Cell(2,0,3,-1),new VoxelSculpture.Cell(6,1,10,-1))); + for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:SculptureAnchor.values())rendered(entity(client,padded,facing,anchor)); + // Colored markers identify forward motion after rotation, independent of the origin shift. + var sculpture=new VoxelSculpture(model,"Direction fixture",3,2,7,List.of(new VoxelSculpture.Cell(0,0,0,0xFFFF0000),new VoxelSculpture.Cell(0,0,6,0xFF0000FF),new VoxelSculpture.Cell(2,1,0,0xFF00FF00))); + for(var facing:Direction.Plane.HORIZONTAL){ + var mesh=mesh(entity(client,sculpture,facing,SculptureAnchor.CENTER)); + var red=mesh.stream().filter(q->q.color()==0xFFFF0000).findFirst().orElseThrow();var blue=mesh.stream().filter(q->q.color()==0xFF0000FF).findFirst().orElseThrow(); + check(blue.x()-red.x()==facing.getStepX()*6/16f&&blue.z()-red.z()==facing.getStepZ()*6/16f,"Colored front faces gaze"); + } + var itemMesh=new StatuaryRenderer.ItemRenderer().extractArgument(StatuaryEntity.item(sculpture)); + check(itemMesh.equals(mesh(entity(client,sculpture,Direction.SOUTH,SculptureAnchor.CENTER))),"Item preview shares exact centered grid geometry"); + System.out.println("SCULPTURE113_GEOMETRY_PASS 5120 footprints/orientations/anchors and colored direction markers"); + } + private static StatuaryEntity entity(Minecraft client,VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor){ + var entity=new StatuaryEntity(BlockPos.ZERO,Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor));entity.setLevel(client.level); + var data=new CompoundTag();data.put("sculpture",StatuaryEntity.encode(sculpture));entity.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING,client.level.registryAccess(),data));return entity; + } + private static List mesh(StatuaryEntity entity){ + var renderer=new StatuaryRenderer();var state=renderer.createRenderState();renderer.extractRenderState(entity,state,0,Vec3.ZERO,null);return state.mesh; + } + static void rendered(StatuaryEntity entity){ + var mesh=mesh(entity);check(!mesh.isEmpty(),"Sculpture has visible geometry"); + float minX=1,minZ=1,maxX=0,maxZ=0; + for(var q:mesh){ + check(q.x()*16==Math.rint(q.x()*16)&&q.y()*16==Math.rint(q.y()*16)&&q.z()*16==Math.rint(q.z()*16),"Every rendered voxel is on the exact 1/16 grid"); + check(q.x()>=0&&q.y()>=0&&q.z()>=0&&q.x()+1/16f<=1&&q.y()+1/16f<=1&&q.z()+1/16f<=1,"No voxel spills out of its block"); + minX=Math.min(minX,q.x());maxX=Math.max(maxX,q.x()+1/16f);minZ=Math.min(minZ,q.z());maxZ=Math.max(maxZ,q.z()+1/16f); + } + var anchor=entity.getBlockState().getValue(Statuary.ANCHOR); + switch(anchor){ + case NORTH -> check(minZ==0,"North edge flush");case SOUTH -> check(maxZ==1,"South edge flush"); + case WEST -> check(minX==0,"West edge flush");case EAST -> check(maxX==1,"East edge flush");default -> {} + } + if(anchor!=SculptureAnchor.WEST&&anchor!=SculptureAnchor.EAST)check(Math.abs(minX-(1-maxX))<=1/16f,"Other axis centered to nearest whole voxel"); + if(anchor!=SculptureAnchor.NORTH&&anchor!=SculptureAnchor.SOUTH)check(Math.abs(minZ-(1-maxZ))<=1/16f,"Other axis centered to nearest whole voxel"); + } + private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);} +} 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 a9c64ad..022ef7f 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 @@ -66,10 +66,12 @@ public final class Statuary106ClientChecks implements FabricClientGameTest { 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"); check(net.minecraft.nbt.NbtUtils.readBlockState(net.minecraft.core.registries.BuiltInRegistries.BLOCK,legacy).getValue(Statuary.FACING)==Direction.SOUTH,"Legacy state defaults to unchanged model orientation"); + check(net.minecraft.nbt.NbtUtils.readBlockState(net.minecraft.core.registries.BuiltInRegistries.BLOCK,legacy).getValue(Statuary.ANCHOR)==SculptureAnchor.CENTER,"Legacy state keeps centered anchor"); + Sculpture113ClientChecks.placementContexts(p); for(var q:Metatable.members(TABLE))p.level().setBlockAndUpdate(q,Blocks.CRAFTING_TABLE.defaultBlockState());check(Metatable.assemble(p,TABLE),"Metabli"); s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");}); c.waitFor(m->m.level.getBlockState(WORKSHOP).is(Statuary.WORKSHOP)&&!m.player.isCreative(),200); - c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();}); + c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();Sculpture113ClientChecks.geometry(m,source.modelId());}); open(c);c.runOnClient(m->((ClayWorkshopScreen)m.gui.screen()).onFilesDrop(List.of(file))); c.waitFor(m->!((ClayWorkshopScreen)m.gui.screen()).busy()&&((ClayWorkshopScreen)m.gui.screen()).selected()!=null,200);c.waitTicks(10); server.runOnServer(s->{var p=player(s);var menu=(ClayWorkshopMenu)p.containerMenu;check(menu.getSlot(1).getItem().isEmpty(),"No clay, no result"); @@ -79,6 +81,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.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"); @@ -114,17 +117,17 @@ public final class Statuary106ClientChecks implements FabricClientGameTest { for(var direction:Direction.Plane.HORIZONTAL){ var pos=DIRECTIONS.get(direction);double px=pos.getX()+.5-direction.getStepX()*2,pz=pos.getZ()+.5-direction.getStepZ()*2; server.runOnServer(s->{var p=player(s);p.teleportTo(px,1,pz);p.getInventory().setItem(p.getInventory().getSelectedSlot(),StatuaryEntity.item(source));p.inventoryMenu.broadcastFullState();}); - c.waitFor(m->m.player.distanceToSqr(px,1,pz)<.05&&m.player.getMainHandItem().is(Statuary.ITEM),150);place(c,pos); + c.waitFor(m->m.player.distanceToSqr(px,1,pz)<.05&&m.player.getMainHandItem().is(Statuary.ITEM),150);place(c,pos,direction.getOpposite()); server.waitFor(s->player(s).level().getBlockState(pos).is(Statuary.BLOCK),100); server.runOnServer(s->{var state=player(s).level().getBlockState(pos);check(state.getValue(Statuary.FACING)==direction,"Native placement faces gaze: "+direction); + check(state.getValue(Statuary.ANCHOR)==SculptureAnchor.of(direction.getOpposite()),"Aimed edge stored on server"); check(state.rotate(net.minecraft.world.level.block.Rotation.CLOCKWISE_90).getValue(Statuary.FACING)==direction.getClockWise(),"Structure rotation follows native directions"); check(state.mirror(net.minecraft.world.level.block.Mirror.LEFT_RIGHT).getValue(Statuary.FACING)==(direction.getAxis()==Direction.Axis.Z?direction.getOpposite():direction),"Structure mirror follows native directions"); check(((StatuaryEntity)player(s).level().getBlockEntity(pos)).sculpture().equals(source),"Placement never rewrites model voxels");}); c.waitFor(m->m.level.getBlockEntity(pos) instanceof StatuaryEntity e&&source.equals(e.sculpture()),100); c.runOnClient(m->{var entity=(StatuaryEntity)m.level.getBlockEntity(pos);check(entity.getBlockState().getValue(Statuary.FACING)==direction,"Client receives direction"); - var renderer=new StatuaryRenderer();var state=renderer.createRenderState();renderer.extractRenderState(entity,state,0,Vec3.ZERO,null); - var forward=new org.joml.Vector3f(0,0,1).rotate(com.mojang.math.Axis.YP.rotationDegrees(state.yaw)); - check(Math.abs(forward.x()-direction.getStepX())<.001&&Math.abs(forward.z()-direction.getStepZ())<.001,"Rendered model faces gaze: "+direction);}); + check(entity.getBlockState().getValue(Statuary.ANCHOR)==SculptureAnchor.of(direction.getOpposite()),"Client receives aimed edge"); + Sculpture113ClientChecks.rendered(entity);}); c.takeScreenshot("statuary111-facing-"+direction.getSerializedName()); } // Re-place a dropped north-facing model while looking west: no baked or cumulative turn. @@ -133,7 +136,7 @@ public final class Statuary106ClientChecks implements FabricClientGameTest { var drop=p.level().getEntitiesOfClass(ItemEntity.class,new AABB(north).inflate(1)).stream().filter(e->e.getItem().is(Statuary.ITEM)).findFirst().orElseThrow(); check(source.equals(StatuaryEntity.sculpture(drop.getItem())),"Direction does not alter dropped item"); p.getInventory().setItem(p.getInventory().getSelectedSlot(),drop.getItem().copy());drop.discard();p.teleportTo(west.getX()+2.5,1,west.getZ()+.5);p.inventoryMenu.broadcastFullState();}); - c.waitFor(m->m.player.distanceToSqr(west.getX()+2.5,1,west.getZ()+.5)<.05&&m.player.getMainHandItem().is(Statuary.ITEM)&&m.level.getBlockState(west).isAir(),100);place(c,west); + c.waitFor(m->m.player.distanceToSqr(west.getX()+2.5,1,west.getZ()+.5)<.05&&m.player.getMainHandItem().is(Statuary.ITEM)&&m.level.getBlockState(west).isAir(),100);place(c,west,Direction.EAST); 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); @@ -141,7 +144,7 @@ public final class Statuary106ClientChecks implements FabricClientGameTest { 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.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 d’argile"),"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");}); c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.QUICK_MOVE,m.player));c.waitTicks(10); @@ -180,6 +183,7 @@ public final class Statuary106ClientChecks implements FabricClientGameTest { for(var direction:List.of(Direction.SOUTH,Direction.EAST,Direction.WEST)){ var pos=DIRECTIONS.get(direction); server.runOnServer(s->check(player(s).level().getBlockState(pos).getValue(Statuary.FACING)==direction&&((StatuaryEntity)player(s).level().getBlockEntity(pos)).sculpture().equals(ORIGINAL.get()),"Direction and model survive server save/reload")); + server.runOnServer(s->check(player(s).level().getBlockState(pos).getValue(Statuary.ANCHOR)==SculptureAnchor.of(direction.getOpposite()),"Anchor survives server save/reload")); c.waitFor(m->m.level.getBlockEntity(pos) instanceof StatuaryEntity e&&ORIGINAL.get().equals(e.sculpture())&&e.getBlockState().getValue(Statuary.FACING)==direction,150); } c.waitFor(m->m.level.getBlockEntity(SCULPTURE) instanceof StatuaryEntity e&&e.sculpture()!=null,150);c.runOnClient(m->check(((StatuaryEntity)m.level.getBlockEntity(SCULPTURE)).sculpture().equals(ORIGINAL.get()),"Fresh client receives server model without file")); @@ -187,6 +191,7 @@ public final class Statuary106ClientChecks implements FabricClientGameTest { } System.out.println("STATUARY106_PASS GLB textured scene, bounded import, shared folder filters, native paid crafting, 16 clay colors, model ID, shift-click, place/drop/re-place, Metabli plan, cancellation, server save/reconnect without source"); System.out.println("STATUARY111_PASS nine-slice GUI FR/EN scales 2/3, four native gaze placements, renderer directions, structure rotation/mirror, legacy state default, drop/re-place and persisted orientation"); + System.out.println("STATUARY113_PASS clay sculpture FR/EN, native aimed edges, voxel grid across every footprint/orientation/anchor, side placement, legacy states, rotated/mirrored anchor, save/reconnect"); System.out.println("STATUARY112_PASS fixed compact hotbar-only menu, native input transfer, hidden slots/offhand rejected, full hotbar no consumption, partial merge exact cost, closing restitution"); }catch(IOException e){throw new AssertionError(e);}finally{try{Files.deleteIfExists(file);}catch(IOException ignored){}} } @@ -200,8 +205,9 @@ public final class Statuary106ClientChecks implements FabricClientGameTest { });} private static void close(ClientGameTestContext c){c.runOnClient(m->m.gui.screen().onClose());c.waitFor(m->m.gui.screen()==null,100);} private static void open(ClientGameTestContext c){c.runOnClient(m->m.gameMode.useItemOn(m.player,InteractionHand.MAIN_HAND,new BlockHitResult(Vec3.atCenterOf(WORKSHOP),Direction.UP,WORKSHOP,false)));c.waitForScreen(ClayWorkshopScreen.class);} - private static void place(ClientGameTestContext c,BlockPos pos){ - c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(pos.getX()+.5,pos.getY()-.001,pos.getZ()+.5)));c.waitTicks(3); + private static void place(ClientGameTestContext c,BlockPos pos){place(c,pos,null);} + private static void place(ClientGameTestContext c,BlockPos pos,Direction edge){ + c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(pos.getX()+.5+(edge==null?0:edge.getStepX()*.4),pos.getY()-.001,pos.getZ()+.5+(edge==null?0:edge.getStepZ()*.4))));c.waitTicks(3); c.runOnClient(m->check(m.hitResult instanceof BlockHitResult hit&&hit.getBlockPos().equals(pos.below())&&hit.getDirection()==Direction.UP,"Native cursor targets support top face")); c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse); } 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 c2d9ad1..1e71eca 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 @@ -29,7 +29,9 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen{ - if(!stack.is(Statuary.ITEM))return;var sculpture=StatuaryEntity.sculpture(stack);if(sculpture!=null){lines.add(text("model_name",sculpture.name()));if(flags.isAdvanced())lines.add(Component.literal(sculpture.modelId()));} + if(!stack.is(Statuary.ITEM))return;var sculpture=StatuaryEntity.sculpture(stack);if(sculpture!=null){ + if(!stack.getHoverName().getString().equals(Statuary.BLOCK.getName().getString()))lines.add(Statuary.BLOCK.getName()); + lines.add(text("model_name",sculpture.name()));if(flags.isAdvanced())lines.add(Component.literal(sculpture.modelId()));} }); } @Override protected void init(){ diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/StatuaryRenderer.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/StatuaryRenderer.java index b1517cc..3e429f9 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/StatuaryRenderer.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/StatuaryRenderer.java @@ -4,7 +4,6 @@ import fr.koka.sanctuary.SanctuaryMod; import fr.koka.sanctuary.plans.*; import java.util.*; import com.mojang.blaze3d.vertex.PoseStack; -import com.mojang.math.Axis; import com.mojang.serialization.MapCodec; import net.minecraft.client.renderer.*; import net.minecraft.client.renderer.blockentity.*; @@ -15,6 +14,7 @@ import net.minecraft.client.renderer.special.SpecialModelRenderer; import net.minecraft.client.renderer.state.level.CameraRenderState; import net.minecraft.client.renderer.texture.OverlayTexture; import net.minecraft.core.component.DataComponents; +import net.minecraft.core.Direction; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.component.TypedEntityData; import net.minecraft.world.phys.Vec3; @@ -23,8 +23,9 @@ import org.joml.Vector3fc; /** Shared block/item render geometry: opaque visible faces, generated once per cached sculpture. */ public final class StatuaryRenderer implements BlockEntityRenderer { - private record Quad(float x,float y,float z,int side,int color){} - private static final Map> MESHES=new LinkedHashMap<>(32,.75f,true); + record Quad(float x,float y,float z,int side,int color){} + private record Placement(VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor){} + private static final Map> MESHES=new LinkedHashMap<>(32,.75f,true); private static final Map,VoxelSculpture> ITEMS=new LinkedHashMap<>(32,.75f,true); private static final float[][] FACES={ {0,0,0, 0,1,0, 1,1,0, 1,0,0, 0,0,-1}, @@ -35,26 +36,33 @@ public final class StatuaryRenderer implements BlockEntityRenderer mesh=List.of();float yaw;} + public static final class State extends BlockEntityRenderState {List mesh=List.of();} public State createRenderState(){return new State();} @Override public void extractRenderState(StatuaryEntity entity,State state,float delta,Vec3 camera,ModelFeatureRenderer.CrumblingOverlay breakProgress){ - BlockEntityRenderer.super.extractRenderState(entity,state,delta,camera,breakProgress);state.mesh=mesh(entity.sculpture()); - state.yaw=-entity.getBlockState().getValue(Statuary.FACING).toYRot(); + BlockEntityRenderer.super.extractRenderState(entity,state,delta,camera,breakProgress); + state.mesh=mesh(entity.sculpture(),entity.getBlockState().getValue(Statuary.FACING),entity.getBlockState().getValue(Statuary.ANCHOR)); } public void submit(State state,PoseStack pose,SubmitNodeCollector collector,CameraRenderState camera){ - pose.pushPose();pose.translate(.5,0,.5);pose.rotateDegrees(Axis.YP,state.yaw);pose.translate(-.5,0,-.5); - draw(state.mesh,pose,collector,state.lightCoords,OverlayTexture.NO_OVERLAY);pose.popPose(); + draw(state.mesh,pose,collector,state.lightCoords,OverlayTexture.NO_OVERLAY); } - private static List mesh(VoxelSculpture sculpture){ - if(sculpture==null)return List.of();var cached=MESHES.get(sculpture);if(cached!=null)return cached; + private static List mesh(VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor){ + if(sculpture==null)return List.of();var key=new Placement(sculpture,facing,anchor);var cached=MESHES.get(key);if(cached!=null)return cached; + // Rotate integer cells before anchoring: odd dimensions never introduce half-pixel offsets. + int turns=switch(facing){case WEST -> 1;case NORTH -> 2;case EAST -> 3;default -> 0;}; + for(int i=0;i(); - float ox=(16-sculpture.width())/32f,oz=(16-sculpture.depth())/32f; + int minX=16,minZ=16,maxX=0,maxZ=0; + for(var cell:sculpture.cells()){ + minX=Math.min(minX,cell.x());minZ=Math.min(minZ,cell.z()); + maxX=Math.max(maxX,cell.x()+1);maxZ=Math.max(maxZ,cell.z()+1); + } + float ox=(anchor.originX(maxX-minX)-minX)/16f,oz=(anchor.originZ(maxZ-minZ)-minZ)/16f; for(var cell:sculpture.cells())for(int side=0;side<6;side++){ var s=STEP[side];int x=cell.x()+s[0],y=cell.y()+s[1],z=cell.z()+s[2]; if(x>=0&&x<16&&y>=0&&y<16&&z>=0&&z<16&&occupied.get(x|y<<4|z<<8))continue; quads.add(new Quad(ox+cell.x()/16f,cell.y()/16f,oz+cell.z()/16f,side,cell.color())); } - cached=List.copyOf(quads);MESHES.put(sculpture,cached);if(MESHES.size()>32)MESHES.remove(MESHES.keySet().iterator().next());return cached; + cached=List.copyOf(quads);MESHES.put(key,cached);if(MESHES.size()>32)MESHES.remove(MESHES.keySet().iterator().next());return cached; } private static void draw(List mesh,PoseStack pose,SubmitNodeCollector collector,int light,int overlay){ if(mesh.isEmpty())return; @@ -68,7 +76,7 @@ public final class StatuaryRenderer implements BlockEntityRenderer64)ITEMS.remove(ITEMS.keySet().iterator().next());} - return mesh(sculpture); + return mesh(sculpture,Direction.SOUTH,SculptureAnchor.CENTER); } public void submit(List mesh,PoseStack pose,SubmitNodeCollector collector,int light,int overlay,boolean foil,int outline){draw(mesh,pose,collector,light,overlay);} public void getExtents(java.util.function.Consumer out){out.accept(new Vector3f(0,0,0));out.accept(new Vector3f(1,1,1));} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/SculptureAnchor.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/SculptureAnchor.java new file mode 100644 index 0000000..91ddda1 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/SculptureAnchor.java @@ -0,0 +1,32 @@ +package fr.koka.sanctuary.plans; + +import java.util.Locale; +import net.minecraft.core.Direction; +import net.minecraft.util.StringRepresentable; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.block.Mirror; +import net.minecraft.world.level.block.Rotation; + +/** World-space edge of the occupied block; CENTER is the legacy placement. */ +public enum SculptureAnchor implements StringRepresentable { + CENTER(null), NORTH(Direction.NORTH), SOUTH(Direction.SOUTH), EAST(Direction.EAST), WEST(Direction.WEST); + + private final Direction direction; + SculptureAnchor(Direction direction){this.direction=direction;} + public String getSerializedName(){return name().toLowerCase(Locale.ROOT);} + public SculptureAnchor rotate(Rotation rotation){return direction==null?this:of(rotation.rotate(direction));} + public SculptureAnchor mirror(Mirror mirror){return direction==null?this:of(mirror.mirror(direction));} + public static SculptureAnchor of(Direction direction){return switch(direction){ + case NORTH -> NORTH;case SOUTH -> SOUTH;case EAST -> EAST;case WEST -> WEST; + default -> throw new IllegalArgumentException("Horizontal anchor required"); + };} + public static SculptureAnchor placement(BlockPlaceContext context){ + var pos=context.getClickedPos();var hit=context.getClickLocation(); + double x=hit.x-pos.getX()-.5,z=hit.z-pos.getZ()-.5; + if(Math.abs(x)<1e-7&&Math.abs(z)<1e-7)return of(context.getHorizontalDirection().getOpposite()); + if(Math.abs(x)>Math.abs(z))return x<0?WEST:EAST; + return z<0?NORTH:SOUTH; + } + public int originX(int width){return this==WEST?0:this==EAST?VoxelSculpture.EDGE-width:(VoxelSculpture.EDGE-width)/2;} + public int originZ(int depth){return this==NORTH?0:this==SOUTH?VoxelSculpture.EDGE-depth:(VoxelSculpture.EDGE-depth)/2;} +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/Statuary.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/Statuary.java index d9cb1fe..3c86b22 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/Statuary.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/Statuary.java @@ -28,6 +28,7 @@ import net.minecraft.network.chat.Component; /** The clay workshop crafts portable sculptures; a placed sculpture is only a decoration. */ public final class Statuary { public static final EnumProperty FACING=BlockStateProperties.HORIZONTAL_FACING; + public static final EnumProperty ANCHOR=EnumProperty.create("anchor",SculptureAnchor.class); public static final Block BLOCK=block("statuary",new SculptureBlock()); public static final Item ITEM=blockItem("statuary",BLOCK); public static final Block WORKSHOP=block("clay_workshop",new WorkshopBlock()); @@ -59,11 +60,11 @@ public final class Statuary { private static Item blockItem(String name,Block block){return Registry.register(BuiltInRegistries.ITEM,SanctuaryMod.id(name),new BlockItem(block,new Item.Properties().setId(ResourceKey.create(Registries.ITEM,SanctuaryMod.id(name))).useBlockDescriptionPrefix()));} private static BlockBehaviour.Properties properties(String id){return BlockBehaviour.Properties.of().strength(1.5f).noOcclusion().noLootTable().pushReaction(PushReaction.IMMOVEABLE).setId(ResourceKey.create(Registries.BLOCK,SanctuaryMod.id(id)));} private static final class SculptureBlock extends Block implements EntityBlock { - SculptureBlock(){super(Statuary.properties("statuary"));registerDefaultState(stateDefinition.any().setValue(FACING,Direction.SOUTH));} - @Override protected void createBlockStateDefinition(StateDefinition.Builder builder){builder.add(FACING);} - @Override public BlockState getStateForPlacement(BlockPlaceContext context){return defaultBlockState().setValue(FACING,context.getHorizontalDirection());} - @Override protected BlockState rotate(BlockState state,Rotation rotation){return state.setValue(FACING,rotation.rotate(state.getValue(FACING)));} - @Override protected BlockState mirror(BlockState state,Mirror mirror){return state.rotate(mirror.getRotation(state.getValue(FACING)));} + SculptureBlock(){super(Statuary.properties("statuary"));registerDefaultState(stateDefinition.any().setValue(FACING,Direction.SOUTH).setValue(ANCHOR,SculptureAnchor.CENTER));} + @Override protected void createBlockStateDefinition(StateDefinition.Builder builder){builder.add(FACING,ANCHOR);} + @Override public BlockState getStateForPlacement(BlockPlaceContext context){return defaultBlockState().setValue(FACING,context.getHorizontalDirection()).setValue(ANCHOR,SculptureAnchor.placement(context));} + @Override protected BlockState rotate(BlockState state,Rotation rotation){return state.setValue(FACING,rotation.rotate(state.getValue(FACING))).setValue(ANCHOR,state.getValue(ANCHOR).rotate(rotation));} + @Override protected BlockState mirror(BlockState state,Mirror mirror){return state.setValue(FACING,mirror.mirror(state.getValue(FACING))).setValue(ANCHOR,state.getValue(ANCHOR).mirror(mirror));} public BlockEntity newBlockEntity(BlockPos pos,BlockState state){return new StatuaryEntity(pos,state);} @Override protected List getDrops(BlockState s,LootParams.Builder context){return List.of(context.getOptionalParameter(LootContextParams.BLOCK_ENTITY) instanceof StatuaryEntity entity?entity.item():new ItemStack(ITEM));} @Override protected ItemStack getCloneItemStack(LevelReader l,BlockPos pos,BlockState s,boolean data){return l.getBlockEntity(pos) instanceof StatuaryEntity entity?entity.item():new ItemStack(ITEM);} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/StatuaryEntity.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/StatuaryEntity.java index 82f87cf..02906ad 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/StatuaryEntity.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/StatuaryEntity.java @@ -49,7 +49,7 @@ public final class StatuaryEntity extends BlockEntity { public static ItemStack item(VoxelSculpture sculpture){ var item=new ItemStack(Statuary.ITEM);var tag=new CompoundTag();tag.put("sculpture",encode(sculpture)); item.set(DataComponents.BLOCK_ENTITY_DATA,TypedEntityData.of(Statuary.TYPE,tag)); - item.set(DataComponents.ITEM_NAME,net.minecraft.network.chat.Component.literal(sculpture.name()));return item; + return item; } public static VoxelSculpture sculpture(ItemStack item){ var data=item.get(DataComponents.BLOCK_ENTITY_DATA);if(!item.is(Statuary.ITEM)||data==null||data.type()!=Statuary.TYPE)return null; 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 229ce36..a6c814f 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 @@ -2150,7 +2150,7 @@ "sanctuary.weather.season.summer": "Summer", "sanctuary.weather.season.autumn": "Autumn", "sanctuary.weather.season.winter": "Winter", - "block.sanctuary.statuary": "Statuary", + "block.sanctuary.statuary": "Clay Sculpture", "block.sanctuary.clay_workshop": "Clay workshop", "sanctuary.plans.imported_models": "My 3D models", "sanctuary.plans.native_models": "Discovered creatures", @@ -2161,13 +2161,13 @@ "sanctuary.statuary.folder": "Shared folder", "sanctuary.statuary.refresh": "Refresh", "sanctuary.statuary.models": "GLB models", - "sanctuary.statuary.placement": "The statue is placed facing the direction you look. Rotate adjusts the model before crafting.", + "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 statue. Plain clay keeps model colors. Colored clay gives a solid color.", + "sanctuary.statuary.ready": "1 clay = 1 sculpture. Plain: model colors. Colored: solid tint.", "sanctuary.statuary.empty": "No .glb files", "sanctuary.statuary.input": "Clay", - "sanctuary.statuary.output": "Statue", + "sanctuary.statuary.output": "Sculpture", "sanctuary.statuary.model_name": "Model: %s", "sanctuary.statuary.error.file": "Cannot read this local file.", "sanctuary.statuary.error.format": "Invalid or unsupported GLB 2.0. Export a self-contained static model.", 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 6c0c034..10f24d1 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 @@ -2150,7 +2150,7 @@ "sanctuary.weather.season.summer": "Été", "sanctuary.weather.season.autumn": "Automne", "sanctuary.weather.season.winter": "Hiver", - "block.sanctuary.statuary": "Statuaire", + "block.sanctuary.statuary": "Sculpture d’argile", "block.sanctuary.clay_workshop": "Atelier d’argile", "sanctuary.plans.imported_models": "Mes modèles 3D", "sanctuary.plans.native_models": "Créatures découvertes", @@ -2161,13 +2161,13 @@ "sanctuary.statuary.folder": "Dossier commun", "sanctuary.statuary.refresh": "Actualiser", "sanctuary.statuary.models": "Modèles GLB", - "sanctuary.statuary.placement": "La statue se pose dans le sens de votre regard. Tourner ajuste le modèle avant fabrication.", + "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 statue. Normale : couleurs du modèle. Colorée : teinte unie.", + "sanctuary.statuary.ready": "1 argile = 1 sculpture. Normale : couleurs du modèle. Colorée : teinte unie.", "sanctuary.statuary.empty": "Aucun fichier .glb", "sanctuary.statuary.input": "Argile", - "sanctuary.statuary.output": "Statue", + "sanctuary.statuary.output": "Sculpture", "sanctuary.statuary.model_name": "Modèle : %s", "sanctuary.statuary.error.file": "Impossible de lire ce fichier local.", "sanctuary.statuary.error.format": "GLB 2.0 invalide ou non pris en charge. Exportez un modèle statique autonome.", diff --git a/packwiz/pack.toml b/packwiz/pack.toml index 6520d0e..08b05e1 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "beta.112" +version = "beta.113" pack-format = "packwiz:1.1.0" [index]