Fix multiblock furnace slot mapping and explain shared cooking (beta.115)
Build Sanctuary / build (push) Canceled after 0s

This commit is contained in:
koka
2026-09-17 07:28:40 +02:00
parent ed0e0b26aa
commit 22e58b4b91
11 changed files with 304 additions and 26 deletions
+6
View File
@@ -35,6 +35,12 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
l’étude de Xaero et des alternatives libres.
## beta.115 — Fourneau : dépôts et chauffe
Correctif du décalage par trois cases, combustible stable et progression
par cuisson. [Diagnostic et essais](docs/fourneau-transfers-beta115.md).
Préparation locale pour la prochaine livraison commune avec latelier.
## beta.114 — Animaux portés et fuite en vol
Un animal volant porté entraîne son porteur après un coup. La trajectoire
+74
View File
@@ -0,0 +1,74 @@
# beta.115 — Dépôts et chauffe du Fourneau
Ticket sur `codex/fourneau-transfers-beta115`, après le signalement de piles
qui se réalignent pendant les dépôts rapides avec Maj, dans Ingrédients et
Combustibles, et d'une chauffe difficile à comprendre.
## Diagnostic
La méthode `mayPlace` de la case serveur utilisait `index`, masqué par le
champ hérité `Slot.index` (position affichée). Elle validait donc successivement
une entrée, un combustible et une sortie, au lieu des 27 entrées ou des
27 combustibles. Le client acceptait le dépôt puis le serveur le refusait.
Le test reproduit le refus dun déplacement de la case 1 vers la case 23.
Le correctif utilise explicitement `physicalIndex` pour la validation.
## Comportement
Les transferts Maj dans les vues complètes du Fourneau sont anticipés sur le
client, avec les mêmes destinations et règles de combustible que le serveur.
Les cases se remplissent dans l'ordre, après fusion des piles compatibles.
Un dépôt manuel conserve la case choisie ; aucun tri périodique des ingrédients
n'est ajouté. Les recherches masquent une partie du stock : leurs transferts
entrants continuent d'attendre la réponse du serveur, qui connaît tout le stock.
La chauffe emprunte du combustible à une autre case uniquement pendant le tick
natif. La pile non brûlée retourne immédiatement à sa case d'origine, avec tous
ses composants. Les restes natifs restent associés à leur cuisson : un seau de
lave devient un seau, puis un seau d'eau avec une éponge mouillée.
Les 27 entrées ont chacune une barre et une infobulle : cuisson en cours,
manque de combustible avec progression conservée, résultat bloqué, absence de
recette. Le panneau indique le nombre de cuissons et la réserve allumée en
secondes de travail cumulées. Ce n'est pas une durée réelle restante : les
cuissons parallèles se partagent la réserve. « Fonctionnement » explique les
onglets, les gestes et le rendement normal du combustible en FR/EN.
Les recettes, résultats, expérience et règles de trémies restent natifs.
Aucun changement de format, de monde ou de génération ; les 27 fours physiques
conservent l'unique copie des objets et de leurs temps de chauffe.
## Vérifications
Le test natif `Furnace115ClientChecks` passe avec un serveur intégré, sur un
nouveau monde plat de graine 115 : vrai glisser avec Maj sur neuf piles nommées,
remplissage consécutif, déplacement manuel vers la case 23 et répartition native
sur six cases de colonnes différentes. Les 27 cases de chaque onglet valident
leur rôle physique. Le combustible nommé conserve sa position et ses composants.
La cuisson parallèle, ses états synchronisés, la progression conservée à court
de combustible, les 27 cuissons ravitaillées, le seau de lave et l'éponge,
l'extraction des résultats et la sauvegarde native passent. Captures FR/EN
relues dans `build/furnace115-evidence/`. Marqueur `FURNACE115_PASS` dans
`build/furnace115-client.log`.
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussit
en 2 min 29 s, 125 tâches. Le panneau d'aide reçoit ensuite une dernière
vérification client et une reconstruction ; la livraison commune beta.116
relancera les contrôles complets sur l'ensemble des changements.
Le GameTest dédié reste exclu conformément au refus antérieur de son EULA.
Pas d'essai Windows ou de réseau distant avec latence simulée. Aucun monde
personnel ouvert. Cette étape est préparée localement pour la livraison commune
avec les matériaux de sculpture ; le canal publié reste beta.114.
Archives locales vérifiées (même JAR Sanctuary, sources concordantes, aucune
classe de test ni changement de production extérieur au ticket) :
- `Sanctuary-beta.115.mrpack` : 10184430 octets, SHA-256
`8fcac63b1bf5a01f680fb46666924705db3a2675e102037278e9d650d04cbf22`.
- `Sanctuary-Test-beta.115.mrpack` : 10203354 octets, SHA-256
`5b98fdb8842690158a20b422d6aa5496d74e572b3429498ffb25476d24e87f8a`.
- JAR Sanctuary : `2066d3191d7ee27904927fab269d8160136f048742c3223713e8ed4fe90dd8f5`.
Reçu : `build/furnace115-artifact.json`.
+2 -2
View File
@@ -9,8 +9,8 @@ loom_version=1.17.20
fabric_api_version=0.160.5+26.3
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
mod_version=beta.114
pack_version=beta.114
mod_version=beta.115
pack_version=beta.115
resource_pack_version=beta.090
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+3
View File
@@ -93,6 +93,9 @@ tasks.named('runGameTest') {
}
tasks.named('processGametestResources') {
def furnace115Client = providers.gradleProperty('sanctuaryFurnace115ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryFurnace115ClientTests', furnace115Client)
if (furnace115Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Furnace115ClientChecks') }
def carryPanic114Client = providers.gradleProperty('sanctuaryCarryPanic114ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryCarryPanic114ClientTests', carryPanic114Client)
if (carryPanic114Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.CarryPanic114ClientChecks') }
@@ -0,0 +1,103 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import fr.koka.sanctuary.mixin.MultiblockFurnaceAccess;
import fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.*;
/** Native quick-transfer, arbitrary slot placement, shared cooking and synchronized feedback. */
public final class Furnace115ClientChecks implements FabricClientGameTest {
private static final BlockPos ORIGIN=new BlockPos(2,1,2);
private static MultiblockData.Machine oven;
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Furnace115 "+UUID.randomUUID());s.setSeed("115");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.onUpdateAbilities();p.getInventory().clearContent();p.teleportTo(1.5,1,1.5);
for(int x=-2;x<8;x++)for(int z=-2;z<8;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
oven=new MultiblockData.Machine(UUID.randomUUID(),ORIGIN,true,Direction.NORTH);for(var pos:oven.members())p.level().setBlockAndUpdate(pos,Blocks.FURNACE.defaultBlockState());check(Multiblocks.assemble(p,oven),"Assemble");
for(int i=0;i<9;i++){var stack=new ItemStack(Items.RAW_IRON,8);stack.set(DataComponents.CUSTOM_NAME,Component.literal("Batch "+i));p.getInventory().setItem(i,stack);}Multiblocks.open(p,oven,ORIGIN,0,"",0);
});
c.waitFor(m->m.gui.screen() instanceof MultiblockScreen&&m.player.containerMenu.getSlot(54).hasItem(),200);
// One native Shift-drag crosses nine hotbar stacks, with no mouse release between them.
c.getInput().holdKey(o->o.keyShift);move(c,54);c.runOnClient(m->m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,InputConstants.MOD_SHIFT),1));
for(int i=55;i<63;i++)move(c,i);
c.runOnClient(m->m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,InputConstants.MOD_SHIFT),0));c.getInput().releaseKey(o->o.keyShift);
c.waitTicks(8);c.takeScreenshot("furnace115-inputs");
c.waitFor(m->m.player.containerMenu.getSlot(8).hasItem()&&!m.player.containerMenu.getSlot(62).hasItem(),150);
c.runOnClient(m->{for(int i=0;i<9;i++)check(m.player.containerMenu.getSlot(i).getItem().getHoverName().getString().equals("Batch "+i),"Shift-drag fills consecutive UI slots, no three-column gaps");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);for(int i=0;i<9;i++)check(stock.getItem(i*3).getCount()==8,"Physical inputs correspond to visible slots");});
// Move an ingredient to a distant, arbitrary slot using real inventory packets.
click(c,1,0,ContainerInput.PICKUP);click(c,23,0,ContainerInput.PICKUP);c.waitTicks(10);
c.runOnClient(m->{check(m.player.containerMenu.getSlot(1).getItem().isEmpty()&&m.player.containerMenu.getSlot(23).getItem().getCount()==8,"Manual placement remains in chosen cell: "+m.player.containerMenu.slots.stream().filter(slot->slot.hasItem()).map(slot->slot.index+":"+slot.getItem()).toList()+", cursor="+m.player.containerMenu.getCarried());});
click(c,23,0,ContainerInput.PICKUP);
click(c,-999,net.minecraft.world.inventory.AbstractContainerMenu.getQuickcraftMask(0,0),ContainerInput.QUICK_CRAFT);
for(int i:new int[]{1,9,10,13,14,22})click(c,i,net.minecraft.world.inventory.AbstractContainerMenu.getQuickcraftMask(1,0),ContainerInput.QUICK_CRAFT);
click(c,-999,net.minecraft.world.inventory.AbstractContainerMenu.getQuickcraftMask(2,0),ContainerInput.QUICK_CRAFT);click(c,23,0,ContainerInput.PICKUP);c.waitTicks(6);
c.runOnClient(m->{for(int i:new int[]{1,9,10,13,14,22})check(m.player.containerMenu.getSlot(i).getItem().getCount()==1,"Native drag distribution keeps every chosen column");check(m.player.containerMenu.getSlot(23).getItem().getCount()==2&&m.player.containerMenu.getCarried().isEmpty(),"Distributed count conserved");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);check(stock.getItem(69).getCount()==2&&stock.getItem(3).getCount()==1,"Manual distribution matches server");for(int i=0;i<27;i++)check(p.containerMenu.getSlot(i).mayPlace(new ItemStack(Items.RAW_IRON)),"Every visible input validates its physical input slot, not Slot.index");reset(stock);stock.setItem(0,new ItemStack(Items.RAW_IRON,4));stock.setItem(18,new ItemStack(Items.RAW_GOLD,4));
var coal=new ItemStack(Items.COAL,5);coal.set(DataComponents.CUSTOM_NAME,Component.literal("Stationary fuel"));stock.setItem(79,coal);
for(int tick=0;tick<205;tick++)MultiblockCooking.tick(p.level(),oven);
check(stock.getItem(79).getCount()==4&&stock.getItem(79).getHoverName().getString().equals("Stationary fuel"),"Named unburnt fuel stays in original cell");
for(int i=1;i<79;i+=3)check(stock.getItem(i).isEmpty(),"No displaced coal in other slots");
check(stock.getItem(2).is(Items.IRON_INGOT)&&stock.getItem(20).is(Items.GOLD_INGOT),"Shared heat cooks both arbitrary lanes");
Multiblocks.open(p,oven,ORIGIN,0,"",0);
});
c.waitTicks(6);c.runOnClient(m->{var menu=(MultiblockMenu)m.player.containerMenu;check(menu.cooking==2&&menu.heat>0&&menu.laneState(6)==MultiblockMenu.COOKING,"Real per-lane feedback synchronizes");});c.takeScreenshot("furnace115-cooking");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);reset(stock);
stock.setItem(0,new ItemStack(Items.RAW_IRON));stock.setItem(3,new ItemStack(Items.DIAMOND));stock.setItem(6,new ItemStack(Items.RAW_GOLD));stock.setItem(8,new ItemStack(Items.GOLD_INGOT,64));
var a=(MultiblockFurnaceAccess)stock.cell(0);a.sanctuary$progress(80);a.sanctuary$total(200);p.containerMenu.broadcastChanges();
});c.waitTicks(6);
c.runOnClient(m->{var menu=(MultiblockMenu)m.player.containerMenu;check(menu.laneState(0)==MultiblockMenu.NO_FUEL&&menu.laneProgress(0)==40,"Paused progress is visible and preserved");check(menu.laneState(1)==MultiblockMenu.NO_RECIPE,"Invalid recipe explained");check(menu.laneState(2)==MultiblockMenu.BLOCKED,"Blocked output explained");check(menu.cooking==0&&menu.heat==0,"Cold furnace clearly shown");});c.takeScreenshot("furnace115-paused");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();cookingRegressions(p);var stock=new MultiblockContainer(p.level(),oven);reset(stock);
for(int i=0;i<9;i++){var stack=new ItemStack(Items.COAL,5);stack.set(DataComponents.CUSTOM_NAME,Component.literal("Fuel "+i));p.getInventory().setItem(i,stack);}Multiblocks.open(p,oven,ORIGIN,0,"",1);
});c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.section()==1&&menu.getSlot(54).hasItem(),100);
// No cooking in this fixture: compare the predicted UI in the same call, before any server reply.
c.runOnClient(m->{var menu=m.player.containerMenu;for(int i=0;i<9;i++){
m.gameMode.handleContainerInput(menu.containerId,54+i,0,ContainerInput.QUICK_MOVE,m.player);
check(menu.getSlot(54+i).getItem().isEmpty()&&menu.getSlot(i).getItem().getCount()==5,"Immediate Shift prediction uses consecutive fuel cells");}
check(!menu.getSlot(9).mayPlace(new ItemStack(Items.DIAMOND)),"Client rejects invalid fuel like server");});
c.waitTicks(8);server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);for(int i=0;i<9;i++)check(stock.getItem(i*3+1).getCount()==5,"Fuel server mapping matches prediction");for(int i=0;i<27;i++){check(p.containerMenu.getSlot(i).mayPlace(new ItemStack(Items.COAL)),"All 27 fuel cells accept fuel");check(!p.containerMenu.getSlot(i).mayPlace(new ItemStack(Items.DIAMOND)),"All 27 fuel cells refuse other items");}});c.takeScreenshot("furnace115-fuel");
c.runOnClient(m->{var screen=(MultiblockScreen)m.gui.screen();screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(Multiblocks.text("how_to_cook").getString())).findFirst().orElseThrow().onPress(enter());check(screen.helpVisible(),"Help button opens its explanation");});c.waitTicks(5);c.takeScreenshot("furnace115-help");
var reload=c.computeOnClient(m->{m.getLanguageManager().setSelected("fr_fr");return m.reloadResourcePacks();});c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(3);c.takeScreenshot("furnace115-help-fr");
System.out.println("FURNACE115_PASS native Shift-drag, consecutive input/fuel mapping, immediate prediction, arbitrary placement, stationary named fuel, heat/output/pause/recipe feedback, fuel budget, refuelling, buckets, save and result extraction");
}finally{intro.set(false);}
}
private static void cookingRegressions(ServerPlayer p){
var stock=new MultiblockContainer(p.level(),oven);reset(stock);stock.setItem(0,new ItemStack(Items.WET_SPONGE));stock.setItem(79,new ItemStack(Items.LAVA_BUCKET));for(int t=0;t<205;t++)MultiblockCooking.tick(p.level(),oven);
check(stock.getItem(2).is(Items.SPONGE)&&stock.getItem(1).is(Items.WATER_BUCKET)&&stock.getItem(79).isEmpty(),"Native lava and wet sponge remainder");
reset(stock);for(int i=0;i<81;i+=3)stock.setItem(i,new ItemStack(Items.RAW_IRON));stock.setItem(79,new ItemStack(Items.COAL));for(int t=0;t<90;t++)MultiblockCooking.tick(p.level(),oven);
int partial=((MultiblockFurnaceAccess)stock.cell(0)).sanctuary$progress();check(partial>0&&partial<200,"One coal cannot cook all 27 items");for(int t=0;t<30;t++)MultiblockCooking.tick(p.level(),oven);check(((MultiblockFurnaceAccess)stock.cell(0)).sanctuary$progress()==partial,"No lost paid progress when out of fuel");
stock.setItem(79,new ItemStack(Items.COAL,4));for(int t=0;t<205;t++)MultiblockCooking.tick(p.level(),oven);int produced=0;for(int i=2;i<81;i+=3)produced+=stock.getItem(i).getCount();check(produced==27,"Refuelling completes all lanes exactly once");
var f=(AbstractFurnaceBlockEntity)stock.cell(0);var restored=(AbstractFurnaceBlockEntity)BlockEntity.loadStatic(f.getBlockPos(),f.getBlockState(),f.saveWithFullMetadata(p.level().registryAccess()),p.level().registryAccess());check(((MultiblockFurnaceAccess)restored).sanctuary$heat()==((MultiblockFurnaceAccess)f).sanctuary$heat(),"Native heat survives save");
Multiblocks.open(p,oven,ORIGIN,0,"",2);var menu=(MultiblockMenu)p.containerMenu;check(!menu.getSlot(0).mayPlace(new ItemStack(Items.STONE)),"Results refuse input");check(!menu.quickMoveStack(p,0).isEmpty()&&stock.getItem(2).isEmpty(),"Native result slot can extract");
}
private static void reset(MultiblockContainer stock){stock.clearContent();for(int i=0;i<81;i+=3){var a=(MultiblockFurnaceAccess)stock.cell(i);a.sanctuary$heat(0);a.sanctuary$progress(0);}}
private static void click(ClientGameTestContext c,int slot,int button,ContainerInput type){c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,slot,button,type,m.player));}
private static void move(ClientGameTestContext c,int index){c.runOnClient(m->{var a=(AbstractContainerScreenAccessor)m.gui.screen();var slot=m.player.containerMenu.getSlot(index);var w=m.getWindow();double x=a.sanctuary$getLeftPos()+slot.x+8,y=a.sanctuary$getTopPos()+slot.y+8;double sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();m.mouseHandler.onMove(w.handle(),x*sx,y*sy,(x-m.mouseHandler.getScaledXPos(w))*sx,(y-m.mouseHandler.getScaledYPos(w))*sy);m.mouseHandler.handleAccumulatedMovement();});c.waitTick();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -11,6 +11,7 @@ public final class MultiblockScreen extends ContainerScreen {
private EditBox search;
private Button previous,next,apply;
private double wheel;
private boolean showHelp;
private final java.util.List<Button> sections=new java.util.ArrayList<>();
public MultiblockScreen(MultiblockMenu menu,Inventory inv,Component title){super(menu,inv,title);}
private MultiblockMenu machine(){return (MultiblockMenu)menu;}
@@ -26,7 +27,10 @@ public final class MultiblockScreen extends ContainerScreen {
apply=addRenderableWidget(Button.builder(Multiblocks.text("search"),b->change(0,v.section())).bounds(x,topPos+20,w,20).build());
previous=addRenderableWidget(Button.builder(Component.literal("<"),b->change(v.page()-1,v.section())).bounds(x,topPos+44,w/2-1,20).build());
next=addRenderableWidget(Button.builder(Component.literal(">"),b->change(v.page()+1,v.section())).bounds(x+w/2+1,topPos+44,w/2-1,20).build());
if(v.furnace())for(int n=0;n<3;n++){int section=n;var b=addRenderableWidget(Button.builder(Multiblocks.text(new String[]{"inputs","fuel","outputs"}[n]),button->change(0,section)).bounds(x,topPos+84+n*22,w,20).build());sections.add(b);}
if(v.furnace())for(int n=0;n<3;n++){int section=n;var b=addRenderableWidget(Button.builder(Multiblocks.text(new String[]{"inputs","fuel","outputs"}[n]),button->change(0,section)).bounds(x,topPos+44+n*22,w,20).build());sections.add(b);}
if(v.furnace())addRenderableWidget(Button.builder(Multiblocks.text("how_to_cook"),b->showHelp=!showHelp).bounds(x,topPos+142,w,20)
.tooltip(Tooltip.create(Multiblocks.text("cooking_help"))).build());
if(v.furnace()){previous.visible=false;next.visible=false;}
controls();
}
@Override public boolean mouseScrolled(double x,double y,double horizontal,double vertical){
@@ -41,14 +45,46 @@ public final class MultiblockScreen extends ContainerScreen {
@Override protected void containerTick(){super.containerTick();controls();}
@Override protected void extractLabels(net.minecraft.client.gui.GuiGraphicsExtractor g,int mouseX,int mouseY){
super.extractLabels(g,mouseX,mouseY);var m=machine();int x=imageWidth+4;
g.text(font,Multiblocks.text("page",m.view.page()+1,m.view.pages()),x,68,0xFFFFFFFF,false);
if(!m.view.furnace())g.text(font,Multiblocks.text("page",m.view.page()+1,m.view.pages()),x,68,0xFFFFFFFF,false);
if(m.view.furnace()){
g.text(font,Multiblocks.text("heat",m.heat),x,154,0xFFFFFFFF,false);
g.text(font,Multiblocks.text("progress",m.progress),x,166,0xFFFFFFFF,false);
g.text(font,Multiblocks.text("heat",m.heat),x,116,0xFFFFFFFF,false);
g.text(font,Multiblocks.text("cooking_count",m.cooking),x,128,0xFFFFFFFF,false);
}
for(int i=m.view.indices().length;i<m.displaySlots();i++){var s=menu.getSlot(i);g.fill(s.x,s.y,s.x+16,s.y+16,0xB0181818);}
}
public java.util.List<net.minecraft.client.renderer.Rect2i> extraAreas(){return search==null?java.util.List.of():java.util.List.of(new net.minecraft.client.renderer.Rect2i(search.getX()-2,search.getY()-2,search.getWidth()+4,machine().view.furnace()?182:82));}
@Override protected void extractSlot(net.minecraft.client.gui.GuiGraphicsExtractor g,net.minecraft.world.inventory.Slot slot,int x,int y){
super.extractSlot(g,slot,x,y);var m=machine();
if(!m.view.furnace()||m.view.section()!=0||slot.index>=m.view.indices().length)return;
int lane=m.view.indices()[slot.index]/3,state=m.laneState(lane);if(state==MultiblockMenu.IDLE)return;
int color=switch(state){case MultiblockMenu.COOKING->0xFFFFB23F;case MultiblockMenu.NO_FUEL->0xFFE1CE62;default->0xFFE06B5F;};
g.fill(slot.x,slot.y+14,slot.x+16,slot.y+16,0xFF343434);
g.fill(slot.x,slot.y+14,slot.x+Math.max(1,16*m.laneProgress(lane)/100),slot.y+16,color);
}
@Override protected java.util.List<Component> getTooltipFromContainerItem(net.minecraft.world.item.ItemStack stack){
var lines=new java.util.ArrayList<>(super.getTooltipFromContainerItem(stack));var m=machine();
if(m.view.furnace()&&hoveredSlot!=null&&hoveredSlot.index<m.view.indices().length){
int lane=m.view.indices()[hoveredSlot.index]/3;
if(m.view.section()==1)lines.add(Multiblocks.text("shared_fuel").copy().withStyle(net.minecraft.ChatFormatting.GRAY));
else {lines.add(Multiblocks.text("lane",lane+1).copy().withStyle(net.minecraft.ChatFormatting.GRAY));
lines.add(Multiblocks.text("state."+new String[]{"idle","cooking","no_fuel","blocked","no_recipe"}[m.laneState(lane)],m.laneProgress(lane)));}
}
return lines;
}
@Override public void extractRenderState(net.minecraft.client.gui.GuiGraphicsExtractor g,int x,int y,float partial){
super.extractRenderState(g,x,y,partial);
if(showHelp){
g.nextStratum();
g.tooltip(font,font.split(Multiblocks.text("cooking_help"),Math.min(260,width-32)).stream()
.map(net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent::create).toList(),
8,24,net.minecraft.client.gui.screens.inventory.tooltip.DefaultTooltipPositioner.INSTANCE,null,false);
}
}
@Override public boolean mouseClicked(net.minecraft.client.input.MouseButtonEvent event,boolean doubleClick){
if(showHelp){showHelp=false;return true;}
return super.mouseClicked(event,doubleClick);
}
boolean helpVisible(){return showHelp;}
public java.util.List<net.minecraft.client.renderer.Rect2i> extraAreas(){return search==null?java.util.List.of():java.util.List.of(new net.minecraft.client.renderer.Rect2i(search.getX()-2,search.getY()-2,search.getWidth()+4,machine().view.furnace()?166:82));}
public static void register(){
net.minecraft.client.gui.screens.MenuScreens.<net.minecraft.world.inventory.ChestMenu,MultiblockScreen>register(Multiblocks.MENU,(m,i,t)->new MultiblockScreen((MultiblockMenu)m,i,t));
net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback.EVENT.register((stack,context,flags,lines)->{if(stack.is(Multiblocks.WRENCH))lines.add(Multiblocks.text("hint").copy().withStyle(net.minecraft.ChatFormatting.GRAY));});
@@ -12,6 +12,9 @@ public final class MultiblockCooking {
private static final ThreadLocal<Boolean> NATIVE=ThreadLocal.withInitial(()->false);
public static boolean nativeTick(){return NATIVE.get();}
private static MultiblockFurnaceAccess access(AbstractFurnaceBlockEntity f){return (MultiblockFurnaceAccess)f;}
public static boolean hasRecipe(ServerLevel level,AbstractFurnaceBlockEntity f){
return !f.getItem(0).isEmpty()&&access(f).sanctuary$recipes().getRecipeFor(new SingleRecipeInput(f.getItem(0)),level).isPresent();
}
public static boolean canCook(ServerLevel level,AbstractFurnaceBlockEntity f){
if(f.getItem(0).isEmpty())return false;
var input=new SingleRecipeInput(f.getItem(0));var recipe=access(f).sanctuary$recipes().getRecipeFor(input,level).orElse(null);
@@ -40,17 +43,27 @@ public final class MultiblockCooking {
f.setChanged();donor.setChanged();break;
}
}
AbstractFurnaceBlockEntity fuelSource=null;ItemStack borrowed=ItemStack.EMPTY;
if(a.sanctuary$heat()<=1&&a.sanctuary$fuel(level,f.getItem(1))<=0){
for(var donor:furnaces)if(donor!=f&&a.sanctuary$fuel(level,donor.getItem(1))>0){
// Exchange physical fuel slots, retaining buckets and every component exactly once.
// Lend the stack only for this native tick, then return its unburnt contents
// to the chosen slot. Never leave whole piles wandering through the fuel tab.
fuelSource=donor;borrowed=donor.getItem(1).copy();
var old=f.getItem(1);f.setItem(1,donor.getItem(1));donor.setItem(1,old);donor.setChanged();f.setChanged();break;
}
}
// A batch can be refuelled gradually: do not erase partially paid work when heat runs out.
if(a.sanctuary$heat()<=1&&a.sanctuary$fuel(level,f.getItem(1))<=0)continue;
if(fuelSource==null&&a.sanctuary$heat()<=1&&a.sanctuary$fuel(level,f.getItem(1))<=0)continue;
NATIVE.set(true);
try{AbstractFurnaceBlockEntity.serverTick(level,f.getBlockPos(),f.getBlockState(),f);}
finally{NATIVE.remove();}
finally{
NATIVE.remove();
if(fuelSource!=null&&(f.getItem(1).isEmpty()||ItemStack.isSameItemSameComponents(f.getItem(1),borrowed))){
var remaining=f.getItem(1);f.setItem(1,fuelSource.getItem(1));fuelSource.setItem(1,remaining);fuelSource.setChanged();
}
// A native remainder (e.g. a lava bucket) stays with the cooking lane, so
// wet sponges still fill that bucket and no item is copied or discarded.
}
f.setChanged();used.add(f);
}
// Idle heat still decays. A blocked output never starts a new fuel item.
@@ -11,6 +11,8 @@ import net.minecraft.world.*;
import net.minecraft.world.entity.player.*;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.phys.Vec3;
import java.util.*;
@@ -30,26 +32,35 @@ public final class MultiblockMenu extends ChestMenu {
public final MultiblockContainer stock;
public final BlockPos anchor;
public int displaySlots(){return view.furnace?27:54;}
public int heat,progress;
public int heat,progress,cooking;
public static final int IDLE=0,COOKING=1,NO_FUEL=2,BLOCKED=3,NO_RECIPE=4;
private final int[] lanes=new int[27];
public int laneState(int lane){return lanes[lane]/101;}
public int laneProgress(int lane){return lanes[lane]%101;}
public MultiblockMenu(int id,Inventory inv,View view){this(id,inv,view,null,BlockPos.ZERO);}
public MultiblockMenu(int id,Inventory inv,View view,MultiblockContainer stock,BlockPos anchor){
super(Multiblocks.MENU,id,inv,new SimpleContainer(54),view.furnace?3:6);this.view=view;this.stock=stock;this.anchor=anchor.immutable();
for(int i=0;i<displaySlots();i++){
var old=slots.get(i);boolean enabled=i<view.indices.length;int index=enabled?view.indices[i]:-1;
var old=slots.get(i);boolean enabled=i<view.indices.length;int physicalIndex=enabled?view.indices[i]:-1;
Slot slot;
if(enabled&&stock!=null){
var cell=stock.cell(index);
slot=view.furnace&&view.section==2?new FurnaceResultSlot(inv.player,cell,stock.local(index),old.x,old.y):new Slot(stock,index,old.x,old.y){
@Override public boolean mayPlace(ItemStack s){return stock.canPlaceItem(index,s);}
var cell=stock.cell(physicalIndex);
slot=view.furnace&&view.section==2?new FurnaceResultSlot(inv.player,cell,stock.local(physicalIndex),old.x,old.y):new Slot(stock,physicalIndex,old.x,old.y){
// Slot.index is the displayed menu ID, not the physical furnace slot.
@Override public boolean mayPlace(ItemStack s){return stock.canPlaceItem(physicalIndex,s);}
};
}else slot=new Slot(getContainer(),i,old.x,old.y){
@Override public boolean mayPlace(ItemStack s){return enabled&&(!view.furnace||view.section!=2)&&super.mayPlace(s);}
@Override public boolean mayPlace(ItemStack s){return enabled&&(!view.furnace||view.section==0||view.section==1&&(s.has(DataComponents.COOKING_FUEL)||s.is(Items.BUCKET)&&!getItem().is(Items.BUCKET)))&&super.mayPlace(s);}
@Override public boolean mayPickup(Player p){return enabled;}
};
slot.index=i;slots.set(i,slot);
}
addDataSlot(new DataSlot(){public int get(){return heat;}public void set(int n){heat=n;}});
addDataSlot(new DataSlot(){public int get(){return progress;}public void set(int n){progress=n;}});
if(view.furnace){
addDataSlot(new DataSlot(){public int get(){return cooking;}public void set(int n){cooking=n;}});
for(int i=0;i<27;i++){int lane=i;addDataSlot(new DataSlot(){public int get(){return lanes[lane];}public void set(int n){lanes[lane]=n;}});}
}
}
public static View view(MultiblockContainer stock,int wanted,String query,int section){
return view(stock,wanted,query,section,new int[0]);
@@ -78,20 +89,32 @@ public final class MultiblockMenu extends ChestMenu {
}
@Override public void broadcastChanges(){
if(stock!=null&&stock.machine.furnace()&&Multiblocks.ready(stock.level,stock.machine)){
int total=0,max=0;
for(int i=0;i<81;i+=3){var a=(fr.koka.sanctuary.mixin.MultiblockFurnaceAccess)stock.cell(i);total+=a.sanctuary$heat();max=Math.max(max,a.sanctuary$progress()*100/Math.max(1,a.sanctuary$total()));}
heat=Math.min(32767,total);progress=max;
long total=0;boolean fuel=false;cooking=0;progress=0;
for(int i=0;i<81;i+=3){var a=(fr.koka.sanctuary.mixin.MultiblockFurnaceAccess)stock.cell(i);total+=a.sanctuary$heat();fuel|=a.sanctuary$fuel(stock.level,stock.getItem(i+1))>0;}
heat=(int)Math.min(32767,(total+19)/20); // Seconds of combined burning fuel, not wall-clock time.
for(int i=0;i<27;i++){
var f=(net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity)stock.cell(i*3);var a=(fr.koka.sanctuary.mixin.MultiblockFurnaceAccess)f;
int percent=Math.clamp(a.sanctuary$progress()*100/Math.max(1,a.sanctuary$total()),0,100);
int state=f.getItem(0).isEmpty()?IDLE:!MultiblockCooking.hasRecipe(stock.level,f)?NO_RECIPE:!MultiblockCooking.canCook(stock.level,f)?BLOCKED:total>1||fuel?COOKING:NO_FUEL;
lanes[i]=state*101+percent;if(state==COOKING)cooking++;progress=Math.max(progress,percent);
}
}
super.broadcastChanges();
}
@Override public boolean stillValid(Player p){return p.isAlive()&&(stock==null||p.level()==stock.level&&p.distanceToSqr(Vec3.atCenterOf(anchor))<=64&&stock.stillValid(p)&&p instanceof ServerPlayer server&&Multiblocks.accessible(server,stock.machine));}
@Override public void clicked(int slot,int button,ContainerInput input,Player p){if(stillValid(p))super.clicked(slot,button,input,p);}
@Override public ItemStack quickMoveStack(Player p,int index){
if(index<0||index>=slots.size()||!stillValid(p)||stock==null)return ItemStack.EMPTY;
if(index<0||index>=slots.size()||!stillValid(p))return ItemStack.EMPTY;
var slot=slots.get(index);if(!slot.mayPickup(p))return ItemStack.EMPTY;
var source=slot.getItem();if(source.isEmpty())return ItemStack.EMPTY;var original=source.copy();boolean moved;
if(index<displaySlots())moved=SanctuaryInventoryTransfer.move(source,slots.subList(displaySlots(),slots.size()));
else{
else if(stock==null){
// The complete furnace tab has the same destinations on both sides. Predict the
// native transfer immediately, so rapid gestures do not keep selecting stale stacks.
// Filtered views and barrel pages omit destinations: wait for the authoritative reply.
if(!view.furnace||!view.query.isEmpty())return ItemStack.EMPTY;
moved=SanctuaryInventoryTransfer.move(source,slots.subList(0,displaySlots()));
}else{
var targets=new ArrayList<Slot>();
for(int i=0;i<stock.getContainerSize();i++){
if(stock.machine.furnace()&&i%3!=view.section)continue;
@@ -1977,7 +1977,7 @@
"sanctuary.multiblock.inputs": "Ingredients",
"sanctuary.multiblock.fuel": "Fuel",
"sanctuary.multiblock.outputs": "Results",
"sanctuary.multiblock.heat": "Heat: %s",
"sanctuary.multiblock.heat": "Reserve: %s s",
"sanctuary.multiblock.progress": "Cooking: %s %%",
"sanctuary.multiblock.hint": "Right click: assemble / open. Sneak: rotate / separate.",
"sanctuary.multiblock.unavailable": "Machine inaccessible, incomplete or partly unloaded.",
@@ -2193,5 +2193,15 @@
"gamerule.sanctuary.snow_accumulation_height": "Sanctuary snow height",
"gamerule.sanctuary.snow_accumulation_height.description": "Maximum height of a snow column, in blocks. 0: no accumulation; -1: up to the world height limit. Eight layers become a solid block. The vanilla rule set to 0 still takes priority.",
"gamerule.sanctuary.seasonal_snow_melt": "Seasonal snow melt",
"gamerule.sanctuary.seasonal_snow_melt.description": "Gradually melts tracked weather deposits outside winter in Real Time weather. Preserves snow constructions."
"gamerule.sanctuary.seasonal_snow_melt.description": "Gradually melts tracked weather deposits outside winter in Real Time weather. Preserves snow constructions.",
"sanctuary.multiblock.cooking_count": "%s / 27 cooking",
"sanctuary.multiblock.how_to_cook": "How it works",
"sanctuary.multiblock.cooking_help": "Ingredients: one slot = one recipe, up to 27 at once. Fuel: shared by all slots. Results: collect cooked items in the matching ingredient positions. Each recipe uses fuel at the normal rate: 1 coal cooks about 8 items in total. The reserve shows seconds of already burning fuel, shared between recipes. Bars beneath ingredients show their progress. Shift-click fills the first available slots; mouse placement keeps your chosen slot.",
"sanctuary.multiblock.shared_fuel": "Fuel shared between all 27 recipes.",
"sanctuary.multiblock.lane": "Cooking slot %s",
"sanctuary.multiblock.state.idle": "No ingredient in this slot.",
"sanctuary.multiblock.state.cooking": "Cooking: %s %%",
"sanctuary.multiblock.state.no_fuel": "Add fuel — %s %% progress kept",
"sanctuary.multiblock.state.blocked": "Empty the matching slot in Results.",
"sanctuary.multiblock.state.no_recipe": "This item has no furnace recipe."
}
@@ -1977,7 +1977,7 @@
"sanctuary.multiblock.inputs": "Ingrédients",
"sanctuary.multiblock.fuel": "Combustibles",
"sanctuary.multiblock.outputs": "Résultats",
"sanctuary.multiblock.heat": "Chauffe : %s",
"sanctuary.multiblock.heat": "Réserve : %s s",
"sanctuary.multiblock.progress": "Cuisson : %s %%",
"sanctuary.multiblock.hint": "Clic droit : assembler / ouvrir. Maj : tourner / dissocier.",
"sanctuary.multiblock.unavailable": "Machine inaccessible, incomplète ou partiellement déchargée.",
@@ -2193,5 +2193,15 @@
"gamerule.sanctuary.snow_accumulation_height": "Hauteur de neige Sanctuary",
"gamerule.sanctuary.snow_accumulation_height.description": "Hauteur maximale de la colonne de neige, en blocs. 0 : aucun dépôt ; -1 : jusqu’à la limite du monde. Huit couches deviennent un bloc solide. La règle vanilla à 0 reste prioritaire.",
"gamerule.sanctuary.seasonal_snow_melt": "Fonte saisonnière de la neige",
"gamerule.sanctuary.seasonal_snow_melt.description": "Fait fondre progressivement les dépôts météo suivis hors hiver, en météo Real Time. Préserve les constructions en neige."
"gamerule.sanctuary.seasonal_snow_melt.description": "Fait fondre progressivement les dépôts météo suivis hors hiver, en météo Real Time. Préserve les constructions en neige.",
"sanctuary.multiblock.cooking_count": "%s / 27 en cuisson",
"sanctuary.multiblock.how_to_cook": "Fonctionnement",
"sanctuary.multiblock.cooking_help": "Ingrédients : une case = une cuisson, jusqu’à 27 en parallèle. Combustibles : réserve commune pour toutes les cases. Résultats : récupérer ici les objets cuits, aux mêmes positions que les ingrédients. Chaque cuisson consomme du combustible à vitesse normale : 1 charbon cuit environ 8 objets au total. La réserve affiche les secondes de combustible déjà allumé, partagées entre les cuissons. Les barres sous les ingrédients montrent leur progression. Maj-clic transfère vers les premières cases disponibles ; déposer à la souris conserve la case choisie.",
"sanctuary.multiblock.shared_fuel": "Combustible partagé entre les 27 cuissons.",
"sanctuary.multiblock.lane": "Cuisson n° %s",
"sanctuary.multiblock.state.idle": "Aucun ingrédient dans cette case.",
"sanctuary.multiblock.state.cooking": "En cuisson : %s %%",
"sanctuary.multiblock.state.no_fuel": "Ajouter du combustible — %s %% conservés",
"sanctuary.multiblock.state.blocked": "Vider la case correspondante dans Résultats.",
"sanctuary.multiblock.state.no_recipe": "Cet objet ne possède pas de recette au four."
}
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.114"
version = "beta.115"
pack-format = "packwiz:1.1.0"
[index]