Fix multiblock furnace slot mapping and explain shared cooking (beta.115)
Build Sanctuary / build (push) Canceled after 0s
Build Sanctuary / build (push) Canceled after 0s
This commit is contained in:
@@ -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 l’atelier.
|
||||
|
||||
## beta.114 — Animaux portés et fuite en vol
|
||||
|
||||
Un animal volant porté entraîne son porteur après un coup. La trajectoire
|
||||
|
||||
@@ -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 d’un 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
@@ -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
|
||||
|
||||
@@ -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') }
|
||||
|
||||
+103
@@ -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
@@ -1,6 +1,6 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "beta.114"
|
||||
version = "beta.115"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
Reference in New Issue
Block a user