Compare commits

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