Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3ca9fcd63 | ||
|
|
22e58b4b91 | ||
|
|
ed0e0b26aa | ||
|
|
21845bd736 | ||
|
|
4a6f63c52d |
@@ -20,10 +20,12 @@ 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 d’argile » et les
|
||||
cale contre le bord visé du bloc, sur la grille exacte de 1/16 de bloc.
|
||||
Le [ticket](docs/clay-sculpture-grid-beta113.md) décrit l’accroche, sa sauvegarde
|
||||
et les vérifications. L’atelier conserve son menu limité à la hotbar.
|
||||
La version **beta.116** ouvre le Clay Workshop aux blocs de matière : les
|
||||
nuances de leur texture sont appliquées au modèle, avec aperçu et fabrication
|
||||
payée. Elle réunit aussi le correctif du Fourneau (cases décalées par trois,
|
||||
combustible stable et explication de la chauffe).
|
||||
Le [ticket de livraison](docs/sculpture-materials-beta116.md) conserve les
|
||||
contrôles et le suivi de distribution.
|
||||
La [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà
|
||||
l’atelier, les œufs et la neige saisonnière dans l’instance **Sanctuary Beta**.
|
||||
Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées.
|
||||
@@ -34,11 +36,36 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
|
||||
l’étude de Xaero et des alternatives libres.
|
||||
|
||||
|
||||
## beta.116 — Matières de sculpture et Fourneau
|
||||
|
||||
Un bloc de matière devient une sculpture aux couleurs de sa texture. Hotbar,
|
||||
orientation et ancrage conservés. Le Fourneau accepte les dépôts dans chacune
|
||||
de ses 27 cases et explique sa chauffe commune.
|
||||
[Contrat et vérifications](docs/sculpture-materials-beta116.md) ·
|
||||
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.116/Sanctuary-beta.116.mrpack) ·
|
||||
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.116/Sanctuary-Test-beta.116.mrpack).
|
||||
|
||||
## 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).
|
||||
Étape locale intégrée à la livraison commune beta.116.
|
||||
|
||||
## 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 l’animal
|
||||
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 d’argile et alignement
|
||||
|
||||
Pose contre le bord visé, orientation selon le regard et coordonnées entières
|
||||
pour chaque voxel, y compris les modèles de largeur impaire. L’accroche est
|
||||
conservée à la reconnexion. [Contrat et vérifications](docs/clay-sculpture-grid-beta113.md).
|
||||
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 d’argile et hotbar
|
||||
|
||||
|
||||
@@ -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 n’est
|
||||
traversé ni déplacé. Déposer l’animal (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 l’inhibent.
|
||||
La fin de fuite rend le contrôle au joueur et conserve le planeur existant
|
||||
si l’animal 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 n’est modifié. Les nouveaux
|
||||
retours d’interface 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 l’animal 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 s’interrompt.
|
||||
- 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 n’a é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 d’argile. 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 n’a été ouvert. La sauvegarde ciblée est
|
||||
`sanctuary-backups/before-beta.114/` dans l’instance existante. Reçus locaux :
|
||||
`build/carry-panic114-isolated.json` et `build/carry-panic114-prism.json`.
|
||||
@@ -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 l’instance à 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 n’a été ouvert. La sauvegarde ciblée est
|
||||
`sanctuary-backups/before-beta.113/` dans l’instance existante. Reçus locaux :
|
||||
`build/clay-grid113-isolated.json` et `build/clay-grid113-prism.json`.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# beta.115 — Dépôts et chauffe du Fourneau
|
||||
|
||||
Ticket sur `codex/fourneau-transfers-beta115`, après le signalement de piles
|
||||
qui se réalignent pendant les dépôts rapides avec Maj, dans Ingrédients et
|
||||
Combustibles, et d'une chauffe difficile à comprendre.
|
||||
|
||||
## Diagnostic
|
||||
|
||||
La méthode `mayPlace` de la case serveur utilisait `index`, masqué par le
|
||||
champ hérité `Slot.index` (position affichée). Elle validait donc successivement
|
||||
une entrée, un combustible et une sortie, au lieu des 27 entrées ou des
|
||||
27 combustibles. Le client acceptait le dépôt puis le serveur le refusait.
|
||||
Le test reproduit le refus d’un déplacement de la case 1 vers la case 23.
|
||||
Le correctif utilise explicitement `physicalIndex` pour la validation.
|
||||
|
||||
## Comportement
|
||||
|
||||
Les transferts Maj dans les vues complètes du Fourneau sont anticipés sur le
|
||||
client, avec les mêmes destinations et règles de combustible que le serveur.
|
||||
Les cases se remplissent dans l'ordre, après fusion des piles compatibles.
|
||||
Un dépôt manuel conserve la case choisie ; aucun tri périodique des ingrédients
|
||||
n'est ajouté. Les recherches masquent une partie du stock : leurs transferts
|
||||
entrants continuent d'attendre la réponse du serveur, qui connaît tout le stock.
|
||||
|
||||
La chauffe emprunte du combustible à une autre case uniquement pendant le tick
|
||||
natif. La pile non brûlée retourne immédiatement à sa case d'origine, avec tous
|
||||
ses composants. Les restes natifs restent associés à leur cuisson : un seau de
|
||||
lave devient un seau, puis un seau d'eau avec une éponge mouillée.
|
||||
|
||||
Les 27 entrées ont chacune une barre et une infobulle : cuisson en cours,
|
||||
manque de combustible avec progression conservée, résultat bloqué, absence de
|
||||
recette. Le panneau indique le nombre de cuissons et la réserve allumée en
|
||||
secondes de travail cumulées. Ce n'est pas une durée réelle restante : les
|
||||
cuissons parallèles se partagent la réserve. « Fonctionnement » explique les
|
||||
onglets, les gestes et le rendement normal du combustible en FR/EN.
|
||||
|
||||
Les recettes, résultats, expérience et règles de trémies restent natifs.
|
||||
Aucun changement de format, de monde ou de génération ; les 27 fours physiques
|
||||
conservent l'unique copie des objets et de leurs temps de chauffe.
|
||||
|
||||
## Vérifications
|
||||
|
||||
Le test natif `Furnace115ClientChecks` passe avec un serveur intégré, sur un
|
||||
nouveau monde plat de graine 115 : vrai glisser avec Maj sur neuf piles nommées,
|
||||
remplissage consécutif, déplacement manuel vers la case 23 et répartition native
|
||||
sur six cases de colonnes différentes. Les 27 cases de chaque onglet valident
|
||||
leur rôle physique. Le combustible nommé conserve sa position et ses composants.
|
||||
|
||||
La cuisson parallèle, ses états synchronisés, la progression conservée à court
|
||||
de combustible, les 27 cuissons ravitaillées, le seau de lave et l'éponge,
|
||||
l'extraction des résultats et la sauvegarde native passent. Captures FR/EN
|
||||
relues dans `build/furnace115-evidence/`. Marqueur `FURNACE115_PASS` dans
|
||||
`build/furnace115-client.log`.
|
||||
|
||||
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussit
|
||||
en 2 min 29 s, 125 tâches. Le panneau d'aide reçoit ensuite une dernière
|
||||
vérification client et une reconstruction ; la livraison commune beta.116
|
||||
repasse les contrôles complets en 2 min 23 s et le scénario Fourneau en 42 s.
|
||||
|
||||
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 reste locale et rejoint la livraison commune
|
||||
avec les matériaux de sculpture : [beta.116](sculpture-materials-beta116.md).
|
||||
|
||||
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`.
|
||||
@@ -0,0 +1,87 @@
|
||||
# beta.116 — Sculptures à partir des textures de blocs
|
||||
|
||||
Ticket sur `codex/sculpture-materials-beta116`, comprenant aussi le correctif
|
||||
Fourneau beta.115 dans la livraison commune.
|
||||
|
||||
Le Clay Workshop accepte les objets-blocs comme matière. Le client prélève une
|
||||
palette bornée dans la texture du bloc issue du pack actif, avec sa teinte, puis
|
||||
le modèle conserve sa géométrie et transpose ses valeurs claires/sombres dans
|
||||
cette palette. L'aperçu et le résultat utilisent le même calcul. Un bloc est
|
||||
consommé par sculpture ; les neuf cases de hotbar restent les seules accessibles.
|
||||
|
||||
## Contrat des données et du réseau
|
||||
|
||||
Les sculptures existantes conservent exactement leurs voxels sauvegardés :
|
||||
schéma 1 inchangé, identifiants `sanctuary:*` stables. Les nouvelles sculptures
|
||||
stockent elles aussi leurs couleurs finales dans ce format. Aucun chargement
|
||||
ne recolore les objets ou constructions existants. Orientation et ancrage
|
||||
beta.113 inchangés, sans migration ni modification d'un monde personnel.
|
||||
|
||||
La sélection du menu ajoute l'identité de la matière et une palette de 256
|
||||
couleurs opaques au maximum. Ce sont des données d'apparence fournies par le
|
||||
client, comme le modèle déjà importé ; elles n'octroient ni objets ni propriétés
|
||||
de bloc. Le serveur vérifie le menu, son jeton, l'accès à l'atelier, les limites
|
||||
et l'objet effectivement présent. Il applique lui-même la palette et facture
|
||||
chaque résultat. Un changement de matière invalide immédiatement l'ancien
|
||||
résultat jusqu'à réception de la sélection correspondante. Les deux côtés
|
||||
doivent utiliser beta.116. Aucun nouveau format de sauvegarde.
|
||||
|
||||
## Vérifications natives
|
||||
|
||||
`Sculpture116ClientChecks` passe en 1 min 7 s, sur un nouveau monde plat de
|
||||
Minecraft 26.3, graine 116, puis la régression complète de l'atelier :
|
||||
|
||||
- Les 1 207 objets-blocs enregistrés ont une palette opaque, non vide et bornée.
|
||||
- Fabrication payée avec pierre, planches de chêne, or, laine rouge, verre,
|
||||
feuilles de chêne, argile et ardoise ; aperçu identique au résultat serveur.
|
||||
- Conservation des tons clairs/sombres du modèle et de ses coordonnées, sans
|
||||
convertir les 16 argiles colorées en une simple couleur unie.
|
||||
- Une matière différente efface aussitôt l'ancien résultat ; une sélection
|
||||
périmée ou un jeton incorrect ne fabrique rien et ne consomme aucun objet.
|
||||
- Les conteneurs contenant des objets sont refusés ; ils peuvent servir de
|
||||
matière une fois vidés. Les objets qui ne sont pas des blocs sont refusés.
|
||||
- Bornes et opacité des palettes, véritable rechargement des ressources,
|
||||
neuf cases de hotbar, stock plein, consommation exacte et restitution.
|
||||
- Poses selon les quatre regards, accroche au bord, grille de voxels,
|
||||
casse/repose, sauvegarde et reconnexion après suppression du fichier GLB.
|
||||
- Interface FR/EN aux échelles 2/3, génération de plan au Métabli inchangée.
|
||||
|
||||
Marqueurs `SCULPTURE116_PALETTES`, `SCULPTURE116_MATERIALS_PASS`,
|
||||
`SCULPTURE116_PASS` et régressions 106/111/112/113 dans
|
||||
`build/sculpture116-client.log`. Captures relues dans
|
||||
`build/sculpture116-evidence/`. Un libellé est raccourci en « Bloc » pour éviter
|
||||
le contact avec « Sculpture » dans le cadre compact.
|
||||
|
||||
La palette vient de la texture représentative du modèle de bloc du pack actif,
|
||||
avec la teinte locale ; une texture animée utilise sa première image. Les blocs
|
||||
sans texture lisible utilisent leur couleur de carte. Il s'agit d'un transfert
|
||||
de couleurs sur les voxels opaques, sans transparence, émission ou fonction
|
||||
spéciale du bloc d'origine. Les sculptures existantes gardent leurs couleurs.
|
||||
|
||||
Essais natifs macOS avec serveur intégré ; pas de client Windows ni de test
|
||||
LAN à plusieurs joueurs. Le serveur dédié de GameTest reste exclu selon le
|
||||
refus antérieur de son EULA. Aucun monde personnel ouvert.
|
||||
|
||||
La régression native du Fourneau passe également dans beta.116, en 42 s :
|
||||
`build/sculpture116-furnace.log`, captures FR/EN dans
|
||||
`build/sculpture116-furnace-evidence/`. Les 27 cases, le glisser-déposer, les
|
||||
combustibles et les états de cuisson restent conformes au ticket beta.115.
|
||||
|
||||
## Construction et archives
|
||||
|
||||
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussit
|
||||
en 2 min 23 s : 125 tâches, dont 105 exécutées. Sources Java concordantes avec
|
||||
les JAR sources, versions alignées et intégrité des deux MRpack vérifiées.
|
||||
Le JAR conserve à l'identique le correctif Fourneau beta.115 ; seuls les
|
||||
classes de matière de sculpture et libellés FR/EN changent dans la production.
|
||||
Les classes de test et modèles GLB de test sont absents des archives.
|
||||
|
||||
- `Sanctuary-beta.116.mrpack` : 10189831 octets, SHA-256
|
||||
`7df04a6e2a57e76a55ca57bd7520b05cbb573392690b401b868837498807d252`.
|
||||
- `Sanctuary-Test-beta.116.mrpack` : 10208754 octets, SHA-256
|
||||
`f4c9293e20384b05366d092fc7ee1abea275940a02373ee4036f73f0caa43747`.
|
||||
- JAR Sanctuary :
|
||||
`76a9205783c8b18506c32c9eef6c481d95fd3ba0746a2bdd558a194534a51a32`.
|
||||
|
||||
Reçu local : `build/sculpture116-artifact.json`. Publication et synchronisation
|
||||
de l'instance en cours ; leurs résultats seront consignés après vérification.
|
||||
+2
-2
@@ -9,8 +9,8 @@ loom_version=1.17.20
|
||||
fabric_api_version=0.160.5+26.3
|
||||
|
||||
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
|
||||
mod_version=beta.113
|
||||
pack_version=beta.113
|
||||
mod_version=beta.116
|
||||
pack_version=beta.116
|
||||
resource_pack_version=beta.090
|
||||
maven_group=fr.koka.sanctuary
|
||||
jei_version=30.32.0-sanctuary.3
|
||||
|
||||
@@ -93,6 +93,16 @@ tasks.named('runGameTest') {
|
||||
}
|
||||
|
||||
tasks.named('processGametestResources') {
|
||||
def sculpture116Client = providers.gradleProperty('sanctuarySculpture116ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuarySculpture116ClientTests', sculpture116Client)
|
||||
if (sculpture116Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Sculpture116ClientChecks') }
|
||||
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') }
|
||||
|
||||
+115
@@ -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 2–3 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);}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import fr.koka.sanctuary.gametest.Starter039TestSupport;
|
||||
import fr.koka.sanctuary.multiblock.*;
|
||||
import fr.koka.sanctuary.mixin.MultiblockFurnaceAccess;
|
||||
import fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.*;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
||||
import net.minecraft.client.input.*;
|
||||
import net.minecraft.core.*;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import net.minecraft.world.item.*;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.*;
|
||||
|
||||
/** Native quick-transfer, arbitrary slot placement, shared cooking and synchronized feedback. */
|
||||
public final class Furnace115ClientChecks implements FabricClientGameTest {
|
||||
private static final BlockPos ORIGIN=new BlockPos(2,1,2);
|
||||
private static MultiblockData.Machine oven;
|
||||
public void runTest(ClientGameTestContext c){
|
||||
var intro=new AtomicBoolean(true);
|
||||
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
|
||||
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
|
||||
Starter039TestSupport.selectFirst(screen);
|
||||
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
|
||||
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
|
||||
}});
|
||||
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Furnace115 "+UUID.randomUUID());s.setSeed("115");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
|
||||
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
|
||||
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.onUpdateAbilities();p.getInventory().clearContent();p.teleportTo(1.5,1,1.5);
|
||||
for(int x=-2;x<8;x++)for(int z=-2;z<8;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
|
||||
oven=new MultiblockData.Machine(UUID.randomUUID(),ORIGIN,true,Direction.NORTH);for(var pos:oven.members())p.level().setBlockAndUpdate(pos,Blocks.FURNACE.defaultBlockState());check(Multiblocks.assemble(p,oven),"Assemble");
|
||||
for(int i=0;i<9;i++){var stack=new ItemStack(Items.RAW_IRON,8);stack.set(DataComponents.CUSTOM_NAME,Component.literal("Batch "+i));p.getInventory().setItem(i,stack);}Multiblocks.open(p,oven,ORIGIN,0,"",0);
|
||||
});
|
||||
c.waitFor(m->m.gui.screen() instanceof MultiblockScreen&&m.player.containerMenu.getSlot(54).hasItem(),200);
|
||||
// One native Shift-drag crosses nine hotbar stacks, with no mouse release between them.
|
||||
c.getInput().holdKey(o->o.keyShift);move(c,54);c.runOnClient(m->m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,InputConstants.MOD_SHIFT),1));
|
||||
for(int i=55;i<63;i++)move(c,i);
|
||||
c.runOnClient(m->m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,InputConstants.MOD_SHIFT),0));c.getInput().releaseKey(o->o.keyShift);
|
||||
c.waitTicks(8);c.takeScreenshot("furnace115-inputs");
|
||||
c.waitFor(m->m.player.containerMenu.getSlot(8).hasItem()&&!m.player.containerMenu.getSlot(62).hasItem(),150);
|
||||
c.runOnClient(m->{for(int i=0;i<9;i++)check(m.player.containerMenu.getSlot(i).getItem().getHoverName().getString().equals("Batch "+i),"Shift-drag fills consecutive UI slots, no three-column gaps");});
|
||||
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);for(int i=0;i<9;i++)check(stock.getItem(i*3).getCount()==8,"Physical inputs correspond to visible slots");});
|
||||
// Move an ingredient to a distant, arbitrary slot using real inventory packets.
|
||||
click(c,1,0,ContainerInput.PICKUP);click(c,23,0,ContainerInput.PICKUP);c.waitTicks(10);
|
||||
c.runOnClient(m->{check(m.player.containerMenu.getSlot(1).getItem().isEmpty()&&m.player.containerMenu.getSlot(23).getItem().getCount()==8,"Manual placement remains in chosen cell: "+m.player.containerMenu.slots.stream().filter(slot->slot.hasItem()).map(slot->slot.index+":"+slot.getItem()).toList()+", cursor="+m.player.containerMenu.getCarried());});
|
||||
click(c,23,0,ContainerInput.PICKUP);
|
||||
click(c,-999,net.minecraft.world.inventory.AbstractContainerMenu.getQuickcraftMask(0,0),ContainerInput.QUICK_CRAFT);
|
||||
for(int i:new int[]{1,9,10,13,14,22})click(c,i,net.minecraft.world.inventory.AbstractContainerMenu.getQuickcraftMask(1,0),ContainerInput.QUICK_CRAFT);
|
||||
click(c,-999,net.minecraft.world.inventory.AbstractContainerMenu.getQuickcraftMask(2,0),ContainerInput.QUICK_CRAFT);click(c,23,0,ContainerInput.PICKUP);c.waitTicks(6);
|
||||
c.runOnClient(m->{for(int i:new int[]{1,9,10,13,14,22})check(m.player.containerMenu.getSlot(i).getItem().getCount()==1,"Native drag distribution keeps every chosen column");check(m.player.containerMenu.getSlot(23).getItem().getCount()==2&&m.player.containerMenu.getCarried().isEmpty(),"Distributed count conserved");});
|
||||
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);check(stock.getItem(69).getCount()==2&&stock.getItem(3).getCount()==1,"Manual distribution matches server");for(int i=0;i<27;i++)check(p.containerMenu.getSlot(i).mayPlace(new ItemStack(Items.RAW_IRON)),"Every visible input validates its physical input slot, not Slot.index");reset(stock);stock.setItem(0,new ItemStack(Items.RAW_IRON,4));stock.setItem(18,new ItemStack(Items.RAW_GOLD,4));
|
||||
var coal=new ItemStack(Items.COAL,5);coal.set(DataComponents.CUSTOM_NAME,Component.literal("Stationary fuel"));stock.setItem(79,coal);
|
||||
for(int tick=0;tick<205;tick++)MultiblockCooking.tick(p.level(),oven);
|
||||
check(stock.getItem(79).getCount()==4&&stock.getItem(79).getHoverName().getString().equals("Stationary fuel"),"Named unburnt fuel stays in original cell");
|
||||
for(int i=1;i<79;i+=3)check(stock.getItem(i).isEmpty(),"No displaced coal in other slots");
|
||||
check(stock.getItem(2).is(Items.IRON_INGOT)&&stock.getItem(20).is(Items.GOLD_INGOT),"Shared heat cooks both arbitrary lanes");
|
||||
Multiblocks.open(p,oven,ORIGIN,0,"",0);
|
||||
});
|
||||
c.waitTicks(6);c.runOnClient(m->{var menu=(MultiblockMenu)m.player.containerMenu;check(menu.cooking==2&&menu.heat>0&&menu.laneState(6)==MultiblockMenu.COOKING,"Real per-lane feedback synchronizes");});c.takeScreenshot("furnace115-cooking");
|
||||
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);reset(stock);
|
||||
stock.setItem(0,new ItemStack(Items.RAW_IRON));stock.setItem(3,new ItemStack(Items.DIAMOND));stock.setItem(6,new ItemStack(Items.RAW_GOLD));stock.setItem(8,new ItemStack(Items.GOLD_INGOT,64));
|
||||
var a=(MultiblockFurnaceAccess)stock.cell(0);a.sanctuary$progress(80);a.sanctuary$total(200);p.containerMenu.broadcastChanges();
|
||||
});c.waitTicks(6);
|
||||
c.runOnClient(m->{var menu=(MultiblockMenu)m.player.containerMenu;check(menu.laneState(0)==MultiblockMenu.NO_FUEL&&menu.laneProgress(0)==40,"Paused progress is visible and preserved");check(menu.laneState(1)==MultiblockMenu.NO_RECIPE,"Invalid recipe explained");check(menu.laneState(2)==MultiblockMenu.BLOCKED,"Blocked output explained");check(menu.cooking==0&&menu.heat==0,"Cold furnace clearly shown");});c.takeScreenshot("furnace115-paused");
|
||||
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();cookingRegressions(p);var stock=new MultiblockContainer(p.level(),oven);reset(stock);
|
||||
for(int i=0;i<9;i++){var stack=new ItemStack(Items.COAL,5);stack.set(DataComponents.CUSTOM_NAME,Component.literal("Fuel "+i));p.getInventory().setItem(i,stack);}Multiblocks.open(p,oven,ORIGIN,0,"",1);
|
||||
});c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.section()==1&&menu.getSlot(54).hasItem(),100);
|
||||
// No cooking in this fixture: compare the predicted UI in the same call, before any server reply.
|
||||
c.runOnClient(m->{var menu=m.player.containerMenu;for(int i=0;i<9;i++){
|
||||
m.gameMode.handleContainerInput(menu.containerId,54+i,0,ContainerInput.QUICK_MOVE,m.player);
|
||||
check(menu.getSlot(54+i).getItem().isEmpty()&&menu.getSlot(i).getItem().getCount()==5,"Immediate Shift prediction uses consecutive fuel cells");}
|
||||
check(!menu.getSlot(9).mayPlace(new ItemStack(Items.DIAMOND)),"Client rejects invalid fuel like server");});
|
||||
c.waitTicks(8);server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stock=new MultiblockContainer(p.level(),oven);for(int i=0;i<9;i++)check(stock.getItem(i*3+1).getCount()==5,"Fuel server mapping matches prediction");for(int i=0;i<27;i++){check(p.containerMenu.getSlot(i).mayPlace(new ItemStack(Items.COAL)),"All 27 fuel cells accept fuel");check(!p.containerMenu.getSlot(i).mayPlace(new ItemStack(Items.DIAMOND)),"All 27 fuel cells refuse other items");}});c.takeScreenshot("furnace115-fuel");
|
||||
c.runOnClient(m->{var screen=(MultiblockScreen)m.gui.screen();screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(Multiblocks.text("how_to_cook").getString())).findFirst().orElseThrow().onPress(enter());check(screen.helpVisible(),"Help button opens its explanation");});c.waitTicks(5);c.takeScreenshot("furnace115-help");
|
||||
var reload=c.computeOnClient(m->{m.getLanguageManager().setSelected("fr_fr");return m.reloadResourcePacks();});c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(3);c.takeScreenshot("furnace115-help-fr");
|
||||
System.out.println("FURNACE115_PASS native Shift-drag, consecutive input/fuel mapping, immediate prediction, arbitrary placement, stationary named fuel, heat/output/pause/recipe feedback, fuel budget, refuelling, buckets, save and result extraction");
|
||||
}finally{intro.set(false);}
|
||||
}
|
||||
private static void cookingRegressions(ServerPlayer p){
|
||||
var stock=new MultiblockContainer(p.level(),oven);reset(stock);stock.setItem(0,new ItemStack(Items.WET_SPONGE));stock.setItem(79,new ItemStack(Items.LAVA_BUCKET));for(int t=0;t<205;t++)MultiblockCooking.tick(p.level(),oven);
|
||||
check(stock.getItem(2).is(Items.SPONGE)&&stock.getItem(1).is(Items.WATER_BUCKET)&&stock.getItem(79).isEmpty(),"Native lava and wet sponge remainder");
|
||||
reset(stock);for(int i=0;i<81;i+=3)stock.setItem(i,new ItemStack(Items.RAW_IRON));stock.setItem(79,new ItemStack(Items.COAL));for(int t=0;t<90;t++)MultiblockCooking.tick(p.level(),oven);
|
||||
int partial=((MultiblockFurnaceAccess)stock.cell(0)).sanctuary$progress();check(partial>0&&partial<200,"One coal cannot cook all 27 items");for(int t=0;t<30;t++)MultiblockCooking.tick(p.level(),oven);check(((MultiblockFurnaceAccess)stock.cell(0)).sanctuary$progress()==partial,"No lost paid progress when out of fuel");
|
||||
stock.setItem(79,new ItemStack(Items.COAL,4));for(int t=0;t<205;t++)MultiblockCooking.tick(p.level(),oven);int produced=0;for(int i=2;i<81;i+=3)produced+=stock.getItem(i).getCount();check(produced==27,"Refuelling completes all lanes exactly once");
|
||||
var f=(AbstractFurnaceBlockEntity)stock.cell(0);var restored=(AbstractFurnaceBlockEntity)BlockEntity.loadStatic(f.getBlockPos(),f.getBlockState(),f.saveWithFullMetadata(p.level().registryAccess()),p.level().registryAccess());check(((MultiblockFurnaceAccess)restored).sanctuary$heat()==((MultiblockFurnaceAccess)f).sanctuary$heat(),"Native heat survives save");
|
||||
Multiblocks.open(p,oven,ORIGIN,0,"",2);var menu=(MultiblockMenu)p.containerMenu;check(!menu.getSlot(0).mayPlace(new ItemStack(Items.STONE)),"Results refuse input");check(!menu.quickMoveStack(p,0).isEmpty()&&stock.getItem(2).isEmpty(),"Native result slot can extract");
|
||||
}
|
||||
private static void reset(MultiblockContainer stock){stock.clearContent();for(int i=0;i<81;i+=3){var a=(MultiblockFurnaceAccess)stock.cell(i);a.sanctuary$heat(0);a.sanctuary$progress(0);}}
|
||||
private static void click(ClientGameTestContext c,int slot,int button,ContainerInput type){c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,slot,button,type,m.player));}
|
||||
private static void move(ClientGameTestContext c,int index){c.runOnClient(m->{var a=(AbstractContainerScreenAccessor)m.gui.screen();var slot=m.player.containerMenu.getSlot(index);var w=m.getWindow();double x=a.sanctuary$getLeftPos()+slot.x+8,y=a.sanctuary$getTopPos()+slot.y+8;double sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();m.mouseHandler.onMove(w.handle(),x*sx,y*sy,(x-m.mouseHandler.getScaledXPos(w))*sx,(y-m.mouseHandler.getScaledYPos(w))*sy);m.mouseHandler.handleAccumulatedMovement();});c.waitTick();}
|
||||
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
|
||||
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import fr.koka.sanctuary.gametest.Starter039TestSupport;
|
||||
import fr.koka.sanctuary.plans.*;
|
||||
import java.nio.file.*;
|
||||
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.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
|
||||
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.*;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import net.minecraft.world.item.*;
|
||||
import net.minecraft.world.item.component.ItemContainerContents;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
|
||||
/** All registered block materials, native paid crafts, stale input rejection and pack reload. */
|
||||
public final class Sculpture116ClientChecks implements FabricClientGameTest {
|
||||
private static final BlockPos WORKSHOP=new BlockPos(2,1,2);
|
||||
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);
|
||||
}});
|
||||
Path file=PlanClient.directory().resolve("sculpture116-"+UUID.randomUUID()+".glb");
|
||||
try{
|
||||
Files.createDirectories(file.getParent());Files.write(file,Statuary106ClientChecks.fixture());var asset=GlbModel.load(file);var source=VoxelSculpture.of(asset.id(),asset.name(),StatueVoxelizer.miniature(asset.faces()));
|
||||
try{new SculpturePalette(List.of());throw new AssertionError("Empty palette accepted");}catch(IllegalArgumentException expected){}
|
||||
try{new SculpturePalette(Collections.nCopies(257,-1));throw new AssertionError("Oversized palette accepted");}catch(IllegalArgumentException expected){}
|
||||
try{new SculpturePalette(List.of(0));throw new AssertionError("Transparent color accepted");}catch(IllegalArgumentException expected){}
|
||||
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sculpture116 "+UUID.randomUUID());s.setSeed("116");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(.5,1,.5);for(int x=-4;x<8;x++)for(int z=-4;z<8;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.level().setBlockAndUpdate(WORKSHOP,Statuary.WORKSHOP.defaultBlockState());Statuary.open(p,WORKSHOP);});
|
||||
c.waitForScreen(ClayWorkshopScreen.class);c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();((ClayWorkshopScreen)m.gui.screen()).load(file);});
|
||||
c.waitFor(m->!((ClayWorkshopScreen)m.gui.screen()).busy()&&((ClayWorkshopScreen)m.gui.screen()).selected()!=null,200);
|
||||
c.runOnClient(m->{int count=0;for(var item:BuiltInRegistries.ITEM)if(item instanceof BlockItem){var stack=new ItemStack(item);check(ClayWorkshopMenu.material(stack),"Empty registered block accepted: "+item);var palette=SculptureMaterialSampler.sample(stack);check(palette!=null&&palette.colors().size()<=256&&palette.colors().stream().allMatch(v->(v>>>24)==255),"Bounded opaque palette: "+item);count++;}check(count>1000,"Whole block registry sampled");System.out.println("SCULPTURE116_PALETTES "+count+" registered block items");
|
||||
var dark=new ItemStack(Items.SHULKER_BOX);dark.set(DataComponents.CONTAINER,ItemContainerContents.fromItems(List.of(new ItemStack(Items.DIAMOND))));check(!ClayWorkshopMenu.material(dark),"Filled container cannot lose contents");check(!ClayWorkshopMenu.material(new ItemStack(Items.DIAMOND)),"Non-block item refused");
|
||||
var shades=new ArrayList<VoxelSculpture.Cell>();for(int x=0;x<16;x++){int v=x*17;shades.add(new VoxelSculpture.Cell(x,0,0,0xFF000000|v<<16|v<<8|v));}var ramp=new VoxelSculpture(source.modelId(),"Ramp",16,1,1,shades);var palette=SculptureMaterialSampler.sample(new ItemStack(Items.OAK_PLANKS));var recolored=palette.apply(ramp);check(recolored.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>2,"Material retains multiple texture shades");for(int x=1;x<16;x++)check(SculpturePalette.lightness(recolored.cells().get(x).color())>=SculpturePalette.lightness(recolored.cells().get(x-1).color()),"Model shading order retained");});
|
||||
var rendered=new HashSet<VoxelSculpture>();
|
||||
for(var item:List.of(Items.STONE,Items.OAK_PLANKS,Items.GOLD_BLOCK,Items.WOOL.pick(DyeColor.RED),Items.GLASS,Items.OAK_LEAVES,Items.CLAY,Items.DEEPSLATE)){
|
||||
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();var menu=(ClayWorkshopMenu)p.containerMenu;menu.getSlot(0).set(new ItemStack(item,2));check(menu.getSlot(1).getItem().isEmpty(),"Previous material result invalidates immediately");menu.broadcastChanges();});
|
||||
var expected=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(item)).apply(source));rendered.add(expected);
|
||||
c.waitFor(m->expected.equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())),180);
|
||||
c.runOnClient(m->check(ItemStack.matches(((ClayWorkshopScreen)m.gui.screen()).preview(),m.player.containerMenu.getSlot(1).getItem()),"Preview equals server-owned result"));
|
||||
check(StatuaryEntity.decode(StatuaryEntity.encode(expected)).equals(expected),"Palette colors persist in unchanged schema 1");
|
||||
c.takeScreenshot("sculpture116-"+BuiltInRegistries.ITEM.getKey(item).getPath());
|
||||
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.PICKUP,m.player));
|
||||
c.waitFor(m->m.player.containerMenu.getCarried().is(Statuary.ITEM)&&m.player.containerMenu.getSlot(0).getItem().getCount()==1,100);
|
||||
server.waitFor(s->{var menu=s.getPlayerList().getPlayers().getFirst().containerMenu;return menu.getCarried().is(Statuary.ITEM)&&menu.getSlot(0).getItem().getCount()==1;},100);
|
||||
server.runOnServer(s->{var menu=(ClayWorkshopMenu)s.getPlayerList().getPlayers().getFirst().containerMenu;check(expected.equals(StatuaryEntity.sculpture(menu.getCarried()))&&menu.getSlot(0).getItem().getCount()==1,"Exactly one actual block consumed per sculpture");});
|
||||
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,ClayWorkshopMenu.HOTBAR_START,0,ContainerInput.PICKUP,m.player));c.waitFor(m->m.player.containerMenu.getCarried().isEmpty(),100);server.waitFor(s->s.getPlayerList().getPlayers().getFirst().containerMenu.getCarried().isEmpty(),100);
|
||||
}
|
||||
check(rendered.size()>=7,"Distinct materials produce distinct sculptures");
|
||||
c.waitTicks(6);var stone=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(Items.STONE)));
|
||||
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var menu=(ClayWorkshopMenu)p.containerMenu;menu.getSlot(0).set(new ItemStack(Items.DIAMOND_BLOCK));menu.select(p,source,Items.STONE,stone);check(menu.getSlot(1).getItem().isEmpty(),"Stale material palette rejected by server");});
|
||||
var diamond=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(Items.DIAMOND_BLOCK)).apply(source));c.waitFor(m->diamond.equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())),180);
|
||||
c.runOnClient(m->{var menu=(ClayWorkshopMenu)m.player.containerMenu;ClientPlayNetworking.send(new ClayWorkshopMenu.Select(menu.containerId,UUID.randomUUID(),source,Items.DIAMOND_BLOCK,stone));});c.waitTicks(6);
|
||||
server.runOnServer(s->{var menu=(ClayWorkshopMenu)s.getPlayerList().getPlayers().getFirst().containerMenu;check(diamond.equals(StatuaryEntity.sculpture(menu.getSlot(1).getItem()))&&menu.getSlot(0).getItem().getCount()==1,"Forged menu token cannot change recipe or spend input");});
|
||||
var before=c.computeOnClient(m->m.getModelManager().getBlockStateModelSet());var reload=c.computeOnClient(m->m.reloadResourcePacks());c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(8);
|
||||
c.runOnClient(m->{check(before!=m.getModelManager().getBlockStateModelSet(),"Actual resource reload replaces sampling generation");check(diamond.equals(StatuaryEntity.sculpture(((ClayWorkshopScreen)m.gui.screen()).preview())),"Preview refreshes after pack reload");});
|
||||
System.out.println("SCULPTURE116_MATERIALS_PASS all block palettes, eight native paid materials, preview equality, shading, stale selections, input identity, token, palette bounds, containers and resource reload");
|
||||
}
|
||||
}catch(java.io.IOException e){throw new AssertionError(e);}finally{intro.set(false);try{Files.deleteIfExists(file);}catch(java.io.IOException ignored){}}
|
||||
new Statuary106ClientChecks().runTest(c);
|
||||
System.out.println("SCULPTURE116_PASS materials plus full workshop/hotbar, placement, anchoring, drops, reconnect, FR/EN and Metabli regression");
|
||||
}
|
||||
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
|
||||
private static void check(boolean ok,String why){if(!ok)throw new AssertionError(why);}
|
||||
}
|
||||
+14
-11
@@ -46,8 +46,8 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
|
||||
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
|
||||
try{
|
||||
System.out.println("STATUARY106_LIBRARY "+file);Files.createDirectories(file.getParent());byte[] fixture=fixture();Files.write(file,fixture);
|
||||
var asset=GlbModel.load(file);var pixels=StatueVoxelizer.miniature(asset.faces());var source=VoxelSculpture.of(asset.id(),asset.name(),pixels);
|
||||
MODEL.set(source.modelId());check(source.height()==16&&source.width()<16,"Proportions, 16 voxel longest axis");check(source.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>1,"Embedded PNG retains multiple colors");
|
||||
var asset=GlbModel.load(file);var pixels=StatueVoxelizer.miniature(asset.faces());var originalSource=VoxelSculpture.of(asset.id(),asset.name(),pixels);
|
||||
MODEL.set(originalSource.modelId());check(originalSource.height()==16&&originalSource.width()<16,"Proportions, 16 voxel longest axis");check(originalSource.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>1,"Embedded PNG retains multiple colors");
|
||||
check(CreationLibrary.files(CreationLibrary.Kind.MODEL).contains(file)&&!CreationLibrary.files(CreationLibrary.Kind.SCHEMATIC).contains(file),"One folder, different filters");
|
||||
var invalid=fixture.clone();ByteBuffer.wrap(invalid).order(ByteOrder.LITTLE_ENDIAN).putInt(8,1);reject(invalid,"GLB header length");
|
||||
var oversize=new byte[GlbModel.MAX_BYTES+1];reject(oversize,"File bound");
|
||||
@@ -57,11 +57,12 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
|
||||
reject(edit(fixture,j->{var a=new JsonArray();a.add(0);j.getAsJsonArray("nodes").get(0).getAsJsonObject().add("children",a);}),"Cyclic scene");
|
||||
var interop=net.fabricmc.loader.api.FabricLoader.getInstance().getGameDir().resolve("../../../../../build/statuary106-evidence/BoxTextured.glb").normalize();
|
||||
if(Files.exists(interop)){var box=GlbModel.read(interop);check(box.size()==12,"Independent Khronos indexed GLB");check(!StatueVoxelizer.miniature(box).isEmpty(),"Independent textured box voxelizes");System.out.println("STATUARY106_INTEROP_PASS Khronos BoxTextured GLB");}
|
||||
check(source.rotate().rotate().rotate().rotate().equals(source),"Four rotations preserve ID and data");
|
||||
try{new VoxelSculpture(source.modelId(),"bad",16,16,16,List.of(new VoxelSculpture.Cell(0,0,0,-1),new VoxelSculpture.Cell(0,0,0,-1)));throw new AssertionError("Duplicate cell accepted");}catch(IllegalArgumentException expected){}
|
||||
check(originalSource.rotate().rotate().rotate().rotate().equals(originalSource),"Four rotations preserve ID and data");
|
||||
try{new VoxelSculpture(originalSource.modelId(),"bad",16,16,16,List.of(new VoxelSculpture.Cell(0,0,0,-1),new VoxelSculpture.Cell(0,0,0,-1)));throw new AssertionError("Duplicate cell accepted");}catch(IllegalArgumentException expected){}
|
||||
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Statuary106 "+UUID.randomUUID());s.setSeed("106");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);
|
||||
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
|
||||
saved=world.getWorldSave();c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
|
||||
var source=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(Items.CLAY)).apply(originalSource));
|
||||
var server=world.getServer();
|
||||
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");
|
||||
@@ -81,7 +82,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.runOnClient(m->check(m.player.containerMenu.getSlot(1).getItem().getHoverName().getString().equals("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");
|
||||
@@ -97,8 +98,9 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
|
||||
p.getInventory().setItem(18,ItemStack.EMPTY);p.getInventory().setItem(Inventory.SLOT_OFFHAND,ItemStack.EMPTY);fr.koka.sanctuary.inventory.SanctuaryOverflowAccess.of(p).setItem(17,ItemStack.EMPTY);
|
||||
});
|
||||
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();});
|
||||
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;ORIGINAL.set(StatuaryEntity.sculpture(menu.getSlot(1).getItem()));check(ORIGINAL.get().equals(source),"Plain clay keeps exact colors and model ID");
|
||||
for(var family:ColoredBricks.families()){var colored=ClayWorkshopMenu.material(source,new ItemStack(family.clay()));check(colored.modelId().equals(source.modelId())&&colored.cells().size()==source.cells().size()&&colored.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()==1,"All 16 clays keep geometry and ID, recolor uniformly");}});
|
||||
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;ORIGINAL.set(StatuaryEntity.sculpture(menu.getSlot(1).getItem()));check(ORIGINAL.get().equals(source),"Plain clay applies its sampled palette and preserves model ID");
|
||||
});
|
||||
c.runOnClient(m->{for(var family:ColoredBricks.families()){var palette=SculptureMaterialSampler.sample(new ItemStack(family.clay()));var colored=palette.apply(originalSource);check(colored.modelId().equals(source.modelId())&&colored.cells().size()==source.cells().size()&&colored.cells().stream().allMatch(cell->palette.colors().contains(cell.color())),"All 16 clays keep geometry and use actual texture shades");}});
|
||||
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.PICKUP,m.player));
|
||||
c.waitFor(m->m.player.containerMenu.getCarried().is(Statuary.ITEM),100);
|
||||
c.waitTicks(6);
|
||||
@@ -140,13 +142,14 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
|
||||
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);
|
||||
// Reuse the same file with colored clay; server, rather than the client, chooses the tint.
|
||||
// Reuse the file with colored clay; server applies the palette bound to that actual material.
|
||||
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.runOnClient(m->check(m.player.containerMenu.getSlot(1).getItem().getHoverName().getString().equals("Sculpture d’argile"),"Crafted sculpture has translated type name FR"));c.takeScreenshot("statuary106-workshop-fr-blue");
|
||||
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"),"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");});
|
||||
var expectedBlue=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(ColoredBricks.families().stream().filter(f->f.color()==DyeColor.BLUE).findFirst().orElseThrow().clay())).apply(originalSource));
|
||||
server.runOnServer(s->{var menu=(ClayWorkshopMenu)player(s).containerMenu;var blue=StatuaryEntity.sculpture(menu.getSlot(1).getItem());check(blue.equals(expectedBlue),"Blue input uses sampled palette and model shading");});
|
||||
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.QUICK_MOVE,m.player));c.waitTicks(10);
|
||||
server.runOnServer(s->{var p=player(s);check(p.containerMenu.getSlot(0).getItem().isEmpty(),"Shift crafting consumes stock");check(count(p,Statuary.ITEM)==2,"Shift crafting produces exactly paid quantity");});close(c);
|
||||
// At the Metabli, the same source creates a full-sized construction plan, with no placement in survival.
|
||||
@@ -224,7 +227,7 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
|
||||
byte[] text=json.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);int padded=(text.length+3)/4*4;byte[] binary=Arrays.copyOfRange(file,20+length,file.length);var out=ByteBuffer.allocate(20+padded+binary.length).order(ByteOrder.LITTLE_ENDIAN);out.putInt(0x46546C67).putInt(2).putInt(out.capacity()).putInt(padded).putInt(0x4E4F534A).put(text);while(out.position()<20+padded)out.put((byte)' ');out.put(binary);return out.array();
|
||||
}
|
||||
/** A tiny authored cat built from triangle boxes, embedded 2×2 texture and a translated scene node. */
|
||||
private static byte[] fixture()throws IOException{
|
||||
static byte[] fixture()throws IOException{
|
||||
var xyz=new ArrayList<Float>();var uv=new ArrayList<Float>();
|
||||
for(float[] box:List.of(new float[]{.2f,0,.1f,.8f,1,.65f},new float[]{.1f,1,.05f,.9f,1.7f,.7f},new float[]{.1f,1.7f,.1f,.3f,2.1f,.5f},new float[]{.7f,1.7f,.1f,.9f,2.1f,.5f},new float[]{.75f,.1f,.2f,1.1f,.65f,.5f})){
|
||||
float x=box[0],y=box[1],z=box[2],X=box[3],Y=box[4],Z=box[5];float[][] v={{x,y,z},{X,y,z},{X,Y,z},{x,Y,z},{x,y,Z},{X,y,Z},{X,Y,Z},{x,Y,Z}};
|
||||
|
||||
@@ -22,7 +22,8 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
|
||||
private static final Identifier SELECTION=fr.koka.sanctuary.SanctuaryMod.id("hud/hotbar_selection");
|
||||
private static final ExecutorService WORKER=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"Sanctuary clay models");t.setDaemon(true);return t;});
|
||||
private List<Path> files=List.of();private int page,generation,ticks;private boolean busy,pending;
|
||||
private VoxelSculpture source,previewSource;private ItemStack preview=ItemStack.EMPTY,lastClay=ItemStack.EMPTY;
|
||||
private VoxelSculpture source,previewSource;private ItemStack preview=ItemStack.EMPTY,lastMaterial=ItemStack.EMPTY;
|
||||
private SculpturePalette palette;private Object sampledResources;
|
||||
private Component status=text("choose_model");private Button rotate;
|
||||
public ClayWorkshopScreen(ClayWorkshopMenu menu,Inventory inv,Component title){super(menu,inv,title,310,202);}
|
||||
public static Component text(String key,Object...args){return Component.translatable("sanctuary.statuary."+key,args);}
|
||||
@@ -59,12 +60,22 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
|
||||
}
|
||||
@Override protected void containerTick(){
|
||||
super.containerTick();rotate.active=source!=null&&!busy;
|
||||
if(pending&&++ticks>=5){ticks=0;pending=false;ClientPlayNetworking.send(new ClayWorkshopMenu.Select(menu.containerId,menu.view.token(),source));}
|
||||
var clay=menu.getSlot(0).getItem();
|
||||
if(previewSource!=source||!ItemStack.matches(lastClay,clay)){
|
||||
previewSource=source;lastClay=clay.copy();var colored=ClayWorkshopMenu.material(source,clay);
|
||||
preview=source==null?ItemStack.EMPTY:StatuaryEntity.item(colored==null?source:colored);
|
||||
var material=menu.getSlot(0).getItem();var resources=minecraft.getModelManager().getBlockStateModelSet();
|
||||
if(previewSource!=source||!ItemStack.isSameItemSameComponents(lastMaterial,material)||sampledResources!=resources){
|
||||
previewSource=source;lastMaterial=material.copy();sampledResources=resources;
|
||||
palette=SculptureMaterialSampler.sample(material);
|
||||
var colored=source==null?null:palette==null?source:palette.apply(source);
|
||||
preview=colored==null?ItemStack.EMPTY:StatuaryEntity.item(colored);pending=true;
|
||||
if(source!=null&&!busy)status=palette==null?text("choose_material"):text("material_ready",material.getHoverName());
|
||||
}
|
||||
// Retry a selection that raced the input packet or server rate limit. The result is the
|
||||
// acknowledgement; never predict consumption or manufacture an item on the client.
|
||||
boolean needsResult=source!=null&&palette!=null&&!ItemStack.matches(preview,menu.getSlot(1).getItem());
|
||||
if(++ticks>=5&&(pending||needsResult)){
|
||||
ticks=0;pending=false;
|
||||
if(source!=null&&palette!=null)ClientPlayNetworking.send(new ClayWorkshopMenu.Select(menu.containerId,menu.view.token(),source,material.getItem(),palette));
|
||||
}
|
||||
|
||||
}
|
||||
@Override public void onFilesDrop(List<Path> files){if(!files.isEmpty())load(files.getFirst());}
|
||||
@Override public void removed(){generation++;super.removed();}
|
||||
@@ -88,6 +99,7 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
|
||||
g.text(font,title,10,8,0xFF303030,false);g.text(font,text("models"),10,22,0xFF404040,false);
|
||||
g.text(font,text("input"),230-font.width(text("input"))/2,95,0xFF303030,false);g.text(font,text("output"),imageWidth-10-font.width(text("output")),95,0xFF303030,false);
|
||||
}
|
||||
ItemStack preview(){return preview;}
|
||||
public boolean busy(){return busy;}
|
||||
public VoxelSculpture selected(){return source;}
|
||||
}
|
||||
|
||||
@@ -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));});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import fr.koka.sanctuary.plans.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.util.ARGB;
|
||||
import net.minecraft.world.item.*;
|
||||
|
||||
/** Representative texture pixels from the active pack, including biome tint and animated first frame. */
|
||||
final class SculptureMaterialSampler {
|
||||
static SculpturePalette sample(ItemStack stack){
|
||||
if(!ClayWorkshopMenu.material(stack))return null;
|
||||
var state=((BlockItem)stack.getItem()).getBlock().defaultBlockState();var sample=PlanMaterials.sample(state);
|
||||
var mc=net.minecraft.client.Minecraft.getInstance();var tints=mc.getBlockColors().getTintSources(state);
|
||||
int tint=tints.isEmpty()?0xFFFFFFFF:tints.getFirst().colorInWorld(state,mc.level,mc.player.blockPosition());
|
||||
var pixels=new ArrayList<Integer>();
|
||||
for(int pixel:sample.pixels().argb()){
|
||||
int color=ARGB.multiply(pixel,tint);
|
||||
if((color>>>24)>=128)pixels.add(color|0xFF000000);
|
||||
}
|
||||
if(pixels.isEmpty())pixels.add(sample.average()|0xFF000000);
|
||||
pixels.sort(Comparator.comparingDouble(SculpturePalette::lightness).thenComparingInt(Integer::intValue));
|
||||
var palette=new ArrayList<Integer>();int count=Math.min(SculpturePalette.MAX_COLORS,pixels.size());
|
||||
for(int i=0;i<count;i++)palette.add(pixels.get((int)((i+.5)*pixels.size()/count)));
|
||||
return new SculpturePalette(palette);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+2
-2
@@ -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();}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package fr.koka.sanctuary.plans;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.building.ColoredBricks;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import java.util.*;
|
||||
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
|
||||
import net.minecraft.core.*;
|
||||
@@ -16,23 +17,27 @@ import net.minecraft.world.item.*;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.phys.*;
|
||||
|
||||
/** Native input/result slots. Server recomputes the color from the actual clay and charges each take. */
|
||||
/** Native input/result slots. Server validates the material, applies the bounded palette and charges each take. */
|
||||
public final class ClayWorkshopMenu extends AbstractContainerMenu {
|
||||
public static final int HOTBAR_START=2,HOTBAR_END=HOTBAR_START+Inventory.SELECTION_SIZE,HOTBAR_X=75,HOTBAR_Y=176;
|
||||
public record View(BlockPos pos,UUID token){
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf,View> CODEC=StreamCodec.of((b,p)->{b.writeBlockPos(p.pos);b.writeUUID(p.token);},b->new View(b.readBlockPos(),b.readUUID()));
|
||||
}
|
||||
/** At most 24 KiB of cells in one atomic, bounded selection, scoped to one open menu. */
|
||||
public record Select(int menu,UUID token,VoxelSculpture sculpture) implements CustomPacketPayload {
|
||||
/** At most 24 KiB of cells plus 256 palette colors in one atomic, bounded selection, scoped to one open menu. */
|
||||
public record Select(int menu,UUID token,VoxelSculpture sculpture,Item material,SculpturePalette palette) implements CustomPacketPayload {
|
||||
public static final Type<Select> TYPE=new Type<>(SanctuaryMod.id("clay_model"));
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf,Select> CODEC=StreamCodec.of((b,p)->{
|
||||
b.writeVarInt(p.menu);b.writeUUID(p.token);b.writeBoolean(p.sculpture!=null);
|
||||
b.writeVarInt(p.menu);b.writeUUID(p.token);b.writeVarInt(BuiltInRegistries.ITEM.getId(p.material));b.writeVarIntArray(p.palette==null?new int[0]:p.palette.colors().stream().mapToInt(Integer::intValue).toArray());b.writeBoolean(p.sculpture!=null);
|
||||
if(p.sculpture!=null){var s=p.sculpture;b.writeUtf(s.modelId(),128);b.writeUtf(s.name(),128);b.writeByte(s.width());b.writeByte(s.height());b.writeByte(s.depth());b.writeVarInt(s.cells().size());for(var c:s.cells()){b.writeShort(c.key());b.writeInt(c.color());}}
|
||||
},b->{
|
||||
int menu=b.readVarInt();var token=b.readUUID();if(!b.readBoolean())return new Select(menu,token,null);
|
||||
int menu=b.readVarInt();var token=b.readUUID();int itemId=b.readVarInt();
|
||||
if(itemId<0||itemId>=BuiltInRegistries.ITEM.size())throw new IllegalArgumentException("sculpture_material");
|
||||
var material=BuiltInRegistries.ITEM.byId(itemId);var colors=b.readVarIntArray(SculpturePalette.MAX_COLORS);
|
||||
var palette=colors.length==0?null:new SculpturePalette(Arrays.stream(colors).boxed().toList());
|
||||
if(!b.readBoolean())return new Select(menu,token,null,material,palette);
|
||||
String model=b.readUtf(128),name=b.readUtf(128);int w=b.readUnsignedByte(),h=b.readUnsignedByte(),d=b.readUnsignedByte(),n=b.readVarInt();
|
||||
if(n<1||n>VoxelSculpture.MAX_CELLS)throw new IllegalArgumentException("sculpture_limit");int[] packed=new int[n*2];for(int i=0;i<n;i++){packed[i*2]=b.readUnsignedShort();packed[i*2+1]=b.readInt();}
|
||||
return new Select(menu,token,VoxelSculpture.unpack(model,name,w,h,d,packed));
|
||||
return new Select(menu,token,VoxelSculpture.unpack(model,name,w,h,d,packed),material,palette);
|
||||
});
|
||||
public Type<? extends CustomPacketPayload> type(){return TYPE;}
|
||||
}
|
||||
@@ -41,41 +46,42 @@ public final class ClayWorkshopMenu extends AbstractContainerMenu {
|
||||
private final SimpleContainer input=new SimpleContainer(1){@Override public void setChanged(){super.setChanged();ClayWorkshopMenu.this.slotsChanged(this);}};
|
||||
private final SimpleContainer result=new SimpleContainer(1);
|
||||
private VoxelSculpture selected;
|
||||
private ItemStack lastClay=ItemStack.EMPTY;
|
||||
private ItemStack lastMaterial=ItemStack.EMPTY;
|
||||
private Item selectedMaterial=Items.AIR;
|
||||
private SculpturePalette selectedPalette;
|
||||
private long lastSelection=-100;
|
||||
public ClayWorkshopMenu(int id,Inventory inv,View view){this(id,inv,view,null);}
|
||||
public ClayWorkshopMenu(int id,Inventory inv,View view,Level level){
|
||||
super(Statuary.MENU,id);this.view=view;this.level=level;
|
||||
addSlot(new Slot(input,0,222,106){@Override public boolean mayPlace(ItemStack stack){return clay(stack);}});
|
||||
addSlot(new Slot(input,0,222,106){@Override public boolean mayPlace(ItemStack stack){return material(stack);}});
|
||||
addSlot(new Slot(result,0,282,106){
|
||||
@Override public boolean mayPlace(ItemStack stack){return false;}
|
||||
@Override public boolean mayPickup(Player p){return mayCraft(p)&&clay(input.getItem(0))&&!getItem().isEmpty();}
|
||||
@Override public boolean mayPickup(Player p){return mayCraft(p)&&material(input.getItem(0))&&(level==null||input.getItem(0).is(selectedMaterial))&&!getItem().isEmpty();}
|
||||
@Override public void onTake(Player p,ItemStack stack){input.removeItem(0,1);input.setChanged();super.onTake(p,stack);}
|
||||
});
|
||||
// Use the nine carried slots without registering a full inventory/overflow range.
|
||||
addInventoryHotbarSlots(inv,HOTBAR_X,HOTBAR_Y);
|
||||
}
|
||||
// Colors match the published clay palette (tools/colored-bricks-palette.json), in native dye order.
|
||||
private static final int[] CLAY_COLORS={0xC6C4C8,0xD3AA8D,0xD39FD0,0x91B0CA,0xC8BF98,0xA2C69A,0xDAC4DD,0x74767D,0xA9A8AC,0xA8C5C1,0xBE91E3,0xA0A7BC,0xA49A91,0xA0B499,0xB49A99,0x444541};
|
||||
public static boolean clay(ItemStack item){return item.is(Items.CLAY)||ColoredBricks.families().stream().anyMatch(f->item.is(f.clay().asItem()));}
|
||||
public static VoxelSculpture material(VoxelSculpture model,ItemStack clay){
|
||||
if(model==null||!clay(clay))return null;
|
||||
if(clay.is(Items.CLAY))return model;
|
||||
for(var family:ColoredBricks.families())if(clay.is(family.clay().asItem()))return model.color(0xFF000000|CLAY_COLORS[family.color().getId()]);
|
||||
return null;
|
||||
public static boolean material(ItemStack item){
|
||||
if(item.isEmpty()||!(item.getItem() instanceof BlockItem))return false;
|
||||
var contents=item.get(DataComponents.CONTAINER);
|
||||
return contents==null||!contents.nonEmptyItems().iterator().hasNext();
|
||||
}
|
||||
public void select(ServerPlayer p,VoxelSculpture sculpture){
|
||||
public void select(ServerPlayer p,VoxelSculpture sculpture,Item material,SculpturePalette palette){
|
||||
if(level==null||!stillValid(p)||level.getGameTime()-lastSelection<4)return;
|
||||
var hit=new BlockHitResult(Vec3.atCenterOf(view.pos),Direction.UP,view.pos,false);
|
||||
if(UseBlockCallback.EVENT.invoker().interact(p,level,InteractionHand.MAIN_HAND,hit)!=InteractionResult.PASS||!stillValid(p))return;
|
||||
lastSelection=level.getGameTime();selected=sculpture;refresh();broadcastChanges();
|
||||
lastSelection=level.getGameTime();
|
||||
if(sculpture!=null&&(!material(input.getItem(0))||!input.getItem(0).is(material)||palette==null))return;
|
||||
selected=sculpture;selectedMaterial=material;selectedPalette=palette;refresh();broadcastChanges();
|
||||
}
|
||||
private void refresh(){
|
||||
if(level==null)return;var sculpture=material(selected,input.getItem(0));lastClay=input.getItem(0).copy();
|
||||
if(level==null)return;var item=input.getItem(0);
|
||||
var sculpture=selected!=null&&selectedPalette!=null&&material(item)&&item.is(selectedMaterial)?selectedPalette.apply(selected):null;lastMaterial=item.copy();
|
||||
result.setItem(0,sculpture==null?ItemStack.EMPTY:StatuaryEntity.item(sculpture));
|
||||
}
|
||||
@Override public void slotsChanged(Container container){if(container==input)refresh();super.slotsChanged(container);}
|
||||
@Override public void broadcastChanges(){if(level!=null&&!ItemStack.matches(lastClay,input.getItem(0)))refresh();super.broadcastChanges();}
|
||||
@Override public void broadcastChanges(){if(level!=null&&!ItemStack.matches(lastMaterial,input.getItem(0)))refresh();super.broadcastChanges();}
|
||||
@Override public boolean stillValid(Player p){return level==null?p.isAlive():Statuary.allowed(p,level,view.pos);}
|
||||
private boolean mayCraft(Player p){
|
||||
if(!stillValid(p))return false;
|
||||
@@ -94,7 +100,7 @@ public final class ClayWorkshopMenu extends AbstractContainerMenu {
|
||||
if(index<0||index>=slots.size()||!stillValid(p))return ItemStack.EMPTY;var slot=slots.get(index);if(!slot.mayPickup(p)||!slot.hasItem())return ItemStack.EMPTY;
|
||||
var stack=slot.getItem();var copy=stack.copy();
|
||||
if(index<HOTBAR_START){if(!moveItemStackTo(stack,HOTBAR_START,HOTBAR_END,true))return ItemStack.EMPTY;}
|
||||
else if(clay(stack)){if(!moveItemStackTo(stack,0,1,false))return ItemStack.EMPTY;}
|
||||
else if(material(stack)){if(!moveItemStackTo(stack,0,1,false))return ItemStack.EMPTY;}
|
||||
else return ItemStack.EMPTY;
|
||||
if(stack.isEmpty())slot.set(ItemStack.EMPTY);else slot.setChanged();
|
||||
if(index==1)slot.onTake(p,copy);return copy;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package fr.koka.sanctuary.plans;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/** Bounded appearance data, applied identically by preview and authoritative crafting. */
|
||||
public record SculpturePalette(List<Integer> colors) {
|
||||
public static final int MAX_COLORS=256;
|
||||
public SculpturePalette {
|
||||
if(colors==null||colors.isEmpty()||colors.size()>MAX_COLORS||colors.stream().anyMatch(c->c==null||(c>>>24)!=255))
|
||||
throw new IllegalArgumentException("sculpture_palette");
|
||||
colors=colors.stream().sorted(Comparator.comparingDouble(SculpturePalette::lightness).thenComparingInt(Integer::intValue)).toList();
|
||||
}
|
||||
public static double lightness(int color){return StatueVoxelizer.colorSpace(color)[0];}
|
||||
public VoxelSculpture apply(VoxelSculpture source){
|
||||
if(source==null)return null;
|
||||
var shades=new HashMap<Integer,Double>();for(var cell:source.cells())shades.computeIfAbsent(cell.color(),SculpturePalette::lightness);
|
||||
double min=shades.values().stream().mapToDouble(Double::doubleValue).min().orElse(0),max=shades.values().stream().mapToDouble(Double::doubleValue).max().orElse(0);
|
||||
var matches=new HashMap<Integer,Integer>();
|
||||
shades.forEach((color,l)->{double fraction=max-min<.00001?.5:(l-min)/(max-min);matches.put(color,colors.get(Math.clamp((int)Math.round(fraction*(colors.size()-1)),0,colors.size()-1)));});
|
||||
return new VoxelSculpture(source.modelId(),source.name(),source.width(),source.height(),source.depth(),source.cells().stream()
|
||||
.map(c->new VoxelSculpture.Cell(c.x(),c.y(),c.z(),matches.get(c.color()))).toList());
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ public final class Statuary {
|
||||
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.FUNCTIONAL_BLOCKS).register(out->out.accept(WORKSHOP_ITEM));
|
||||
PayloadTypeRegistry.serverboundPlay().register(ClayWorkshopMenu.Select.TYPE,ClayWorkshopMenu.Select.CODEC);
|
||||
ServerPlayNetworking.registerGlobalReceiver(ClayWorkshopMenu.Select.TYPE,(packet,c)->c.server().execute(()->{
|
||||
if(c.player().containerMenu instanceof ClayWorkshopMenu menu&&menu.containerId==packet.menu()&&menu.view.token().equals(packet.token()))menu.select(c.player(),packet.sculpture());
|
||||
if(c.player().containerMenu instanceof ClayWorkshopMenu menu&&menu.containerId==packet.menu()&&menu.view.token().equals(packet.token()))menu.select(c.player(),packet.sculpture(),packet.material(),packet.palette());
|
||||
}));
|
||||
}
|
||||
public static boolean allowed(Player p,Level l,BlockPos pos){
|
||||
|
||||
@@ -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.",
|
||||
@@ -2150,23 +2151,23 @@
|
||||
"sanctuary.weather.season.summer": "Summer",
|
||||
"sanctuary.weather.season.autumn": "Autumn",
|
||||
"sanctuary.weather.season.winter": "Winter",
|
||||
"block.sanctuary.statuary": "Clay Sculpture",
|
||||
"block.sanctuary.statuary": "Sculpture",
|
||||
"block.sanctuary.clay_workshop": "Clay workshop",
|
||||
"sanctuary.plans.imported_models": "My 3D models",
|
||||
"sanctuary.plans.native_models": "Discovered creatures",
|
||||
"sanctuary.plans.imported_model": "3D model → block statue",
|
||||
"sanctuary.plans.model_files_help": "GLB models in schematics/sanctuary/, alongside your schematics. Create a full-block statue here. Craft 16³ miniatures at the clay workshop.",
|
||||
"sanctuary.plans.empty_models": "Place a .glb file in the shared folder, then refresh.",
|
||||
"sanctuary.statuary.choose_model": "Choose a GLB model, then add clay from your hotbar.",
|
||||
"sanctuary.statuary.choose_model": "Choose a GLB model, then a material block from your hotbar.",
|
||||
"sanctuary.statuary.folder": "Shared folder",
|
||||
"sanctuary.statuary.refresh": "Refresh",
|
||||
"sanctuary.statuary.models": "GLB models",
|
||||
"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 sculpture. Plain: model colors. Colored: solid tint.",
|
||||
"sanctuary.statuary.ready": "1 block = 1 sculpture. Shades come from your material’s texture.",
|
||||
"sanctuary.statuary.empty": "No .glb files",
|
||||
"sanctuary.statuary.input": "Clay",
|
||||
"sanctuary.statuary.input": "Block",
|
||||
"sanctuary.statuary.output": "Sculpture",
|
||||
"sanctuary.statuary.model_name": "Model: %s",
|
||||
"sanctuary.statuary.error.file": "Cannot read this local file.",
|
||||
@@ -2192,5 +2193,17 @@
|
||||
"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.",
|
||||
"sanctuary.statuary.choose_material": "Add a block to apply its palette to the model. Containers must be empty.",
|
||||
"sanctuary.statuary.material_ready": "1 block = 1 sculpture. Palette: %s."
|
||||
}
|
||||
|
||||
@@ -1013,6 +1013,7 @@
|
||||
"sanctuary.companion.rename_hint": "Renomme son œuf à l’enclume, 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": "L’animal s’affole 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.",
|
||||
@@ -2150,23 +2151,23 @@
|
||||
"sanctuary.weather.season.summer": "Été",
|
||||
"sanctuary.weather.season.autumn": "Automne",
|
||||
"sanctuary.weather.season.winter": "Hiver",
|
||||
"block.sanctuary.statuary": "Sculpture d’argile",
|
||||
"block.sanctuary.statuary": "Sculpture",
|
||||
"block.sanctuary.clay_workshop": "Atelier d’argile",
|
||||
"sanctuary.plans.imported_models": "Mes modèles 3D",
|
||||
"sanctuary.plans.native_models": "Créatures découvertes",
|
||||
"sanctuary.plans.imported_model": "Modèle 3D → statue en blocs",
|
||||
"sanctuary.plans.model_files_help": "Modèles GLB dans schematics/sanctuary/, avec vos schémas. Ici, créez une grande statue en blocs. Les miniatures 16³ se fabriquent à l’atelier d’argile.",
|
||||
"sanctuary.plans.empty_models": "Déposez un fichier .glb dans le dossier commun, puis actualisez.",
|
||||
"sanctuary.statuary.choose_model": "Choisissez un modèle GLB, puis déposez de l’argile depuis votre barre rapide.",
|
||||
"sanctuary.statuary.choose_model": "Choisissez un modèle GLB, puis un bloc de matière dans votre barre rapide.",
|
||||
"sanctuary.statuary.folder": "Dossier commun",
|
||||
"sanctuary.statuary.refresh": "Actualiser",
|
||||
"sanctuary.statuary.models": "Modèles GLB",
|
||||
"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 sculpture. Normale : couleurs du modèle. Colorée : teinte unie.",
|
||||
"sanctuary.statuary.ready": "1 bloc = 1 sculpture. Les nuances viennent de la texture de votre matière.",
|
||||
"sanctuary.statuary.empty": "Aucun fichier .glb",
|
||||
"sanctuary.statuary.input": "Argile",
|
||||
"sanctuary.statuary.input": "Bloc",
|
||||
"sanctuary.statuary.output": "Sculpture",
|
||||
"sanctuary.statuary.model_name": "Modèle : %s",
|
||||
"sanctuary.statuary.error.file": "Impossible de lire ce fichier local.",
|
||||
@@ -2192,5 +2193,17 @@
|
||||
"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.",
|
||||
"sanctuary.statuary.choose_material": "Ajoutez un bloc pour appliquer sa palette au modèle. Les conteneurs doivent être vides.",
|
||||
"sanctuary.statuary.material_ready": "1 bloc = 1 sculpture. Palette : %s."
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "beta.113"
|
||||
version = "beta.116"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
Reference in New Issue
Block a user