Compare commits

...
Author SHA1 Message Date
koka 22e58b4b91 Fix multiblock furnace slot mapping and explain shared cooking (beta.115)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 07:28:40 +02:00
koka ed0e0b26aa Document beta.114 release and verified instance update
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 06:59:17 +02:00
koka 21845bd736 Add chaotic flight when carried flying animals are struck (beta.114)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 06:57:34 +02:00
koka 4a6f63c52d Document beta.113 publication and verified Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 06:39:24 +02:00
24 changed files with 625 additions and 42 deletions
+22 -5
View File
@@ -20,10 +20,11 @@ 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.113** nomme les miniatures « sculptures dargile » 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 laccroche, sa sauvegarde
et les vérifications. Latelier conserve son menu limité à la hotbar.
La version **beta.114** ajoute une fuite chaotique aux animaux volants portés :
un coup les fait décoller avec leur porteur, dans une direction aléatoire,
pendant deux à trois secondes. Le [ticket](docs/carried-flight-panic-beta114.md)
décrit le comportement et ses vérifications. La mise à jour est installée
dans **Sanctuary Beta**.
La [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà
latelier, les œufs et la neige saisonnière dans linstance **Sanctuary Beta**.
Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées.
@@ -34,11 +35,27 @@ 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
aléatoire prend brièvement le dessus sur les commandes ; déposer lanimal
interrompt le vol. [Contrat et vérifications](docs/carried-flight-panic-beta114.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.114/Sanctuary-beta.114.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.114/Sanctuary-Test-beta.114.mrpack).
## beta.113 — Sculptures dargile 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. Laccroche est
conservée à la reconnexion. [Contrat et vérifications](docs/clay-sculpture-grid-beta113.md).
conservée à la reconnexion. [Contrat et vérifications](docs/clay-sculpture-grid-beta113.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.113/Sanctuary-beta.113.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.113/Sanctuary-Test-beta.113.mrpack).
## beta.112 — Atelier dargile et hotbar
+91
View File
@@ -0,0 +1,91 @@
# CARRY-08 — Fuite des animaux volants portés — beta.114
Contrat du 17 septembre 2026, branche `codex/carried-flight-panic-beta114`.
Un coup reçu par un animal volant porté sur la tête déclenche une fuite de
40 à 60 ticks : il entraîne son porteur vers le haut, dans une direction
aléatoire, avec des embardées. Le serveur choisit le départ et la trajectoire,
transmis au client pour la physique native. Les touches de déplacement et le
regard ne pilotent pas cette brève fuite. La caméra reste libre. Un nouveau coup peut relancer la fuite avec un autre
cap après dix ticks, sans cumuler les vitesses.
Le porteur peut viser et frapper son animal volant. Les animaux ordinaires
reçoivent les dégâts habituels ; le familier conserve son coup amical sans
perte de santé. Les attaques extérieures acceptées déclenchent aussi la fuite.
Les autres protections entre passagers et porteurs restent en place.
Les espèces reprennent le groupe déjà utilisé pour planer, poule comprise.
La vitesse reste bornée, les collisions sont natives et aucun bloc nest
traversé ni déplacé. Déposer lanimal (Maj + clic droit), le perdre, mourir,
se déconnecter ou changer de dimension arrête la propulsion. Immersion,
mode spectateur, vol créatif, élytres et porteur lui-même passager linhibent.
La fin de fuite rend le contrôle au joueur et conserve le planeur existant
si lanimal reste porté. Les montures colossales gardent leur pilotage actuel.
L’état de fuite est temporaire, synchronisé et non sauvegardé. Aucun format
persistant, identifiant existant ou monde personnel nest modifié. Les nouveaux
retours dinterface sont traduits FR/EN. Client et serveur utilisent beta.114.
## Vérifications natives
`CarryPanic114ClientChecks` passe en **56 secondes** avec un serveur intégré
Minecraft 26.3 et un monde plat jetable de graine 114 :
- Véritable ciblage du perroquet sur la tête et frappe avec la touche native,
perte de santé de lanimal ordinaire, décollage et déplacement du joueur.
- Trajectoire synchronisée et priorité sur les touches avant/droite/saut et
le regard ; comparaison exacte avec la vitesse du tick de physique.
- Expiration, suppression de l’état temporaire et retour au planeur normal.
- 32 trajectoires tirées côté serveur couvrent les quatre quadrants, toutes
dans les bornes de durée et de vitesse ; Maj + clic droit natif interrompt.
- Sept espèces ordinaires : poule, chauve-souris, abeille, allay, perroquet,
blaze et breeze. Une vache portée reste protégée et ne déclenche aucun vol.
- Attaque extérieure, mort, activation du vol créatif et détachement arrêtent
ou déclenchent la réaction selon le contrat.
- Plafond bas : aucune traversée ; le passager bloqué est déposé par les
contrôles de place existants et la propulsion sinterrompt.
- Véritable coup amical sur un familier perroquet : lancement sans perte de
santé, puis arrêt immédiat lors du détachement.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryCarryPanic114ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Marqueurs `CARRY114_NATIVE_FLIGHT_PASS` et `CARRY114_PASS` dans
`build/carry-panic114-client.log`. Capture en jeu relue dans
`build/carry-panic114-evidence/`. Le GameTest dédié reste exclu conformément
au refus antérieur de son EULA. Pas de test Windows, de réseau distant ou de
latence simulée ; aucun monde personnel na été ouvert.
## Livraison
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 22 s**, 125 tâches. Les sources JAR correspondent exactement
aux sources du dépôt. Les classes et ressources hors de ce ticket restent
identiques à beta.113, notamment les sculptures dargile. Les deux packs
contiennent le même JAR, sans monde, GLB ni classes de test.
- Sanctuary-beta.114.mrpack : 10179428 octets, SHA-256
`92aac61c2c00a2206f3f5ac3d170ab058f6765526cf5a2a64c5336261db3f5bf`.
- Sanctuary-Test-beta.114.mrpack : 10198350 octets, SHA-256
`5e8ec1f18e34ca51e09e2738f2b04f9a1bbac5d14c7a7c2d766218a4cff6021f`.
- JAR Sanctuary : SHA-256
`18105b76f63008210c62d5728f999c7abc083a861ecc1de2c7ee87262dde5a40`.
Reçu local : `build/carry-panic114-artifact.json`.
## Publication et installation
La [release beta.114](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.114)
est publiée depuis `21845bd7367a260ee6a3d189417a8620aa5bf738`. Le tag exact
et les artefacts sont immuables ; les téléchargements publics sont vérifiés.
Le canal packwiz avance à `c26f3bb59491c9bb4bbdd27f59571ef84f417f7b`.
Deux synchronisations isolées puis deux passages dans **Sanctuary Beta** ont
réussi. Un seul JAR Sanctuary beta.114 est actif, et le second passage conserve
les mêmes hashes. Les **923 fichiers personnels et réglages suivis** restent
identiques. Aucun monde personnel na été ouvert. La sauvegarde ciblée est
`sanctuary-backups/before-beta.114/` dans linstance existante. Reçus locaux :
`build/carry-panic114-isolated.json` et `build/carry-panic114-prism.json`.
+13 -1
View File
@@ -75,4 +75,16 @@ aux sources du dépôt. Les classes et ressources hors du ticket sont identiques
Reçu local : `build/clay-grid113-artifact.json`.
Publication et synchronisation de linstance à effectuer après le commit source.
## Publication et installation
La [release beta.113](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.113)
est publiée depuis `6166d0aa9eba3c3dd84b8b9f1501e3346231ac82`. Le tag exact
et les artefacts sont immuables ; les téléchargements publics sont vérifiés.
Le canal packwiz avance à `e4c16d517bfda4f2b688604cc61945a46a4513a9`.
Deux synchronisations isolées puis deux passages dans **Sanctuary Beta** ont
réussi. Un seul JAR Sanctuary beta.113 est actif, et le second passage conserve
les mêmes hashes. Les **923 fichiers personnels et réglages suivis** restent
identiques. Aucun monde personnel na été ouvert. La sauvegarde ciblée est
`sanctuary-backups/before-beta.113/` dans linstance existante. Reçus locaux :
`build/clay-grid113-isolated.json` et `build/clay-grid113-prism.json`.
+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.113
pack_version=beta.113
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
+7
View File
@@ -93,6 +93,13 @@ 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') }
def statuary106Client = providers.gradleProperty('sanctuaryStatuary106ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryStatuary106ClientTests', statuary106Client)
if (statuary106Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Statuary106ClientChecks') }
@@ -0,0 +1,115 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.inventory.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.CameraType;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.CustomData;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
/** Real cursor/attack packets, synchronized player travel, interruption and collision regression. */
public final class CarryPanic114ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("CarryPanic114 "+UUID.randomUUID());s.setSeed("114");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=player(s);p.getInventory().clearContent();p.getAbilities().invulnerable=true;p.onUpdateAbilities();reset(p);
for(int x=-24;x<=24;x++)for(int z=-24;z<=24;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");});
int parrot=server.computeOnServer(s->carry(player(s),EntityTypes.PARROT).getId());
c.waitFor(m->m.level.getEntity(parrot)!=null&&CarryService.carried(m.level.getEntity(parrot)),150);
float health=server.computeOnServer(s->((LivingEntity)player(s).level().getEntity(parrot)).getHealth());
attack(c,parrot);c.waitFor(m->CarryPanic.active(m.player),100);
server.runOnServer(s->{var p=player(s);check(CarryPanic.active(p),"Real native attack triggers server panic");check(((LivingEntity)p.level().getEntity(parrot)).getHealth()<health,"Ordinary carried animal takes native damage");});
c.getInput().holdKey(o->o.keyUp);c.getInput().holdKey(o->o.keyRight);c.getInput().holdKey(o->o.keyJump);
c.runOnClient(m->{m.player.setYRot(170);m.player.setXRot(15);});c.waitTicks(12);
c.runOnClient(m->{check(m.player.getY()>2.5&&m.player.position().subtract(new Vec3(.5,1,.5)).horizontalDistance()>1,"Actual client moves upward and sideways");
check(CarryPanic.active(m.player),"Panic remains active through steering keys");
var wanted=m.player.getAttached(CarryPanic.FLIGHT).velocity(m.level.getGameTime());
// Level time can advance immediately after the player physics tick. Match either exact tick.
var previous=m.player.getAttached(CarryPanic.FLIGHT).velocity(m.level.getGameTime()-1);
check(Math.min(m.player.getDeltaMovement().distanceTo(wanted),m.player.getDeltaMovement().distanceTo(previous))<.00001,"Movement follows animal, not camera or held keys: actual="+m.player.getDeltaMovement()+", expected="+wanted+", flight="+m.player.getAttached(CarryPanic.FLIGHT)+", time="+m.level.getGameTime());check(m.player.fallDistance<.2,"Flight clears falling distance");
m.options.setCameraType(CameraType.THIRD_PERSON_BACK);});c.waitTicks(2);c.takeScreenshot("carry114-chaotic-flight");
c.getInput().releaseKey(o->o.keyUp);c.getInput().releaseKey(o->o.keyRight);c.getInput().releaseKey(o->o.keyJump);
c.waitTicks(65);c.waitFor(m->!CarryPanic.active(m.player),100);
server.runOnServer(s->{var p=player(s);check(!p.hasAttached(CarryPanic.FLIGHT)&&CarryGlide.supported(p),"Timeout removes panic and retains ordinary glider");check(p.connection.isAcceptingMessages(),"Flight is accepted by server");});
c.runOnClient(m->{check(m.player.getDeltaMovement().y<=0&&m.player.getDeltaMovement().y>=-CarryGlide.DESCENT-.02,"Normal gentle descent resumes");m.options.setCameraType(CameraType.FIRST_PERSON);});
System.out.println("CARRY114_NATIVE_FLIGHT_PASS cursor, real attack, damage, movement, input override, timeout, glide");
server.runOnServer(s->{var p=player(s);p.getRandom().setSeed(114);var bird=p.level().getEntity(parrot);var quadrants=new HashSet<Integer>();
for(int i=0;i<32;i++){
CarryPanic.clear(p);CarryPanic.start(bird);var flight=p.getAttached(CarryPanic.FLIGHT);check(flight.duration()>=40&&flight.duration()<=60,"Duration bounded at 23 seconds");quadrants.add((int)(flight.yaw()/(Math.PI/2)));
for(int t=0;t<=flight.duration();t++){var v=flight.velocity(flight.start()+t);check(v.horizontalDistance()<=.331&&v.y>=0&&v.y<=.241,"Velocity bounded without cumulative impulses");}
}
check(quadrants.size()==4,"Server random headings cover every quadrant");
});c.waitFor(m->CarryPanic.active(m.player),100);
c.getInput().holdKey(o->o.keyShift);c.waitTicks(2);c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);c.getInput().releaseKey(o->o.keyShift);
c.waitFor(m->!CarryService.carrying(m.player)&&!CarryPanic.active(m.player),100);
server.runOnServer(s->{check(!player(s).hasAttached(CarryPanic.FLIGHT),"Native drop clears flight immediately");player(s).level().getEntity(parrot).discard();reset(player(s));});
int cow=server.computeOnServer(s->carry(player(s),EntityTypes.COW).getId());
server.runOnServer(s->{var p=player(s);var animal=(LivingEntity)p.level().getEntity(cow);float before=animal.getHealth();p.attack(animal);
check(CarryService.blocksAttack(p,animal)&&animal.getHealth()==before&&!animal.hurtServer(p.level(),p.damageSources().playerAttack(p),1)&&!CarryPanic.active(p),"Terrestrial carry pair stays protected");CarryPanic.start(animal);check(!CarryPanic.active(p),"Ground animal cannot launch player");CarryService.release(animal,true);animal.discard();reset(p);});
// Every supported appearance can trigger the shared flight, including the gliding chicken.
server.runOnServer(s->{var p=player(s);for(var type:List.of(EntityTypes.CHICKEN,EntityTypes.BAT,EntityTypes.BEE,EntityTypes.ALLAY,EntityTypes.PARROT,EntityTypes.BLAZE,EntityTypes.BREEZE)){
var animal=carry(p,type);check(CarryGlide.flyer(animal)&&CarryPanic.canStrike(p,animal),"Carried flying species can be struck: "+type);
check(animal.hurtServer(p.level(),p.damageSources().playerAttack(p),1)&&CarryPanic.active(p),"Accepted damage launches carrier: "+type);CarryService.release(animal,true);animal.discard();check(!p.hasAttached(CarryPanic.FLIGHT),"Release clears each flight");
}});
int external=server.computeOnServer(s->carry(player(s),EntityTypes.PARROT).getId());
server.runOnServer(s->{var p=player(s);var bird=(LivingEntity)p.level().getEntity(external);var zombie=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);
check(bird.hurtServer(p.level(),p.damageSources().mobAttack(zombie),1)&&CarryPanic.active(p),"External accepted hit also triggers flight");
p.getAbilities().flying=true;CarryGlide.tick(p);check(!p.hasAttached(CarryPanic.FLIGHT),"Creative flying invalidates propulsion");p.getAbilities().flying=false;
CarryPanic.start(bird);bird.hurtServer(p.level(),p.damageSources().mobAttack(zombie),100);CarryGlide.tick(p);check(!CarryPanic.active(p),"Death stops propulsion");CarryService.detach(p);bird.discard();reset(p);});
int ceiling=server.computeOnServer(s->{var p=player(s);var bird=carry(p,EntityTypes.PARROT);for(int x=-3;x<=3;x++)for(int z=-3;z<=3;z++)p.level().setBlockAndUpdate(new BlockPos(x,4,z),Blocks.STONE.defaultBlockState());return bird.getId();});
c.waitFor(m->m.level.getEntity(ceiling)!=null&&CarryService.carried(m.level.getEntity(ceiling)),100);
server.runOnServer(s->{var p=player(s);((LivingEntity)p.level().getEntity(ceiling)).hurtServer(p.level(),p.damageSources().playerAttack(p),1);});c.waitTicks(12);
server.runOnServer(s->{var p=player(s);check(p.getY()<2&&p.level().noBlockCollision(p,p.getBoundingBox().deflate(.02)),"Player cannot fly through low ceiling");check(!CarryService.carrying(p)&&!CarryPanic.active(p),"Blocked carried animal is safely released");
p.level().getEntity(ceiling).discard();for(int x=-3;x<=3;x++)for(int z=-3;z<=3;z++)p.level().removeBlock(new BlockPos(x,4,z),false);reset(p);
var egg=new ItemStack(Items.PARROT_SPAWN_EGG);var size=new CompoundTag();size.putInt("schema",1);size.putInt("per_mille",1000);CustomData.update(DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.inventoryMenu.broadcastChanges();});
c.waitTicks(25);
int familiar=server.computeOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet!=null,"Familiar spawned");pet.setNoAi(true);pet.setPos(p.position().add(0,0,2));check(CarryService.interact(p,pet),"Flying familiar picked up");return pet.getId();});
c.waitFor(m->m.level.getEntity(familiar)!=null&&CarryService.carried(m.level.getEntity(familiar)),100);
float petHealth=server.computeOnServer(s->FamiliarService.active(player(s)).getHealth());attack(c,familiar);c.waitFor(m->CarryPanic.active(m.player),100);
server.runOnServer(s->{var p=player(s);check(FamiliarService.active(p).getHealth()==petHealth&&CarryPanic.active(p),"Friendly familiar tap launches without hurting it");CarryService.detach(p);check(!p.hasAttached(CarryPanic.FLIGHT),"Lifecycle detach clears synchronized flight");});
System.out.println("CARRY114_PASS random flight, native ordinary/familiar strikes, seven species, outside hits, ground protection, drop/death/creative cancellation, ceiling collisions, timeout and normal glide");
}finally{intro.set(false);c.getInput().releaseKey(o->o.keyUp);c.getInput().releaseKey(o->o.keyRight);c.getInput().releaseKey(o->o.keyJump);c.getInput().releaseKey(o->o.keyShift);c.getInput().releaseKey(o->o.keyAttack);c.getInput().releaseKey(o->o.keyUse);}
}
private static void attack(ClientGameTestContext c,int id){
c.waitFor(m->m.level.getEntity(id).position().distanceTo(CarryService.head(m.player,m.level.getEntity(id)))<.01,100);
c.runOnClient(m->{m.options.setCameraType(CameraType.FIRST_PERSON);m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,m.level.getEntity(id).getBoundingBox().getCenter());});c.waitTicks(3);
c.runOnClient(m->check(m.hitResult instanceof EntityHitResult hit&&hit.getEntity().getId()==id,"Native cursor can pick own carried flyer: hit="+m.hitResult+", target="+m.level.getEntity(id).getBoundingBox()+", eye="+m.player.getEyePosition()+", view="+m.player.getLookAngle()+", eligible="+CarryPanic.canStrike(m.player,m.level.getEntity(id))));
c.getInput().holdKey(o->o.keyAttack);c.waitTicks(1);c.getInput().releaseKey(o->o.keyAttack);
}
private static Mob carry(ServerPlayer p,EntityType<? extends Mob> type){var mob=type.create(p.level(),EntitySpawnReason.COMMAND);mob.setNoAi(true);mob.setPos(p.position().add(0,0,2));p.level().addFreshEntity(mob);check(CarryService.interact(p,mob),"Animal picked up: "+type);return mob;}
private static void reset(ServerPlayer p){CarryService.detach(p);p.teleportTo(.5,1,.5);p.setDeltaMovement(Vec3.ZERO);p.resetFallDistance();p.setShiftKeyDown(false);}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
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);}
}
@@ -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));});
@@ -31,6 +31,7 @@ public final class CarryGlide {
}
public static boolean active(Player p){return supported(p)&&!p.onGround()&&(p.getDeltaMovement().y<=0||lifting(p));}
public static void tick(Player p){
if(CarryPanic.apply(p))return;
boolean lift=lifting(p);
if(!lift&&!active(p))return;
var velocity=p.getDeltaMovement();
@@ -0,0 +1,62 @@
package fr.koka.sanctuary.cosmetics;
import fr.koka.sanctuary.SanctuaryMod;
import net.fabricmc.fabric.api.attachment.v1.*;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
/** Server-chosen, temporary flight; both sides use the same bounded native movement. */
public final class CarryPanic {
public record Flight(int animal,long start,int duration,float yaw,float phase){
static final StreamCodec<RegistryFriendlyByteBuf,Flight> CODEC=StreamCodec.of(
(b,f)->{b.writeVarInt(f.animal);b.writeLong(f.start);b.writeVarInt(f.duration);b.writeFloat(f.yaw);b.writeFloat(f.phase);},
b->new Flight(b.readVarInt(),b.readLong(),b.readVarInt(),b.readFloat(),b.readFloat()));
public Vec3 velocity(long now){
double t=Math.clamp(now-start,0,duration),angle=yaw+Math.sin(t*.27+phase)*.65+Math.sin(t*.61)*.18;
double speed=.28+.05*Math.sin(t*.23+phase);
double rise=t<5?.24:.12+.045*Math.sin(t*.32+phase);
double fade=Math.min(1,(duration-t)/8.0);
return new Vec3(-Math.sin(angle)*speed*fade,rise*fade,Math.cos(angle)*speed*fade);
}
}
public static final AttachmentType<Flight> FLIGHT=AttachmentRegistry.create(SanctuaryMod.id("carry_panic"),
b->b.syncWith(Flight.CODEC,AttachmentSyncPredicate.all()));
private CarryPanic(){}
public static void register(){
ServerLivingEntityEvents.AFTER_DAMAGE.register((entity,source,base,taken,blocked)->{
if(taken>0&&!blocked)start(entity);
});
}
public static boolean canStrike(Entity actor,Entity target){
return actor instanceof Player p&&!p.isSpectator()&&p.isAlive()&&target instanceof Mob
&&target.getVehicle()==p&&CarryService.carried(target)&&CarryGlide.flyer(target);
}
public static void start(Entity animal){
if(!(animal.getVehicle() instanceof ServerPlayer p)||!CarryService.carried(animal)||!CarryGlide.flyer(animal)||!CarryGlide.supported(p))return;
long now=p.level().getGameTime();var previous=p.getAttached(FLIGHT);
if(previous!=null&&now-previous.start()<10)return;
var random=p.getRandom();
var flight=new Flight(animal.getId(),now,40+random.nextInt(21),random.nextFloat()*(float)(Math.PI*2),random.nextFloat()*(float)(Math.PI*2));
p.setAttached(FLIGHT,flight);apply(p);p.syncVelocity=true;
p.sendOverlayMessage(Component.translatable("sanctuary.carry.panic"));
}
public static boolean active(Player p){
var flight=p.getAttached(FLIGHT);if(flight==null||!CarryGlide.supported(p))return false;
long elapsed=p.level().getGameTime()-flight.start();
return elapsed>=0&&elapsed<flight.duration()&&p.getPassengers().stream().anyMatch(e->e.getId()==flight.animal()&&CarryService.carried(e)&&CarryGlide.flyer(e));
}
public static boolean apply(Player p){
if(!active(p)){
if(!p.level().isClientSide()&&p.hasAttached(FLIGHT))p.removeAttached(FLIGHT);
return false;
}
p.setDeltaMovement(p.getAttached(FLIGHT).velocity(p.level().getGameTime()));p.resetFallDistance();return true;
}
public static void clear(Player p){if(p.hasAttached(FLIGHT))p.removeAttached(FLIGHT);}
}
@@ -46,6 +46,7 @@ public final class CarryService {
&&!fr.koka.sanctuary.boats.BoatPassengerInteractions.aboardTogether(a,b)
&&!fr.koka.sanctuary.boats.BoatPassengerInteractions.aboardTogether(b,a));}
private static Entity root(Entity e){while(carried(e))e=e.getVehicle();return e;}
public static boolean blocksAttack(Entity attacker,Entity target){return pair(attacker,target)&&!CarryPanic.canStrike(attacker,target);}
/** One seat per head; the chain includes babies even though they add no carrying penalty. */
private static Entity passenger(Entity e){
return e instanceof Player&&e.getPassengers().size()==1&&carried(e.getFirstPassenger())?e.getFirstPassenger():null;
@@ -60,6 +61,7 @@ public final class CarryService {
};
}
public static void register(){
CarryPanic.register();
net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry.serverboundPlay().register(Drop.TYPE,Drop.CODEC);
net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking.registerGlobalReceiver(Drop.TYPE,(request,context)->context.server().execute(()->{
if(active(context.player()))for(var passenger:List.copyOf(context.player().getPassengers()))if(carried(passenger))release(passenger,true);
@@ -72,7 +74,7 @@ public final class CarryService {
if(!success)player.sendOverlayMessage(Component.translatable("sanctuary.carry.unavailable"));
return InteractionResult.SUCCESS;
});
AttackEntityCallback.EVENT.register((p,level,hand,target,hit)->pair(p,target)?InteractionResult.FAIL:InteractionResult.PASS);
AttackEntityCallback.EVENT.register((p,level,hand,target,hit)->blocksAttack(p,target)?InteractionResult.FAIL:InteractionResult.PASS);
ServerTickEvents.END_SERVER_TICK.register(s->{
for(var rider:new ArrayList<>(RIDES.keySet())){
var ride=RIDES.get(rider);if(ride==null||ride.carrier.level().getServer()!=s)continue;
@@ -187,7 +189,7 @@ public final class CarryService {
public static boolean mayDismount(Player p){var ride=RIDES.get(p);return ride==null||ride.releasedShift;}
public static void removed(Entity rider){
var ride=RIDES.remove(rider);rider.removeAttached(CARRIED);
if(ride!=null){refreshBelow(ride.carrier);ride.carrier.connection.send(new net.minecraft.network.protocol.game.ClientboundSetPassengersPacket(ride.carrier));}
if(ride!=null){CarryPanic.clear(ride.carrier);refreshBelow(ride.carrier);ride.carrier.connection.send(new net.minecraft.network.protocol.game.ClientboundSetPassengersPacket(ride.carrier));}
}
public static boolean release(Entity rider,boolean place){
var ride=RIDES.get(rider);if(ride==null){rider.removeAttached(CARRIED);return false;}
@@ -209,6 +211,7 @@ public final class CarryService {
return true;
}
public static void detach(ServerPlayer p){
CarryPanic.clear(p);
// Put down the upper group before removing its support, without orphaning its riders.
for(var e:List.copyOf(p.getPassengers()))if(carried(e))release(e,true);
if(RIDES.containsKey(p))release(p,true);
@@ -245,7 +245,7 @@ public final class FamiliarEntity extends PathfinderMob {
return super.causeFallDamage(distance,multiplier,source);
}
@Override public boolean hurtServer(ServerLevel level,DamageSource source,float amount) {
boolean hurt=fr.koka.sanctuary.familiar.FamiliarBattle.hurt(this,source,amount);if(hurt)playTapSound(source);return hurt;
boolean hurt=fr.koka.sanctuary.familiar.FamiliarBattle.hurt(this,source,amount);if(hurt){playTapSound(source);CarryPanic.start(this);}return hurt;
}
@Override public boolean isIgnoringBlockTriggers() {return true;}
@Override public boolean canBeHitByProjectile() {return true;}
@@ -316,11 +316,12 @@ public final class FamiliarEntity extends PathfinderMob {
}
/** Native feedback without damage callbacks, enchantments, aggro or durability. */
public void tap(ServerPlayer player) {
if(hurtTime>0||!isAlive()||player.level()!=level()||player.isSpectator()||CarryService.pair(player,this))return;
if(hurtTime>0||!isAlive()||player.level()!=level()||player.isSpectator()||CarryService.blocksAttack(player,this))return;
var source=damageSources().playerAttack(player);
if(!HeadEquipment.get(this).isEmpty()){MobHeadActions.activate(this,source,true);}
animateHurt(player.getYRot());
((ServerLevel)level()).broadcastDamageEvent(this,source);
if(CarryPanic.canStrike(player,this)){CarryPanic.start(this);playTapSound(source);return;}
double dx=player.getX()-getX(),dz=player.getZ()-getZ();
if(dx*dx+dz*dz<1.0E-5){var look=player.getLookAngle();dx=-look.x;dz=-look.z;}
knockback(.4,dx,dz,source,0);
@@ -29,7 +29,7 @@ public final class FamiliarService {
net.fabricmc.fabric.api.event.player.AttackEntityCallback.EVENT.register((player,level,hand,target,hit)->{
if(!(target instanceof FamiliarEntity pet)||player.isSpectator())return net.minecraft.world.InteractionResult.PASS;
if(pet.owner()!=null&&fr.koka.sanctuary.familiar.FamiliarDuels.fighting(pet.owner()))return net.minecraft.world.InteractionResult.FAIL;
if(CarryService.pair(player,pet))return net.minecraft.world.InteractionResult.FAIL;
if(CarryService.blocksAttack(player,pet))return net.minecraft.world.InteractionResult.FAIL;
if(player instanceof ServerPlayer attacker&&fr.koka.sanctuary.familiar.FamiliarBattle.enemy(attacker,pet))return net.minecraft.world.InteractionResult.PASS;
if(player instanceof ServerPlayer p&&p.isWithinEntityInteractionRange(pet,0)&&p.hasLineOfSight(pet))pet.tap(p);
return net.minecraft.world.InteractionResult.SUCCESS;
@@ -7,13 +7,13 @@ import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Sustained lift is legitimate only while the server sees the equipped, carried large dragon. */
/** Flight exemption is limited to server-validated dragon lift or a carried flyer's brief panic. */
@Mixin(ServerGamePacketListenerImpl.class)
public abstract class CarryFlightConnectionMixin {
@Shadow public ServerPlayer player;
@Shadow private boolean clientIsFloating;
@Inject(method="tick",at=@At("HEAD"))
private void sanctuary$carriedFlight(CallbackInfo ci){
if(CarryGlide.lifting(player))clientIsFloating=false;
if(CarryGlide.lifting(player)||fr.koka.sanctuary.cosmetics.CarryPanic.active(player))clientIsFloating=false;
}
}
@@ -8,6 +8,8 @@ import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.*;
@Mixin(LivingEntity.class)
public abstract class CarryGlideMixin {
@ModifyVariable(method="travelInAir",at=@At("HEAD"),argsOnly=true)
private Vec3 sanctuary$panicSteering(Vec3 input){return (Object)this instanceof Player p&&fr.koka.sanctuary.cosmetics.CarryPanic.active(p)?Vec3.ZERO:input;}
@Inject(method="travelInAir",at={@At("HEAD"),@At("TAIL")})
private void sanctuary$glide(Vec3 input,CallbackInfo ci){if((Object)this instanceof Player p)CarryGlide.tick(p);}
@Inject(method="getEffectiveGravity",at=@At("RETURN"),cancellable=true)
@@ -16,6 +16,6 @@ public abstract class CarryLivingMixin {
}
@Inject(method="hurtServer",at=@At("HEAD"),cancellable=true)
private void sanctuary$protectPair(ServerLevel level,DamageSource source,float damage,CallbackInfoReturnable<Boolean> ci){
if(CarryService.pair((Entity)(Object)this,source.getEntity()))ci.setReturnValue(false);
if(CarryService.blocksAttack(source.getEntity(),(Entity)(Object)this))ci.setReturnValue(false);
}
}
@@ -16,5 +16,5 @@ public abstract class CarryPlayerMixin {
if(p.getVehicle() instanceof fr.koka.sanctuary.cosmetics.FamiliarEntity pet&&!pet.mayDismount())ci.setReturnValue(false);
}
@Inject(method="attack",at=@At("HEAD"),cancellable=true)
private void sanctuary$noPassengerAttack(Entity target,CallbackInfo ci){if(CarryService.pair((Entity)(Object)this,target))ci.cancel();}
private void sanctuary$noPassengerAttack(Entity target,CallbackInfo ci){if(CarryService.blocksAttack((Entity)(Object)this,target))ci.cancel();}
}
@@ -15,7 +15,8 @@ public abstract class BoatPassengerPickMixin {
at=@At(value="INVOKE",target="Lnet/minecraft/world/entity/Entity;getRootVehicle()Lnet/minecraft/world/entity/Entity;"))
private static Entity sanctuary$pickSeatedMob(Entity candidate, Operation<Entity> original, Entity source) {
// Only the player's cursor: projectile collisions and native range/occlusion stay unchanged.
return source instanceof LocalPlayer && BoatPassengerInteractions.aboardTogether(source,candidate)
return source instanceof LocalPlayer && (BoatPassengerInteractions.aboardTogether(source,candidate)
||fr.koka.sanctuary.cosmetics.CarryPanic.canStrike(source,candidate))
? candidate : original.call(candidate);
}
}
@@ -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;
@@ -1013,6 +1013,7 @@
"sanctuary.companion.rename_hint": "Rename its egg at an anvil, or use a name tag on your familiar.",
"sanctuary.companion.interactions_version_required": "Sanctuary beta.020 is required on both client and server.",
"sanctuary.carry.unavailable": "No free space to carry or put down here.",
"sanctuary.carry.panic": "The animal panics and carries you away!",
"sanctuary.companion.tooltip_size": "Size: %s",
"sanctuary.companion.size.tiny": "Tiny",
"sanctuary.companion.size.small": "Small",
@@ -1976,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.",
@@ -2192,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."
}
@@ -1013,6 +1013,7 @@
"sanctuary.companion.rename_hint": "Renomme son œuf à lenclume, ou utilise une étiquette sur lui.",
"sanctuary.companion.interactions_version_required": "Sanctuary beta.020 est requis côté client et serveur.",
"sanctuary.carry.unavailable": "Pas de place libre pour porter ou déposer ici.",
"sanctuary.carry.panic": "Lanimal saffole et vous entraîne !",
"sanctuary.companion.tooltip_size": "Taille : %s",
"sanctuary.companion.size.tiny": "Minuscule",
"sanctuary.companion.size.small": "Petit",
@@ -1976,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.",
@@ -2192,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.113"
version = "beta.115"
pack-format = "packwiz:1.1.0"
[index]