Compare commits

...
Author SHA1 Message Date
koka 72f1cf50ae docs: record completed beta.289 delivery
Build Sanctuary / build (push) Waiting to run
2026-10-26 01:35:04 +01:00
koka 6821f6ee12 Package the verified zombie and light updates for the managed Beta release (beta.289)
Build Sanctuary / build (push) Waiting to run
2026-10-26 01:21:39 +01:00
koka 85d274f2ef Add special infected, shared zombie skins and held light fix (beta.288) 2026-10-26 01:12:06 +01:00
koka 235e3c08b7 Fix distant atlas surveys and add archipelago zoom (beta.279) 2026-10-25 04:10:15 +01:00
161 changed files with 7321 additions and 158 deletions
+3
View File
@@ -2,6 +2,9 @@
Ce dépôt contient le pack Sanctuary et ses mods. Lire `README.md`, puis le ticket concerné.
La vision est dans `docs/vision.md` ; elle décrit aussi des fonctionnalités futures.
Pour développer/livrer le site, packwiz, la Beta dédiée et Prism local, lire
`docs/updates.md`. La compétence personnelle `sanctuary-updates` mémorise les
chemins et accès propres au poste ; ne pas déduire une publication d'un simple push.
- Avant une modification, lire l'état Git et préserver les changements existants.
- Un ticket correspond à un résultat jouable ou vérifiable et à une branche `codex/<sujet>`.
+116
View File
@@ -6,6 +6,122 @@ Créer un monde **Sanctuary** en Petit, Moyen ou Grand, puis activer les ancres
correspondantes. Le module Sanctuary Test n’est pas requis.
[Intégration et vérification du pack beta.224](docs/four-islands-base224.md).
## beta.289 — mise à jour complète
[Livraison des zombies et de la lumière portée](docs/release-beta289.md), avec
JEI intégré au même JAR pour le client et le serveur. Le téléchargement du
site conserve l’instance Prism prête à suivre le canal stable.
**Publiée** : serveur Beta, téléchargement Web et instance Prism personnelle
synchronisés en beta.289, avec sauvegardes et réglages conservés.
[Procédure de développement et de publication](docs/updates.md).
Construction client/serveur, **31 tests serveur** et parcours des zombies et
familiers sous **Vulkan avec Sodium** réussis pour cette livraison.
## beta.288 — correction des lumières portées intégrée
[Le correctif des Dynamic Lights rejoint le pack courant](docs/dynamic-hand-light-beta288.md).
Les torches et lanternes tenues en main éclairent le terrain avec Sodium,
avec ou sans PBR. Les zombies et les neuf tenues de beta.287 sont conservés.
Build, **19 tests serveur** et trois parcours **Vulkan** réussis : éclairage
avec/sans Sodium, puis zombies et familiers avec Sodium.
[Pack local beta.288](build/Sanctuary-beta.288.mrpack).
## beta.287 — tenues de zombies et postures
[Neuf tenues aléatoires et Hunter accroupi](docs/zombie-appearances-beta287.md)
pour le zombie classique et les six infectés. Les PNG fournis par le créateur
sont choisis aléatoirement par individu et gardés au rechargement, y compris
pour les familiers. Tous gardent les bras classiques en avant ; le Hunter
conserve sa marche accroupie.
Les familiers infectés conservent leur modèle adulte miniature à taille variable.
Build, **77 régressions, 19 tests Smoker et 16 tests Hunter** en sessions
séparées, ainsi que le parcours **Vulkan**, réussis. Neuf tenues par type,
bébé classique, 21 combinaisons de familiers, rééquipement stable et marche/bond
du Hunter vérifiés. [Pack local beta.287](build/Sanctuary-beta.287.mrpack).
## beta.286 — Hunter et bond offensif
[Sixième infecté](docs/hunter-beta286.md) : préparation accroupie, bond physique
vers le joueur et frappe unique au contact. Sa trajectoire reste fixe après
le départ : une esquive, un mur ou un plafond peuvent faire rater l’attaque.
T-shirt bleu et pantalon anthracite, œuf créatif et familier adulte miniature
à taille variable, avec son propre bond offensif sur G.
Build, **16 tests Hunter, 19 tests Smoker et 77 régressions**, en sessions séparées,
ainsi que le parcours **Vulkan FR/EN GUI 2/3/4**, réussis. Contact contre un vrai
joueur, esquive au clavier, atterrissage et trois tailles vérifiés.
[Pack local beta.286](build/Sanctuary-beta.286.mrpack).
## beta.285 — Smoker et lasso
[Cinquième infecté](docs/smoker-beta285.md) : un lasso physique qui peut rater.
Il doit toucher sa cible avant de la ramener vers le Smoker à la vitesse des
tyroliennes. Les obstacles sur le corps arrêtent le trajet ; blesser la source
ou couper le crochet libère la victime. Tenue gris clair et vert olive, œuf
créatif et familier adulte miniature à taille variable, avec son propre lasso.
Build, **19 tests Smoker et 77 régressions** en sessions séparées, ainsi que
le parcours **Vulkan FR/EN GUI 2/3/4**, réussis. Esquive au clavier, touche
réelle, traction imposée du joueur et arrivée vérifiées.
[Pack local beta.285](build/Sanctuary-beta.285.mrpack).
## beta.284 — Boomer et bile
[Quatrième infecté](docs/boomer-beta284.md) : gerbe de bile en cône,
diversion temporaire des zombies déjà présents et éclaboussure à la mort,
sans destruction du terrain. Tenue violette, pantalon sable, œuf créatif et
familier adulte miniature à taille variable, avec une gerbe qui affaiblit
les ennemis et une éclaboussure G qui garde le familier vivant.
Build, **68 tests serveur ciblés** et parcours **Vulkan FR/EN GUI 2/3/4**
réussis, avec G, recrutement sauvage, mort et trois tailles vérifiés.
[Pack local beta.284](build/Sanctuary-beta.284.mrpack).
## beta.283 — Spitter et flaque d’acide
[Troisième zombie spécial](docs/spitter-beta283.md) : le crachat dépose une
zone verte persistante au sol, comme une potion persistante. Les dégâts cessent
en sortant ; aucun poison ne suit la victime. Tenue orange, pantalon bleu
ardoise, œuf créatif et familier adulte miniature à taille variable.
Build, **54 tests serveur ciblés** et parcours **Vulkan FR/EN GUI 2/3/4**
réussis, avec G, dégâts dans la zone, sortie et trois tailles vérifiés.
[Pack local beta.283](build/Sanctuary-beta.283.mrpack).
## beta.282 — Chargeur
[Deuxième zombie spécial](docs/charger-beta282.md) : préparation visible, charge
en ligne droite, choc et récupération. Tenue verte, pantalon anthracite, œuf
créatif et familier adulte miniature à taille variable, avec sa propre charge.
Build, **45 tests serveur ciblés** et parcours **Vulkan FR/EN GUI 2/3/4** réussis,
avec appui réel sur G, choc et recharge vérifiés.
[Pack local beta.282](build/Sanctuary-beta.282.mrpack).
## beta.281 — Infectés adultes en miniature
[Les infectés abandonnent la forme bébé](docs/infected-adults-beta281.md).
Le familier reprend les proportions adultes en miniature, avec son trait de
taille variable conservé. La règle est commune aux futures variantes d'infectés.
Build, **34 tests serveur ciblés** et parcours **Vulkan FR/EN GUI 2/3/4** réussis,
avec trois tailles réelles de miniature vérifiées.
[Pack local beta.281](build/Sanctuary-beta.281.mrpack).
## beta.280 — Premier infecté
[Un nouveau zombie et son familier](docs/infected-beta280.md) : modèle vanilla,
t-shirt rouge, pantalon brun, 10 PV et vitesse légèrement supérieure au zombie.
Œuf créatif `sanctuary:infected_spawn_egg`, apparitions peu fréquentes dans les
habitats de zombies de l'Overworld et familier bébé avec son profil de combat.
Texture vanilla recolorée pixel par pixel, sans déplacement UV. Build,
**33 tests serveur ciblés** et parcours **Vulkan FR/EN GUI 2/3/4** réussis.
[Pack local beta.280](build/Sanctuary-beta.280.mrpack).
## beta.279 — Carte des îles et grand dézoom
[Vue d'ensemble de l'archipel et relevés prolongés](docs/map-islands-zoom-beta279.md).
La carte dézoome jusqu'à l'échelle du monde avec des niveaux de détail adaptés,
et reçoit les nouveaux chunks lointains en gardant son centre fixe. Capacité
portée de 16 384 à **131 072 chunks par couche et habitant**, sans effacer les
relevés existants. Les propriétés et Demeure retrouvent leurs détails en zoomant.
Build, **20 tests serveur ciblés** et parcours **Vulkan FR/EN GUI 2/3/4** réussis.
[Pack local beta.279](build/Sanctuary-beta.279.mrpack).
## beta.278 — Disputer une propriété dans Battle
[Duel consenti pour un claim déjà revendiqué](docs/claim-battle-beta278.md).
+115 -4
View File
@@ -1,5 +1,17 @@
# Provenance et crédits
## Tenues des zombies beta.287
Les neuf PNG `zombie.png`, `zombie2.png` à `zombie9.png` sont fournis par le
créateur le 26 octobre 2026. Ils sont copiés sans modification sous
`assets/sanctuary/textures/entity/zombies/zombie1.png` à `zombie9.png`.
Le [reçu des sources et dérivés](docs/zombie-skins-beta287.json) conserve
leurs SHA-256. `scripts/prepare_zombie_skins.py` produit aussi neuf adaptations
UV pour le modèle bébé natif de Minecraft 26.3 par rééchantillonnage déterministe
des faces ; aucun générateur d’image n’intervient. La géométrie et les couches
d’armure restent issues de Minecraft. Les notices historiques des anciennes
recolorations ci-dessous décrivent les livraisons précédentes.
Depuis beta.256, le pack client inclut [Sodium 0.9.2+mc26.3](https://modrinth.com/mod/sodium/version/bAZQdGpg),
de JellySquid et des contributeurs CaffeineMC, sous Polyform-Shield-1.0.0.
Le JAR officiel est une dépendance séparée téléchargée par packwiz ; il n’est
@@ -106,15 +118,52 @@ L’icône d’objet correspondante provient de `../26.2/sanctuary`
restent la propriété de leurs auteurs et ne sont pas placées sous la licence
du code Sanctuary. Il ne s’agit pas d’une cape officielle accordée au compte.
Les familiers utilisent les modèles et textures résolus par Minecraft installé ;
aucune copie des textures de mobs ni aucun ancien pouvoir n’est embarqué.
Les familiers vanilla utilisent les modèles et textures résolus par Minecraft
installé. L'exception du premier infecté est décrite ci-dessous.
## Infecté beta.280
Le modèle, les animations, l'armure et les sons sont ceux du zombie installé
avec Minecraft 26.3. Aucun modèle ou son de Left 4 Dead n'est inclus ; cette
série reprend seulement l'idée de rôles différents pour des zombies.
La texture `assets/sanctuary/textures/entity/infected.png` est une recoloration
déterministe du zombie **Minecraft 26.3**, réalisée par
`scripts/generate_infected_texture.py` à partir du JAR installé. Seules les
couleurs du t-shirt et du pantalon changent ; coordonnées UV, peau, visage,
chaussures et transparence restent identiques. Empreinte SHA-256 du PNG natif :
`1b88eea9b63889c2c3399773f887af375defc3aad0e7a375adc84249da54cbbd`.
Aucune texture produite par ImageGen n'est livrée.
L'icône `textures/item/infected_spawn_egg.png` est reprise intacte de Sanctuary
26.2, commit `26f2c2a0061619df1f146f9081d6740c560a7497`, module `itsalive`.
Elle provenait d'une recoloration de l'œuf Minecraft par
`itsalive/tools/generate_infected_textures.sh`. Ces ressources dérivées ne sont
pas couvertes par la licence GPL du code ; les droits sur les ressources
Minecraft restent à Mojang/Microsoft.
## Chargeur beta.282
`assets/sanctuary/textures/entity/charger.png` utilise le même PNG natif du
zombie Minecraft 26.3 et les mêmes masques UV que l'Infecté. La sélection
`--variant charger` de `scripts/generate_infected_texture.py` produit un
t-shirt vert et un pantalon anthracite, avec peau, visage, chaussures et alpha
conservés pixel pour pixel. Aucun dessin génératif ni modèle de Left 4 Dead.
SHA-256 : `cf2344d870753ed621b9fd7c3fd7dfe0b3dc35294620e798f113b71abd27fbda`.
L'icône `textures/item/charger_spawn_egg.png` est reprise intacte du module
`itsalive` de Sanctuary 26.2, commit
`26f2c2a0061619df1f146f9081d6740c560a7497`, générée à partir de l'œuf Minecraft.
Les droits sur les ressources Minecraft restent à Mojang/Microsoft.
## JEI beta.024
Just Enough Items, mezz, MIT : fork source de la branche 26.2, commit
`aae2dfcfb82e6b5bce787ba72bb2eda7339b274f`, adapté à Minecraft 26.3-pre-2.
Depuis beta.257, le fork Minecraft 26.3 `30.32.0-sanctuary.4` est un JAR client
séparé et facultatif, avec la licence d’origine et [la provenance du portage](mods/jei/PROVENANCE.md).
Depuis beta.280, le fork Minecraft 26.3 `30.32.0-sanctuary.4` est à nouveau
embarqué dans le JAR Sanctuary, avec la licence d’origine et
[la provenance du portage](mods/jei/PROVENANCE.md). Sa distribution séparée
facultative concernait les beta.257–278.
Les patches et la reconstruction sont fournis ; les téléchargements restent ignorés.
Baked Substring Index 0.1.0 (MIT, James Mitchell) et Suffix Tree 1.1.0
(Apache-2.0, Alessandro Bahgat Shehata) sont inclus avec leurs licences.
@@ -212,3 +261,65 @@ leurs aperçus publics de haute qualité ; la cigale du soir utilise la
conversion MP3 publique de Commons. Aucun enregistrement d’Animal Crossing
n’est inclus. L’identification biologique du grillon reste non précisée ;
la courtilière européenne sert d’analogue sonore.
## Spitter beta.283
`assets/sanctuary/textures/entity/spitter.png` est une recoloration déterministe
du zombie Minecraft 26.3 via `scripts/generate_infected_texture.py --variant spitter`.
Seuls le t-shirt orange et le pantalon bleu ardoise changent. Peau, visage,
chaussures, alpha et UV restent identiques au PNG natif décrit ci-dessus.
SHA-256 : `92f6a8f7ad09a817fc74bfc2728a5f75d34a838dcc91a2ddee336c31803c0592`.
Aucun modèle, son ou dessin génératif de Left 4 Dead n'est inclus.
L'icône `textures/item/spitter_spawn_egg.png` est reprise intacte du module
`itsalive` de Sanctuary 26.2, commit
`26f2c2a0061619df1f146f9081d6740c560a7497`, dérivée de l'œuf Minecraft.
Les droits sur les ressources Minecraft restent à Mojang/Microsoft.
## Boomer beta.284
`assets/sanctuary/textures/entity/boomer.png` est une recoloration déterministe
du zombie Minecraft 26.3 par `scripts/generate_infected_texture.py --variant boomer`.
T-shirt violet et pantalon sable : 570 pixels de vêtements changent, avec
visage, peau, chaussures, UV et transparence conservés. SHA-256 :
`2cbc0a87435c8f12fbbd77bd3d7fc23c9843e9ae22493dbfb6ffe14ea492c230`.
Le même script recolore en violet la silhouette et les ombres de l'icône
`spitter_spawn_egg.png` historique, décrite ci-dessus, pour produire
`textures/item/boomer_spawn_egg.png`. SHA-256 :
`dd11449f7072db2faf753e5d258acfe8e5d2c50116e076d9b799345ed5f62ffa`.
Les droits sur ces ressources dérivées restent à Mojang/Microsoft. Aucun
modèle, son ou bitmap de Left 4 Dead, ni texture générative, n'est livré.
## Smoker beta.285
`assets/sanctuary/textures/entity/smoker.png` est une recoloration déterministe
du zombie Minecraft 26.3 par `scripts/generate_infected_texture.py --variant smoker`.
T-shirt gris clair et pantalon vert olive : 570 pixels de vêtements modifiés ;
visage, peau, chaussures, UV et alpha conservés. SHA-256 :
`80b7b34c915466d2c022f2222bd5bedf5b5b6c39ab447a97dcae7edf66ba4d17`.
Le même script dérive `textures/item/smoker_spawn_egg.png` de la silhouette et
des ombres de l'icône historique `spitter_spawn_egg.png`, avec la teinte du
t-shirt. SHA-256 :
`e73b7d1e08ef0f35bfc7bac3e81795b166b56d9a5267045bcef64ae311c256c6`.
La tête du lasso utilise l'objet laisse natif et son câble le rendu natif des
laisses tendues déjà employé par les tyroliennes. Aucun modèle, son ou bitmap
de Left 4 Dead ni texture générative n'est inclus. Les droits sur les ressources
Minecraft dérivées restent à Mojang/Microsoft.
## Hunter beta.286
`assets/sanctuary/textures/entity/hunter.png` est une recoloration déterministe
du zombie Minecraft 26.3 par `scripts/generate_infected_texture.py --variant hunter`.
T-shirt bleu et pantalon anthracite : 570 pixels de vêtements modifiés ; tête,
peau, chaussures, UV et alpha conservés. SHA-256 :
`1057efc7947105684a24fe165e83581b66fccc877687433cc00ed80231c2c970`.
Le même script dérive `textures/item/hunter_spawn_egg.png` de la silhouette et
des ombres de l'icône historique `spitter_spawn_egg.png`, avec la teinte du
t-shirt. SHA-256 :
`294bbd145e09707f2006d51e9190b538b50148c44c13f3ecaf563e316f03384d`.
Les sons et le modèle sont natifs à Minecraft. Aucun bitmap ou son de Left 4
Dead ni texture générative n'est inclus. Les droits sur les ressources
Minecraft dérivées restent à Mojang/Microsoft.
+19
View File
@@ -15,6 +15,18 @@ allprojects {
tasks.named('build') { dependsOn(':sanctuary:build', ':jei:build', ':sanctuary-test:build') }
tasks.named('check') { dependsOn(':sanctuary:check', ':jei:check', ':sanctuary-test:check', 'verifyPack') }
tasks.register('verifyServerUpdater', Exec) {
group = 'verification'
commandLine('python3', 'scripts/test_server_runtime.py')
}
tasks.named('check') { dependsOn('verifyServerUpdater') }
tasks.register('verifyWebDistribution', Exec) {
group = 'verification'
commandLine('python3', 'scripts/test_publish_web_pack.py')
}
tasks.named('check') { dependsOn('verifyWebDistribution') }
tasks.register('verifyPack', Exec) {
group = 'verification'
description = 'Verify pinned pack dependencies, versions and index hashes.'
@@ -28,6 +40,13 @@ tasks.register('assemblePack', Exec) {
commandLine('python3', 'scripts/pack.py', 'assemble')
}
tasks.register('assembleServerPack', Exec) {
group = 'distribution'
description = 'Build the Linux server kit and verify its packwiz profile.'
dependsOn('assemblePack')
commandLine('python3', 'scripts/server_pack.py')
}
tasks.register('verifyResourcePack', Exec) {
group = 'verification'
description = 'Verify the versioned Sanctuary texture sources and their release hashes.'
+116
View File
@@ -0,0 +1,116 @@
# DIST-WEB — téléchargement direct d'une instance Prism prête
Branche `codex/beta-download`, serveur dédié CT 350 sur `gpu`. Cette livraison
conserve les binaires immuables beta.280 et ne modifie ni monde ni format de
sauvegarde. L'Alpha reste hors cible.
## Résultat pour le joueur
Le bouton **Télécharger** de [Sanctuary Web](https://sanctuary-minecraft.net)
donne directement le fichier **Sanctuary-Beta.zip** :
```text
https://sanctuary-minecraft.net/downloads/sanctuary-prism.zip
```
Le joueur importe ce ZIP dans Prism (**Ajouter une instance → Importer depuis
un ZIP**), puis lance **Sanctuary Beta**. Packwiz télécharge le pack complet au
premier lancement et suit le même canal aux lancements suivants. La commande
avant lancement et le bootstrap sont déjà inclus ; aucune configuration manuelle
ou téléchargement séparé d'outil n'est demandé.
Le format ZIP d'instance Prism conserve ces réglages, contrairement à un MRpack
standard. Le MRpack beta.280 reste accessible par son ancienne adresse pour
compatibilité des liens, sans devenir un deuxième bouton du site. Le parcours
manuel de configuration et son lien dans le pied de page sont retirés.
L'archive légère contient uniquement `instance.cfg`, `mmc-pack.json`, l'icône et
`minecraft/packwiz-installer-bootstrap.jar`. Elle démarre avec zéro mod local.
L'instance est nommée **Sanctuary Beta**, avec `ManagedPack=false`,
`OverrideCommands=true`, la sélection automatique de Java et le canal stable :
```text
https://git.botsu.net/koka/sanctuary-beta/raw/branch/packwiz/pack.toml
```
Le nom du bouton et le téléchargement direct sont conservés. Le pied de page et
le survol indiquent la version réellement publiée. Les réponses de téléchargement
utilisent `Cache-Control: no-store` pour garder la même adresse à chaque livraison.
## Publication demandée via Codex
Aucune tâche périodique ne décide de publier une version. À la demande du créateur :
1. Vérifier et publier les nouveaux binaires immuables, puis le canal packwiz
selon [le protocole de distribution](packwiz.md).
2. Générer l'instance prête avec `python3 scripts/pack.py prism <url-du-canal>`.
Joindre `build/Sanctuary-Prism-beta.xxx.zip` à la release, avec le MRpack et
le kit serveur. L'export Prism est reproductible et conserve ses commandes.
3. Mettre à jour la CT dédiée selon [le contrat serveur](server-pack.md), en
conservant sauvegardes, réglages et secrets, puis vérifier son redémarrage.
4. Publier le téléchargement Web après contrôle de cette CT et des deux archives :
```sh
python3 scripts/publish_web_pack.py \
--web-ct 320 --server-ct 350 --version beta.280 \
--mrpack-url https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.280/Sanctuary-beta.280.mrpack \
--prism-url https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.280/Sanctuary-Prism-beta.280.zip \
--pack-url https://git.botsu.net/koka/sanctuary-beta/raw/branch/packwiz/pack.toml
```
Cette dernière commande s'exécute sur `gpu`, où `pct` est disponible. `--dry-run`
vérifie sans publier. Le script contrôle les index, les versions, les hashes des
dépendances et le JAR Sanctuary de la CT, du canal et du MRpack. Il refuse une
archive Prism sans commande active, visant un autre canal ou une autre plateforme,
avec un bootstrap non vérifié ou des fichiers étrangers.
Les fichiers Web restent dans
`/var/www/sanctuary/storage/app/distribution/releases/beta.xxx/prism/`.
Chaque publication est immuable. Sous verrou, `current` bascule atomiquement après
écriture complète. Les anciennes versions restent disponibles pour une reprise.
Le script ne redémarre aucun serveur et ne change pas le canal lui-même.
## Vérifications
Huit tests Python couvrent l'alignement CT/canal/MRpack, le refus d'un serveur
arrêté ou divergent, le contrat d'import Prism prêt, les fichiers interdits et la
publication atomique avec conservation de la précédente en cas de conflit.
Ils font partie de `./gradlew check`.
Les quatre tests de téléchargement et les trois tests des métriques Laravel
passent, ainsi que Pint : le bouton sert un ZIP anonyme directement, le nom est
`Sanctuary-Beta.zip`, les publications incomplètes restent indisponibles, et le
changement de version ne demande pas de reconstruire le cache de configuration.
Le MRpack reste téléchargeable par son ancien lien.
Les tests natifs utilisent une extraction de l'archive prête dans un chemin
contenant des espaces, avec les variables de sa propre commande avant lancement.
Ils exécutent packwiz sans interface ; aucun Minecraft ni monde personnel n'est
ouvert. Les fichiers témoins de réglages, favoris et sauvegardes sont comparés
avant et après synchronisation. Les journaux sont conservés sous
`build/prism-ready-validation*/`, hors de Git.
`./gradlew check build assemblePack -PsanctuaryFocusedTests=recipes,progression
-PsanctuaryAtlasOnly=true` réussit. L'archive prête mesure **92 417 octets**, SHA-256
`30dd37f3a6e1d79f8b443f6312918258276c85d5b6a5e7fbdc9c3fea7562dbf2`.
Une première tentative native reçoit des HTTP 502 de Gitea ; une nouvelle
extraction entièrement vide réussit ensuite ses deux lancements de synchronisation.
Le reçu est `build/prism-ready-validation-retry/validation.json`.
L'archive est ajoutée à la release beta.280 sans changer son tag ni ses artefacts
existants. La CT Web publie ensuite le ZIP et le MRpack vérifiés dans la même
bascule atomique. Le code Web est livré au commit
`46d784ecbb8ba3d0f20199b85bcf486831ba3750` ; les changements préexistants sont
conservés et les empreintes des comptes et permissions restent identiques.
Sauvegarde privée :
`/root/sanctuary-beta-maintenance/web-prism-ready-20261025-1792943502/`.
Le parcours navigateur **accueil → Télécharger → Sanctuary-Beta.zip reçu** est
vérifié à 1280 × 900 et 390 × 844 : HTTP 200 direct, pas de changement de page,
nom et SHA-256 identiques à l'archive testée, aucune erreur d'application ni
débordement horizontal. Le lien de guide manuel est absent. Le MRpack historique
répond toujours HTTP 200. Le navigateur utilisé est Playwright, le plugin
Browser étant absent ; reçu et captures sous `/tmp/sanctuary-prism-ready-*`.
Reçus opérationnels ignorés : `build/prism-ready-publication.json`,
`build/prism-ready-web-publication.json`, `build/prism-ready-web-deployment.log`
et `build/prism-ready-build.log`.
+85
View File
@@ -0,0 +1,85 @@
# ZOMBIES-284 — Boomer et bile
Branche `codex/boomer`, sur le socle local beta.283 conservé. État : implémenté et vérifié localement en beta.284.
## Contrat
Quatrième infecté : modèle adulte du zombie vanilla 26.3, t-shirt violet et
pantalon sable recolorés pixel par pixel, visage/peau/UV/alpha conservés.
Identifiants `sanctuary:boomer` et `sanctuary:boomer_spawn_egg`.
Aucun bébé, y compris via œuf ou chargement ; pas de conversion en noyé.
- Sauvage : 10 PV, vitesse 0,20, mêlée 2, poursuite 32 blocs. À courte distance,
prépare une gerbe pendant 20 ticks, puis vomit dans un cône de 5 blocs,
orienté au début de la préparation. Recharge de 120 ticks. La bile révèle
pendant 6 secondes et affaiblit pendant 3 secondes ; elle ne crée pas d'acide.
- Une cible enduite attire jusqu'à six zombies déjà présents à 12 blocs, avec
ligne de vue et chunks chargés, pendant six secondes au maximum. Pas de
nouvelles créatures, de chargement distant ou d'horde générée. La diversion
expire proprement et conserve la cible précédente si elle est encore valide.
Retirer la révélation arrête aussi la diversion ; maximum 64 zombies détournés
simultanément par serveur. Les projections touchent au plus 12 victimes.
- À sa mort, éclaboussure unique de 3 blocs, 2 dégâts et bile, sans destruction
de blocs ni feu. Une suppression administrative n'active pas l'éclaboussure.
Les infectés de la famille et les joueurs créatifs/spectateurs sont protégés.
Les murs et la différence de hauteur bloquent les projections.
- Œuf créatif et apparition rare, poids 4 et groupe d'un dans les habitats
de zombies de l'Overworld.
- Familier rare, adulte miniature à taille variable persistante, 14 PV et
vitesse 0,25. Gerbe automatique en cône et G d'éclaboussure à 3 blocs :
dégâts, faiblesse et révélation passent par les règles communes des familiers,
avec protection du propriétaire/alliés/duels. Le familier reste vivant et
ne détourne pas de monstres contre des tiers.
- Recharge du Boomer conservée au chargement ; préparation interrompue annulée.
Les diversions sont transitoires ; aucun format existant ne change.
## Vérification
- Suite serveur ciblée : **68 tests réussis**, dont **14 nouveaux**. Fixtures
plates explicites, graine de test `0`, aucun monde personnel ouvert.
- Couverture Boomer : œuf/profil/spawn, règle adulte, préparation et vraie gerbe,
esquive latérale, mur et étage, recharge après interruption et sur le tick
exact du tir, sauvegarde/rechargement, mort native unique, suppression sans
éclaboussure, famille protégée, six zombies maximum sans nouvelle entité,
navigation/attaque native et restauration de la cible précédente après délai,
absence de chargement distant, G et cône du familier avec protections communes,
recharge partagée et tailles 120/500/1000/1500/3000/6000 conservant l'individu.
- Parcours client natif **Vulkan / MoltenVK 1.4.2 / Apple M1**, Minecraft 26.3,
Java 25, Loader 0.19.5, monde de test neuf de graine `284` : réussi avec arrêt
normal du client. Appui réel sur G, dégâts/faiblesse/révélation, recharge et
survie du familier ; gerbe sauvage, recrutement d'un zombie existant et
éclaboussure après vrai dégât mortel. Trois tailles réelles 500/1000/1500,
renderer adulte, œuf, infobulles et écran FR/EN aux échelles GUI 2/3/4 vérifiés.
- La première vérification générale a rencontré le test préexistant
`Companion019GameTests.creatureProfilesActuallyMove` (déplacement aérien).
Les contrôles Boomer passaient ; la suite de 68 a ensuite repassé avec ce
test inchangé. Journal conservé dans
`build/boomer284-check-build-pack-attempt1.log`.
- `./gradlew check build assemblePack` réussi en **2 min 40 s**, avec
`-PsanctuaryFocusedTests=boomer284,spitter283,charger282,infected280,familiar260,companions`
et `-PsanctuaryAtlasOnly=true`. La suite complète ciblée repasse ses 68 tests.
- PNG du Boomer comparé en RGBA à la recoloration attendue du zombie natif
26.3 épinglé : **331 pixels de t-shirt et 239 de pantalon** modifiés,
tête, peau, chaussures, alpha et coordonnées UV conservés. Les textures des
trois infectés précédents restent identiques. L'œuf est une recoloration
violette de la silhouette et des ombres de l'icône historique, sans changement
d'alpha ; aucun outil de génération d'images utilisé.
- JAR et export MRpack : ZIP intègres, version beta.284 et dépendances
Minecraft 26.3 / Loader 0.19.5 contrôlées. Classes, œuf, texture, profil,
loot, traductions et tags Boomer présents ; classes de tests exclues.
Le JAR exporté est identique au JAR compilé. Pack beta.283 précédent conservé
octet pour octet.
[Pack local beta.284](../build/Sanctuary-beta.284.mrpack), **16 650 758 octets**.
SHA-256 : `4a27ce2b184c8797f0ed7891c6dc62d1db372e2fdabb4fd5de98389df16f25a3`.
[Reçu et empreintes](../build/boomer284-validation.json).
![Adulte et familier miniature sous Vulkan](../build/boomer284-screenshots/0002_boomer284-miniature-1000.png)
La livraison reste locale : aucun tag/release, canal packwiz, serveur personnel
ou instance Prism modifié. Les œufs restent créatifs ; leur acquisition en
survie et les autres infectés demandent leurs propres tickets. Aucune génération
de terrain ni sauvegarde personnelle modifiée.
+112
View File
@@ -0,0 +1,112 @@
# ZOMBIES-282 — Chargeur
Branche `codex/charger`, sur le socle local beta.281 conservé. État : livré et vérifié localement.
## Contrat
- `sanctuary:charger`, œuf `sanctuary:charger_spawn_egg`, noms FR/EN. Modèle
zombie adulte uniquement, tenue verte et pantalon anthracite ; texture native
26.3 recolorée par script, visage, peau, chaussures et UV conservés.
- Attributs de base : 10 PV, vitesse 0,27, mêlée 3, poursuite 36 blocs.
Charge depuis 4 à 15 blocs avec ligne de vue et sol sous les pieds :
préparation visible de 22 ticks, direction figée au départ, course de
24 ticks au maximum, choc de 4 dégâts avec recul, récupération de 48 ticks,
puis recharge de 100 ticks. Mêlée suspendue pendant ces trois phases.
- La collision est celle du déplacement réel : pas de frappe à distance ou
à travers un mur. Un joueur peut esquiver en sortant de la trajectoire.
Obstacles, cible invalide ou bord d'une zone chargée terminent la charge.
Aucune destruction de bloc, aucune téléportation ou génération forcée.
- Spawn peu fréquent dans les habitats de zombies de l'Overworld, poids 4,
un seul individu. Soleil, statut mort-vivant et absence de conversion en
noyé comme l'Infecté. Les autres variantes restent à réaliser.
- Familier adulte miniature, taille persistante variable, profil rare dédié :
16 PV, vitesse 0,28, mêlée ; technique G de charge droite jusqu'à 8 blocs,
préparation 22 ticks, récupération 24 ticks, recharge 12 secondes, dégâts 4.
Variante apprise : ruée droite courte de 5 blocs. Droits, alliés, limites de
dégâts et de contrôle restent ceux des familiers existants. Une charge
déclenchée consomme sa recharge même si elle manque sa cible.
- Œuf disponible en créatif ; pas de nouvelle acquisition en survie.
## Référence et sauvegardes
Référence en lecture seule : `../26.2/itsalive`, commit
`26f2c2a0061619df1f146f9081d6740c560a7497`, variante `CHARGER` et
`tickCharger`. Les phases de l'ancien comportement servent de référence ; le
contrôle des coups repose ici sur la trajectoire réellement parcourue.
Le nouveau type a sa propre persistance native. Pour la charge, seule la recharge est sauvée ;
une phase interrompue reprend au repos avec une recharge à la relecture.
Les identifiants, formats, individus, œufs et textures déjà livrés restent
stables. Aucun monde personnel ouvert ; aucun chunk ou terrain réécrit.
## Validation
Les essais serveur ciblés ont validé les phases du mob avec déplacement natif,
le choc et son recul, la récupération sans double mêlée, l'esquive latérale,
un mur ajouté après le départ, la persistance d'une charge interrompue, le
profil et l'œuf, les dégâts du familier, sa trajectoire et sa recharge sur
échec. Deux contrôles couvrent aussi une téléportation interrompant la course
et les commandes d'une monture pendant la préparation. La sonde du corridor
complet vérifie les chunks avant toute collision ; elle ne les charge pas.
La construction finale **`check build assemblePack` passe en 2 min 59 s**,
avec les **45 cas ciblés**, dont onze nouveaux pour le Chargeur, et les contrôles
purs du dépôt. Log : `build/charger282-check-build-pack.log`.
Le parcours client **Vulkan / MoltenVK 1.4.2 / Apple M1** passe en **1 min 03 s** :
vrai appui sur G, déplacement et choc du familier, recharge et récupération,
phases préparation/course/choc d'un Chargeur sauvage, géométrie adulte,
tailles 500/1 000/1 500 ‰, rareté, infobulles et menus **FR/EN GUI 2/3/4**.
Douze captures sont conservées sous `build/charger282-screenshots/` ; vues de
la miniature ordinaire, de la préparation et du menu FR GUI 2 inspectées.
Log : `build/charger282-client-vulkan.log`, marqueur `CHARGER282_CLIENT_PASS`.
La comparaison des PNG confirme **570 pixels de vêtements recolorés**, avec
558 pixels de peau/visage et tous les alpha intacts. La texture de l'Infecté
rouge conserve exactement son empreinte beta.281. Génération reproductible :
```sh
python3 scripts/generate_infected_texture.py /chemin/minecraft-26.3-client.jar --variant charger
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=charger282,infected280,familiar260,companions \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryCharger282ClientTests=true -PsanctuaryQuickTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
Un contrôle historique de déplacement du dragon a échoué sur un passage
intermédiaire, puis passé sans changement sur le suivant ; sa stabilité n'est
pas démontrée par ce ticket. Un premier parcours client a passé ses étapes
avant une erreur fastutil dans `ChunkMap` à la fermeture du serveur de test.
La fixture nettoie désormais ses acteurs et attend leur retrait ; le parcours
final se termine normalement.
## Essai local
```mcfunction
/give @s sanctuary:charger_spawn_egg
/summon sanctuary:charger
```
L'œuf équipé invoque le familier miniature. G lance la charge ; les ordres et
la sélection d'aptitudes suivent les contrôles habituels. Pendant une charge
montée, préparation et récupération comprises, le pilote ne peut pas détourner
la trajectoire avec les commandes de déplacement. Les autres profils utilisant
`CHARGE` gardent leur comportement ; seul l'effet de profil `straight` utilise
ce nouveau contrôleur.
La fréquence des rencontres naturelles en partie longue et le comportement
sur toutes les formes de relief ne sont pas mesurés. Aucun monde personnel,
serveur, canal packwiz ou instance Prism modifié.
## Artefacts locaux
- Mod : `mods/sanctuary/build/libs/sanctuary-beta.282.jar`.
- Pack : `build/Sanctuary-beta.282.mrpack`.
- Captures : `build/charger282-screenshots/`.
- Reçu : `build/charger282-validation.json`.
JAR et MRpack vérifiés : version exacte, intégrité ZIP, Minecraft 26.3, Fabric
Loader 0.19.5, un seul JAR Sanctuary identique au build, profils et contrôleurs
de charge présents, deux textures identiques aux sources et fixtures exclues.
+88
View File
@@ -0,0 +1,88 @@
# LIGHT-288 — intégrer la correction des lumières portées
Branche `codex/dynamic-light-integration`, socle local beta.287 conservé.
État : intégré, vérifié et construit en pack local beta.288.
## Résultat demandé
Intégrer au pack courant le correctif du commit
`6f44214f2725b4ef2d1002606cb83e70885186ec`, branche `codex/dynamic-hand-light`.
Sa version locale isolée était beta.283 ; la livraison combinée prend beta.288
et conserve les zombies, leurs neuf tenues partagées, les bras natifs et le
Hunter accroupi de beta.287.
Une torche ou une lanterne tenue réagissait dans les normales PBR sans éclairer
le terrain avec Sodium. La collecte des sources était correcte, mais les copies
`LevelSlice` utilisées par les workers Sodium ne recevaient pas l’instantané.
L’interface `DynamicLightContext` et deux mixins optionnels transmettent
l’instantané immuable du contexte préparé côté rendu vers la copie du worker.
Chaque nouveau travail remplace sa capture et `reset` la libère. Les lectures
directes de lumière de bloc dans Sodium utilisent le même champ radial que
le chemin natif, sans modifier le canal du ciel.
Les mixins ne sont sélectionnés que côté client avec Sodium présent. La
version exacte reste `0.9.2+mc26.3`, facultative et active par défaut dans le
pack ; aucune nouvelle dépendance. Les budgets, réglages et chemins natifs
existants restent en vigueur. Aucun monde, identifiant, format de sauvegarde
ou comportement serveur n’est modifié.
## Vérification
Le test existant `HandLight283ClientChecks` est repris sans modification du
commit source. Monde plat neuf, graine `283`, salle en pierre fermée et zone
de pixels du sol sans interface ni objet en main visible. Deux parcours
**Vulkan / MoltenVK 1.4.2, Apple M1** réussis :
| Rendu du pack combiné | Sol sans source | Sol avec torche |
| --- | ---: | ---: |
| Sodium `0.9.2+mc26.3` | 11,785 | 85,366 |
| Natif, Sodium absent | 11,783 | 90,043 |
Ces moyennes de luminance sur 255 sont des mesures de pixels, pas des niveaux
de lumière du serveur. Les deux mains, l’AO activée/désactivée, shaders/PBR
coupés, profil Moyen sans/avec PBR, déplacement et torche jetée sont vérifiés.
Après retrait, le sol retrouve sa luminosité initiale ; le moteur de lumière
du monde reste à zéro. Avec Sodium, le worker retardé conserve son instantané,
la réutilisation le remplace, le ciel reste inchangé et `reset` le libère.
Commandes des deux parcours, en sessions distinctes :
```sh
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryHandLightClientTests=true -PsanctuarySodiumClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryClientGraphicsBackend=vulkan \
-PsanctuaryClientHeapGb=8 -PsanctuaryClientNoVsync=true
```
Le second parcours utilise `-PsanctuarySodiumClientTests=false`. Le parcours
`Zombie287ClientChecks` réussit également avec Sodium activé, Vulkan, graine
`287` : neuf tenues partagées pour les sept types, bébé classique, bras natifs,
21 combinaisons de familiers et marche/bond du Hunter. Logs et captures sous
`build/dynamic288/`.
`./gradlew check build assemblePack -PsanctuaryFocusedTests=lights,hunter286
-PsanctuaryAtlasOnly=true` réussit en **2 min 42 s** : **19 GameTests requis**
pour les lumières et le Hunter, test d’infrastructure compris, ainsi que tous
les contrôles purs configurés par `check`.
L’export packwiz est vérifié : intégrité ZIP, version `beta.288`, Minecraft
`26.3`, Fabric Loader `0.19.5`, un seul JAR Sanctuary identique au binaire
construit. Par rapport au JAR beta.287, seules **cinq entrées** changent :
la version Fabric, le plugin de sélection des mixins et les trois nouvelles
classes du pont Sodium. Toutes les classes et ressources des zombies et
familiers sont strictement identiques. Les tests et Sodium ne sont pas
embarqués dans le JAR Sanctuary.
## Livraison locale
- [Pack beta.288](../build/Sanctuary-beta.288.mrpack), 16 715 918 octets.
SHA-256 : `51f70f5f00b0ccd293cf9be0dd34cf3e125c493d7929393428623668543c396b`.
- JAR `mods/sanctuary/build/libs/sanctuary-beta.288.jar`.
SHA-256 : `189883fad82476da221552fc56facb585f05f3167d0908a855f510625b932358`.
- Reçu : `build/dynamic288/validation.json`, comparaison des archives et
références des logs. Le MRpack beta.287 reste intact.
Aucune publication, synchronisation Prism ou ouverture de sauvegarde
personnelle. La décroissance radiale et ses limites d’occultation restent
celles de l’éclairage dynamique existant.
+87
View File
@@ -0,0 +1,87 @@
# ZOMBIES-286 — Hunter et bond offensif
Branche `codex/hunter`, sur le socle local beta.285 conservé. État : implémenté,
vérifié côté serveur et sous Vulkan ; pack local beta.286 construit et vérifié.
## Contrat demandé
Sixième infecté : un Hunter qui prépare son bond, saute réellement vers sa
cible et la blesse seulement si son corps la touche. Le bond peut rater : sa
direction reste fixée après le départ et les blocs utilisent leurs collisions
natives. Aucun plaquage ni dégât répété automatique n'est ajouté.
- Zombie adulte vanilla 26.3, t-shirt bleu soutenu et pantalon anthracite
recolorés par script ; tête, peau, chaussures, UV et alpha conservés.
Identifiants `sanctuary:hunter` et `sanctuary:hunter_spawn_egg`.
Aucun bébé, y compris via œuf, chargement ou familier ; pas de noyé.
- Sauvage : 10 PV, vitesse 0,30, mêlée 3, poursuite 36 blocs. Préparation
de 16 ticks au sol, bond à 3–10 blocs avec ligne de vue, impulsion verticale
0,62 et vitesse horizontale adaptée à la distance, plafonnée à 1,25.
Une seule frappe de 4 dégâts au contact, récupération de 24 ticks,
recharge de 120 ticks engagée au décollage, même si le bond rate.
- Gravité, trajectoire et collisions natives ; corridor complet chargé et
bordure vérifiés. Mur, plafond, atterrissage ou durée bornée terminent le bond.
Une téléportation n'est pas interprétée comme un balayage d'attaque. Aucun
bloc détruit, chunk généré ou monde existant réécrit.
- Familier rare : adulte miniature à taille variable persistante, 12 PV,
vitesse 0,30. Attaque de contact automatique et G de bond offensif à 8 blocs,
avec trajectoire physique et récupération. Les protections communes des
familiers gardent autorité sur les dégâts, destinataires, claims et duels.
Le familier ne bondit pas lorsqu'il est monté ou transporté.
- Œuf créatif, spawn rare dans les habitats de zombies de l'Overworld : poids 4,
un individu. Recharge conservée au chargement ; préparation et saut interrompus
ne reprennent pas après rechargement. Aucun identifiant ou format existant changé.
## Vérification
- `:sanctuary:runGameTest -PsanctuaryFocusedTests=hunter286
-PsanctuaryAtlasOnly=true` : **16 tests requis réussis**, dont le test
d’infrastructure. Préparation accroupie, vraie élévation, contact unique,
esquive, murs, plafond, atterrissage, recharge, sauvegarde interrompue,
téléportation, corridor déchargé, G physique, protections communes et six
tailles persistantes.
- Régressions séparées : **77 tests** avec
`ziplines242,boomer284,spitter283,charger282,infected280,familiar260,companions`,
puis **19 tests** avec `smoker285`. Les joueurs simulés du Smoker imposent
une session indépendante des tests d’IA asynchrones. Chaque total comprend
son test d’infrastructure.
- Deux arènes historiques corrigées sans changement de comportement : sol
du Charger élargi pour garder l’esquive de quatre blocs sur la plateforme ;
arènes du Smoker replacées dans les chunks suivis par GameTest pour que les
recherches natives de projectiles voient immédiatement les joueurs.
- `:sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuaryHunter286ClientTests=true -PsanctuaryQuickTests=true
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8
-PsanctuaryClientNoVsync=true` : **réussi**, Vulkan / MoltenVK 1.4.2 /
Apple M1. Monde de développement plat temporaire, graine `286` : appui G
réel, familier en vol, contact et recharge ; bond sauvage sur le vrai joueur,
dégâts, récupération et gravité ; esquive au clavier sans dégâts ; rendu
adulte aux tailles 500/1000/1500 ; interface et infobulle FR/EN, GUI 2/3/4.
Captures : `build/hunter286-screenshots/`.
- `./gradlew check build assemblePack -PsanctuaryFocusedTests=hunter286
-PsanctuaryAtlasOnly=true` : **réussi en 2 min 46 s**, y compris les 16
tests Hunter rejoués et tous les contrôles purs configurés par `check`.
- Export `packwiz modrinth export` et intégrité ZIP vérifiés : un seul JAR
Sanctuary, identique au JAR construit ; version `beta.286`, Minecraft `26.3`,
Fabric Loader `0.19.5`. Classes de test exclues ; classes Hunter, œuf,
profil, butin, tags et traductions FR/EN présents. Pixels du PNG conformes
au script, cinq textures antérieures et MRpack beta.285 inchangés.
## Artefacts locaux
- [Pack beta.286](../build/Sanctuary-beta.286.mrpack), 16 682 455 octets.
SHA-256 : `9363b9067bc9974cd5f850b671e0d97c1f0876eda6885a51e2acf32d6d3b1103`.
- JAR `mods/sanctuary/build/libs/sanctuary-beta.286.jar`.
SHA-256 : `02a62837d2385ec61cf76c915c2eb0e0cc5b339d5bd3ceab64a2f1086bc94abf`.
- Reçu détaillé : `build/hunter286-validation.json`. Logs et captures ignorés
sous `build/hunter286-*`. Les premiers essais échoués des arènes historiques
sont conservés à côté des résultats finaux ; les réparations ci-dessus ne
changent aucun comportement du Charger ou du Smoker.
## Limites
Le Hunter saute puis retombe : aucun plaquage ou maintien du joueur. La vitesse
de lancement adapte la portée au sol ; un dénivelé, un obstacle ou le déplacement
de la cible peut faire rater le contact. Seuls les chunks déjà chargés sont
utilisés. Aucune validation graphique OpenGL, publication, synchronisation
Prism, installation personnelle ou modification de sauvegarde existante.
+76
View File
@@ -0,0 +1,76 @@
# ZOMBIES-281 — Infectés adultes et familiers miniatures
Branche `codex/special-zombies`, suite au retour sur beta.280. État : livré et
vérifié localement.
## Contrat
Les infectés n'ont **aucune forme bébé**. Leur modèle et leur texture sont
toujours ceux de l'adulte. Cette règle appartient à `AbstractInfectedEntity`,
le socle commun des variantes : les futurs Hunter, Charger, Spitter et Boomer
devront aussi en hériter. Ces variantes restent à implémenter.
L'Infecté actuel refuse le flag bébé des groupes naturels, des appels natifs,
des œufs et du chargement des données d'entité. Il ne reçoit pas le bonus de
vitesse bébé. Les bébés zombies Minecraft gardent leurs règles habituelles.
En familier, l'adulte est réduit avec le système `FamiliarSize` existant.
Son trait de taille enregistré dans l'œuf reste identique : petites, ordinaires,
grandes et colossales restent possibles. Le rendu en jeu, les aperçus des menus,
les dimensions de monture et les sons utilisent tous la même forme adulte.
La taille ne dépend donc plus d'un âge bébé.
Les identifiants, la texture vanilla recolorée, les règles de combat et les
formats restent ceux de beta.280. Une ancienne donnée d'Infecté portant
`IsBaby=true` est interprétée comme adulte à la lecture, sans changement de
son UUID ou de sa santé. Aucun monde personnel n'est ouvert ou réécrit.
## Vérifications
- Refus du bébé et de son bonus de vitesse, avec bébé zombie vanilla conservé.
- Lecture d'un ancien flag bébé sans perdre l'identité ni la santé.
- Familier adulte à six tailles, de 120 à 6 000 ‰, sans changer le trait ni
l'identité ; cohérence entre hauteur visible et hauteur de monture.
- Parcours Vulkan sur un monde plat neuf, graine `281` : trois tailles réelles
de familier, inspection de la géométrie adulte et menus FR/EN GUI 2/3/4.
- `./gradlew check build assemblePack` et vérification du MRpack beta.281.
La suite de régression conserve son entrée `infected280` et son option client
`sanctuaryInfected280ClientTests`, actualisées selon ce nouveau contrat.
Les **34 GameTests serveur** ciblés passent, dont neuf pour l'Infecté. Les
contrôles de réapparition et des 88 familiers vanilla passent aussi. Le parcours
client **Vulkan / MoltenVK 1.4.2 / Apple M1** passe en **54 secondes**.
Il vérifie le vrai rendu du familier, sa géométrie adulte et sa hauteur pour
500, 1 000 et 1 500 ‰, ainsi que les noms, infobulles et menus FR/EN aux échelles
2, 3 et 4. Dix captures sont conservées sous `build/infected281-screenshots/` ;
les petites et grandes miniatures et le menu FR GUI 2 ont été inspectés.
Logs : `build/infected281-native.log`, `build/infected281-client-vulkan.log`
(marqueur `INFECTED281_CLIENT_PASS`). La construction complète finale
**`check build assemblePack` passe en 3 min 09 s**, avec les 34 tests serveur.
Log : `build/infected281-check-build-pack.log`.
Le JAR et le MRpack beta.281 sont vérifiés : version exacte, intégrité ZIP,
un seul JAR Sanctuary identique au build, socle adulte commun présent,
texture corrigée conservée octet pour octet et fixtures de test exclues.
Reçu : `build/infected281-validation.json`.
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=infected280,familiar260,companions \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryInfected280ClientTests=true -PsanctuaryQuickTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
## Livraison locale
- `mods/sanctuary/build/libs/sanctuary-beta.281.jar`
- `build/Sanctuary-beta.281.mrpack`
- `build/infected281-screenshots/0002_infected281-miniature-1000.png`
Aucun déploiement dans Prism ou un serveur personnel. Les artefacts beta.280
sont conservés ; cette livraison utilise le numéro suivant.
+135
View File
@@ -0,0 +1,135 @@
# ZOMBIES-280 — Premier infecté
Le contrat bébé de cette livraison est remplacé par
[beta.281 : adultes en miniature et taille variable](infected-adults-beta281.md).
Branche `codex/special-zombies`. Premier incrément demandé : l'Infecté simple,
avant les Hunter, Charger, Spitter et Boomer. État : livré localement et vérifié.
## Contrat
- `sanctuary:infected` est un zombie vert au modèle vanilla, t-shirt rouge et
pantalon brun sombre. Adulte et bébé utilisent les formes natives.
- Attributs de base : 10 PV, vitesse 0,25 contre 0,23 pour le zombie ; mêlée
native, dégâts de base 3, portée de poursuite 36 blocs. Il brûle au soleil
et ne se transforme pas en noyé. Aucun pouvoir spécial dans ce premier lot.
- Apparition au sol dans l'obscurité, selon les règles des monstres, uniquement
dans les biomes de l'Overworld qui proposent déjà le zombie : poids 15,
groupes de 1 à 3. Aucun spawner ni remplacement des zombies existants.
- `sanctuary:infected_spawn_egg` apparaît dans l'onglet des œufs créatifs et
invoque uniquement l'Infecté. Il peut être équipé comme familier bébé avec
identité, suivi, ordres, santé et combat persistants du système existant.
- Le familier a 10 PV, vitesse 0,30, morsure de mêlée et assaut court. Son
profil FR/EN est distinct du zombie. Aucune nouvelle aptitude de travail.
- Les autres infectés et le zombie des glaces restent à réaliser. Aucune
nouvelle voie d'acquisition des œufs en survie n'est ajoutée dans ce lot.
## Référence et compatibilité
Référence en lecture seule : `../26.2/itsalive`, commit
`26f2c2a0061619df1f146f9081d6740c560a7497`, `InfectedVariant.INFECTED` et
`InfectedEntity`. Le nouveau type est indépendant des cinq variantes sous
`itsalive:infected` de l'ancien mod ; aucune importation de leur sauvegarde.
Les identifiants, profils et données des familiers existants sont conservés.
L'ajout de tables de spawn affecte les apparitions futures, sans changer le
terrain, les graines, les chunks ou un format de sauvegarde.
La texture est une recoloration déterministe du zombie natif **26.3**, par
masques UV et sélection des seuls pixels cyan du t-shirt et violets du pantalon.
Le visage, la peau verte, les chaussures et l'alpha sont conservés pixel pour
pixel. L'œuf reprend l'icône rouge historique. La première texture générée par
ImageGen décalait l'atlas ; elle a été écartée après le retour du créateur.
## Vérifications
Les **33 GameTests serveur** ciblés passent, dont huit nouveaux : attributs,
mêlée réelle et bébé ; utilisation de l'œuf en survie et consommation ;
sauvegarde/relecture native avec UUID, âge et santé ; tables de spawn ;
immersion sans conversion en noyé ; équipement et rééquipement du familier ;
morsure réelle du familier ; changement d'œuf pendant un objectif en cours.
Les suites de pouvoirs existants et de réapparition
beta.260 passent avec le nouvel œuf installé.
Log final : `build/infected280-check-build-pack.log`.
Le serveur plat natif 26.3 utilise le désert ; le contrôle positif des tables
porte sur cet habitat réellement générable, avec exclusions Champignons,
Nether et End. Il ne mesure pas la fréquence des rencontres en partie longue.
Le parcours client final **Vulkan / MoltenVK 1.4.2 / Apple M1** passe en 54 s :
apparences adulte et bébé face au zombie classique, familier réellement équipé,
renderer partagé avec les proxies, texture embarquée, rareté et infobulle
native, noms et menus **FR/EN GUI 2/3/4**. Sept captures sont conservées dans
`build/infected280-screenshots/`. Les vues comparatives, FR GUI 4 et EN GUI 2
ont été inspectées après correction de la texture. La vue comparative finale
confirme le visage et les bras vanilla, le t-shirt rouge et le pantalon brun
sans décalage. Log : `build/infected280-client-vulkan.log`, marqueur
`INFECTED280_CLIENT_PASS`. Aucun essai OpenGL.
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=infected280,familiar260,companions \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryInfected280ClientTests=true -PsanctuaryQuickTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
Le mode plat explicite évite la fixture historique de génération, sans modifier
le générateur de production. Tous les mondes utilisés sont jetables et ignorés.
La construction complète finale **`check build assemblePack` passe en 2 min 59 s**,
avec la texture corrigée et les 33 tests serveur. Les contrôles purs du dépôt
passent également. Le MRpack est exporté et vérifié : intégrité ZIP,
Minecraft 26.3, Fabric Loader 0.19.5, un seul JAR Sanctuary beta.280 identique
au build et texture corrigée présente octet pour octet. Les fixtures de test
sont exclues du JAR. Reçu : `build/infected280-validation.json`.
Le second parcours a également reproduit un plantage lors du remplacement
d'un œuf : l'ancien objectif d'IA pouvait continuer un tick avant la création
du nouvel individu. Le cerveau vérifie désormais que le familier est actif
et que son œuf correspond encore à celui équipé avant d'agir. Un huitième
GameTest couvre cet intervalle ; le parcours client final inclut le correctif.
## Essai local
Dans un monde de développement avec commandes autorisées :
```mcfunction
/give @s sanctuary:infected_spawn_egg
/summon sanctuary:infected
```
L'œuf placé dans l'emplacement Familier invoque sa forme bébé. Les commandes
Alt bref / Alt maintenu et la technique G restent celles des autres familiers.
L'œuf est fourni en créatif ; aucune nouvelle acquisition en survie n'est
annoncée. Aucun canal packwiz, serveur personnel ou instance Prism modifié.
## Ressources graphiques
- Texture livrée : `mods/sanctuary/src/main/resources/assets/sanctuary/textures/entity/infected.png`.
- Œuf historique : `mods/sanctuary/src/main/resources/assets/sanctuary/textures/item/infected_spawn_egg.png`.
- Outil de texture : `scripts/generate_infected_texture.py`, Python standard et
ImageMagick 7 pour lire/écrire le PNG. **Aucun dessin génératif ni changement
de géométrie.** 331 pixels de t-shirt et 239 pixels de pantalon recolorés,
nuances natives conservées ; visage et transparence vérifiés avant encodage,
puis tous les pixels vérifiés après écriture.
- SHA-256 du PNG source :
`1b88eea9b63889c2c3399773f887af375defc3aad0e7a375adc84249da54cbbd`.
- SHA-256 de la texture livrée :
`398898aed83ec22ef525b60da120fc3e0b971b33ad3640c73104b952ad060e29`.
Reproduction sans modifier le JAR source :
```sh
python3 scripts/generate_infected_texture.py /chemin/vers/minecraft-26.3-client.jar
```
Voir aussi la provenance dans `THIRD_PARTY_NOTICES.md`.
## Artefacts locaux
- Mod : `mods/sanctuary/build/libs/sanctuary-beta.280.jar`.
- Pack : `build/Sanctuary-beta.280.mrpack`.
- Captures corrigées : `build/infected280-screenshots/`.
La livraison est locale. La publication et la mise à jour de l'instance Prism
restent distinctes de ce travail de développement.
+189
View File
@@ -0,0 +1,189 @@
# DIST-280 — JEI embarqué et packwiz serveur
Branche `codex/jei-packwiz-server`, base commit `697e4a2` (beta.278).
Version réservée `beta.280` : beta.279 appartient au chantier parallèle
Map Island Zoom, dont les modifications ne sont pas reprises ici.
## Contrat
- Un seul JAR Sanctuary à distribuer, avec le fork JEI exact
`30.32.0-sanctuary.4` et ses notices/bibliothèques embarqués. Fabric API reste
une dépendance commune ; Sodium et Sound Physics restent facultatifs côté client.
- Même JAR sur client et serveur. JEI possède une initialisation commune
(recettes et transfert), ses écrans restent dans son entrée client.
- Un manifeste serveur `server/pack.toml` sur le même canal packwiz que le
client, avec uniquement Sanctuary et Fabric API. Même version, URL et hash
du JAR Sanctuary dans les deux profils. Publication atomique des manifestes
après vérification de l'artefact immuable.
- Un kit serveur local avec mise à jour packwiz sans interface et lancement
Fabric. Les mondes, configurations, permissions, listes de joueurs et secrets
restent hors de l'index. Une erreur de mise à jour empêche le démarrage.
- Documenter les paramètres existants de l'API Sanctuary Web et de la MariaDB
communautaire, sans inventer d'API ni migrer les données ou configurer un site
public implicitement. Les secrets restent dans l'environnement du service.
## Vérifications locales
`./gradlew check build assemblePack assembleServerPack
-PsanctuaryFocusedTests=recipes,progression -PsanctuaryAtlasOnly=true` réussit :
13 GameTests requis, contrôles purs du dépôt et assemblages. Le fixture ciblé
évite le harnais worldgen historique `unified`, sans modifier sa génération.
Les sept tests de l'outil de mise à jour passent séparément, y compris le
relais du signal d'arrêt et l'attente de Java avant libération du verrou.
Installation réelle avec packwiz, deux synchronisations et six fichiers
personnels témoins conservés octet par octet. Le JAR de production démarre et
s'arrête dans un monde plat de développement neuf avec le JEI imbriqué ;
celui-ci correspond au fork construit. Minecraft normalise lui-même les fichiers
`server.properties`, `ops.json` et `whitelist.json` au lancement : leurs valeurs
sont vérifiées après cet arrêt, distinctement de la préservation par packwiz.
La transition du profil client retire réellement le JEI séparé suivi par
packwiz et conserve les favoris. Les manifestes de publication client/serveur
référencent le même artefact. Aucun nouveau parcours graphique n'est revendiqué ;
les contrôles historiques Vulkan de l'interface ne sont pas rejoués ici.
Journaux et reçus ignorés : `build/jei-server-final.log`,
`build/server280-updater-final.log`, `build/server280-validation-delivery/validation.json`,
`build/client280-validation/validation.json`.
## Serveur dédié demandé le 25 octobre 2026
Le créateur demande une **nouvelle CT pour 20 joueurs** sur le nœud `gpu`.
La CT 350 `sanctuary-beta-20` est créée à cette fin : Debian 13 amd64,
6 vCPU, 8 Gio de RAM, disque 80 Gio, non privilégiée. Le budget Java retenu est
6 Gio, avec 20 places, portée 6 et simulation 4 ; ce dimensionnement n'est pas
une mesure de charge avec vingt joueurs connectés.
L'Alpha (CT 300), les CT 330 et 340 et les mondes existants restent hors cible.
Le nouveau monde utilisera `sanctuary:sanctuary` (réglages initiaux
`sanctuary:island231_medium`), graine `28020261025`, version beta.280.
Aucune expansion n'est activée par le déploiement. Le site est dans la CT 320 ;
son API et son schéma communautaire 3 sont présents.
## Livraison et mise en service
La [release beta.280](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.280)
est publiée depuis le commit `23b295356b499404e0056affbb5d3b3811c1b01f`.
Le canal packwiz est avancé au commit `338e8a62387048c7e703ef036fda759ef57b4130`,
avec les profils client et serveur dans le même commit. Aucun JEI séparé n'est
joint. Le MRpack contient le seul JAR Sanctuary en override, et trois dépendances
téléchargées (Fabric API, Sodium et Sound Physics).
| Artefact | SHA-256 |
| --- | --- |
| `sanctuary-beta.280.jar` | `489dade38218d34d2a4502fa4ffe71f0bc13035948dc1c52b633b528e9855c7e` |
| `Sanctuary-Server-beta.280.zip` | `54ed73e75b53305a9f0cc5505f3d64624d23598f906ac69d121030255ced0d5a` |
| `Sanctuary-beta.280.mrpack` | `b23ba2d9cf72d27da2595e3f4a32a36656f71abf2d2cb4d6cefd7f9d29e4cd01` |
Le kit téléchargé dans la nouvelle CT correspond à cet artefact immuable.
Java 25, Python 3.13 et le service `sanctuary-beta.service` sont installés ;
le service est activé au démarrage. Le premier monde Sanctuary a été créé à
la graine annoncée, avec le générateur 231, taille Medium, en Survie et
`online-mode=true`. La réponse réseau Minecraft annonce 26.3, protocole 777,
**20 places**, et le JEI embarqué est chargé côté serveur.
La synchronisation packwiz a été exécutée après arrêt propre du nouveau serveur :
**61 fichiers de monde, de configuration et de secrets gardent leurs hashes**.
Le même monde redémarre ensuite. L'outil maintient son verrou jusqu'à la fin
de la sauvegarde Java, y compris lorsque systemd transmet SIGTERM.
L'API du site accepte l'authentification et refuse un corps de vérification
vide avec HTTP 422 : aucun code d'habitant n'est consommé. L'accueil serveur
confirme que les codes sont requis. Un compte MariaDB propre à Beta a été créé,
restreint à son hôte et aux sept tables nécessaires, avec TLS obligatoire ;
Connector/J utilise `sslMode=verify-full`. Le schéma existant 3 n'est pas migré.
La ligne de statistiques du nouveau serveur est alimentée toutes les 30 secondes.
La console du site était non configurée. Seules ses trois variables RCON ont
été renseignées pour Beta, après sauvegarde privée ; le cache Laravel est
reconstruit sans redémarrer le site. La commande de lecture `list`, appelée
par le vrai service PHP, répond « 0 of a max of 20 ». Le port RCON est filtré
dans la CT Beta : accès du site et de loopback uniquement, refus depuis un autre
client LAN. Aucune redirection publique RCON n'est créée.
Les secrets restent dans `/etc/sanctuary/beta.env`, le `server.properties`
privé et l'environnement Web, jamais dans Git ou packwiz. Les unités
`sanctuary-beta-rcon-firewall.service` et le complément systemd associé
appartiennent à cette installation, hors de l'index de mods.
Le domaine demandé est `beta.sanctuary-minecraft.net`. Les deux enregistrements
OVH sont publiés : A pour Beta et SRV `_minecraft._tcp.beta` vers le port 25567,
TTL 300. Les quatorze enregistrements DNS préexistants sont conservés. Une
réservation DHCP lie l'adresse de la CT à sa MAC ; elle est activée après test
de syntaxe et redémarrage de dnsmasq, sans changer ses deux fichiers existants.
Aucun serveur Minecraft ou proxy existant n'est redémarré par cette opération.
Une redirection UPnP Beta seule a servi d'amorçage. Le 25 octobre, le créateur
réenregistre la règle Bbox permanente « Sanctuary beta 20 » : **port externe
25567 → 192.168.1.122:25565**. L'API de la Bbox déclare encore `protocol=all`
(TCP/UDP) ; l'accès Minecraft TCP est vérifié.
La redirection UPnP appartenant à Beta est ensuite supprimée. Son timer est
désactivé, le service est arrêté, puis les trois fichiers du maintien sont
retirés de la configuration active et archivés dans la CT sous
`/root/sanctuary-beta-maintenance/upnp-retired-20261025-1792900244/`.
Les sept autres règles NAT permanentes et les quatre autres redirections UPnP
sont conservées. Le processus Minecraft reste le même : aucun redémarrage ni
modification d'une sauvegarde n'est nécessaire à ce retrait.
Après suppression du secours, `beta.sanctuary-minecraft.net:25567` répond bien
Minecraft **26.3**, protocole **777**, **0/20**, avec le MOTD du nouveau Beta.
Le SRV public permet de saisir seulement `beta.sanctuary-minecraft.net`.
La vérification utilise le chemin public depuis le LAN (hairpin) ; une validation
depuis un réseau Internet extérieur reste à effectuer.
Le [MRpack client publié](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.280/Sanctuary-beta.280.mrpack)
est retéléchargé et vérifié : 16 589 058 octets, hash identique au fichier local,
ZIP intègre, version beta.280, Minecraft 26.3 et un seul override Sanctuary avec
JEI intégré.
Aucune charge réelle avec vingt joueurs, nouvelle connexion OAuth Discord ou
nouvelle qualification graphique n'est revendiquée.
Les sources sont isolées de Map Island Zoom. La prochaine livraison doit
reprendre ce profil serveur et utiliser un compteur supérieur à beta.280.
L'instance Prism personnelle n'a pas été synchronisée par cette mise en service.
Preuves locales supplémentaires : `build/server280-publication.log`,
`build/server280-deployment.log`, `build/server280-gpu-update-validation.log`,
`build/server280-rcon-setup.log`, `build/server280-dhcp-reservation.log`,
`build/server280-upnp-retirement.log` et
les inventaires sous `/root/beta-publication-20261025/` sur PVE. Ces fichiers
restent hors de Git.
## Raccordement Sanctuary Web corrigé le 25 octobre 2026
Le statut et l'Intendance de [Sanctuary Web](https://sanctuary-minecraft.net)
lisaient encore l'ancienne base distante par la connexion Laravel `game`, alors
que Beta écrivait déjà dans la base `sanctuary` de la CT Web 320. La vérification
initiale des écritures serveur ne couvrait donc pas toutes les lectures du site.
Hermes confirme l'identité de cette instance Web ; les anciens sites de release
ne sont pas remis en service.
La connexion `game` utilise maintenant la même base locale que Beta, en gardant
`COMMUNITY_DB_CONNECTION=game` pour les services et les ressources
d'administration. Le cache de configuration Laravel est reconstruit. Les sources
Web déployées, qui contiennent des changements préexistants, restent inchangées.
La connexion Discord fonctionne selon le créateur et n'est pas modifiée.
La nouvelle communauté ne contient aucune publication : Gazette, Tableau et
Intendance affichent désormais cet état, sans suppression dans l'ancienne base.
Les trois comptes, dont deux administrateurs, le groupe d'équipe, ses deux
adhésions et les vingt-deux permissions sont conservés. Les empreintes des
identités, des droits administrateurs et des tables d'équipe sont identiques
avant et après l'opération.
Les services publics et les requêtes d'administration Gazette/Tableau lisent
zéro publication. La page d'accueil et les deux listes publiques répondent HTTP
200. Les métriques sont fraîches, le serveur est déclaré en fonctionnement, et
son temps cumulé continue d'augmenter. Le compteur reste à zéro jour tant que
Beta n'a pas cumulé vingt-quatre heures ; les anciens décès ne sont plus affichés.
La configuration précédente est sauvegardée dans la CT Web, avec accès root
seul, sous
`/root/sanctuary-beta-maintenance/web-game-connection-20261025-1792901058/`.
Le reçu `build/sanctuary-web-beta-alignment.log` et la capture
`build/sanctuary-web-beta-after.png` restent hors de Git. Cette correction de
configuration et de documentation ne change aucun binaire beta.280 ni aucune
sauvegarde Minecraft.
+126
View File
@@ -0,0 +1,126 @@
# MAP-279 — Carte des îles et grand dézoom
Branche `codex/map-islands-zoom`, base beta.278 ; livraison locale beta.279,
Minecraft 26.3, demande du 25 octobre 2026.
## Résultat
La limite de zoom liée à une fenêtre de 1 024 blocs disparaît. La molette et
les boutons permettent de passer du détail des blocs à une vue de plusieurs
îles, puis à une emprise de 67 108 864 blocs, suffisante pour cadrer le monde.
Le dézoom reste centré sous le pointeur ; les boutons gardent le centre de la
vue. Les zones découvertes éloignées sont affichées ensemble ; les zones non
relevées restent inconnues. Le centre de la carte peut rester fixe pendant que
le joueur se déplace et reçoit de nouveaux chunks.
Le message « Carte pleine : nouveaux relevés suspendus » provenait du plafond
de 16 384 chunks par couche et habitant. Il passe à **131 072**, sans éviction
des découvertes. Une carte ayant atteint l'ancien plafond reprend ses relevés
lorsque les chunks sont de nouveau reçus ou revisités. Les parcours non
conservés avant ce correctif ne peuvent pas être reconstitués automatiquement.
Cette capacité reste bornée ; ce lot ne promet pas une exploration illimitée.
## Rendu et serveur
La texture reste de 1 024 × 1 024 pixels. Seize niveaux de réduction regroupent
les couleurs par carrés de 2, 4, 8… jusqu'à 65 536 blocs de côté par texel.
Le niveau demandé dépend de la fenêtre et du zoom ; le serveur ne transmet
que les tuiles de ce niveau dans le viewport, par lots de 64 maximum.
Les caches réduits sont calculés à partir des seuls relevés personnels,
à la demande, et invalidés par les changements de terrain. Les couleurs de
terrain sont privilégiées sur le vide relevé pour garder les petits îlots.
Ce choix accentue les très petites zones et n'est pas une mesure de leur aire.
Les six couches d'altitude restent distinctes. Les positions des joueurs et
les marqueurs utilisent la même emprise étendue. La vue opérateur réduit
uniquement les chunks déjà chargés, échantillonnés à son budget existant de
quatre chunks par tick ; elle reste séparée des relevés personnels.
Les permissions, jetons, dimensions et cadences de requête restent contrôlés.
L'aperçu Pause conserve son échelle proche et bénéficie de la capacité accrue.
Les contours des propriétés, Demeure et le clic droit sur un chunk restent
accessibles au niveau détaillé ; la vue d'ensemble indique en FR/EN qu'il faut
zoomer pour les retrouver. La grille s'efface lorsque ses lignes seraient
espacées de moins de huit pixels. L'échelle affiche les blocs par pixel au
lieu d'un multiplicateur arrondi à zéro.
## Persistance et compatibilité
Les fichiers `data/sanctuary-atlas/<UUID>.json` et leurs six couches gardent
le schéma 2, les mêmes coordonnées, couleurs, propriétaires et graines.
Les lecteurs historiques de schéma 1 restent identiques. Aucun niveau réduit
n'est écrit dans les sauvegardes. La limite de taille acceptée passe de 12 à
64 Mio par fichier pour accueillir le plafond élargi ; les écritures atomiques
et le refus d'écraser un fichier modifié extérieurement sont conservés.
Aucun changement de génération, chunk, identifiant ou expansion.
Le protocole `sanctuary:atlas_request` ajoute le niveau de détail : mettre à
jour client et serveur ensemble. Un retour vers beta.278 exige la copie
antérieure des relevés dès qu'une couche a dépassé 16 384 chunks : l'ancien
lecteur refuse un tel fichier. Aucune sauvegarde personnelle ni instance de
jeu n'est ouverte ou modifiée par ce ticket.
## Vérification
Les contrôles purs couvrent les seize réductions, les coordonnées négatives,
le bord du monde, la conservation d'un petit îlot, l'invalidation après une
construction, les zones inconnues, les bornes des requêtes et les marqueurs
lointains. Le schéma 2 est sauvegardé puis relu avec 17 000 chunks ; le plafond
courant est aussi atteint sans éviction.
Commandes de validation :
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=map244,map,claims273,claims277,waystones239 \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryMap279ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan \
-PsanctuaryClientHeapGb=8 -PsanctuaryClientNoVsync=true
```
Le parcours graphique utilise un monde plat de développement neuf, graine
`279`, et cinq relevés de contrôle espacés de 8 192 blocs pour isoler les
problèmes d'échelle. Il ne constitue pas une visite des cinq continents
Sanctuary ni un benchmark multijoueur. Les déplacements lointains, envois
natifs de chunks et changements de blocs utilisent le vrai serveur intégré.
Le parcours **Vulkan / MoltenVK 1.4.2 / Apple M1** passe en **1 min 07 s**,
avec **11 captures**. Il vérifie les cinq relevés simultanés, une arrivée réelle
à 9 008 blocs sans déplacer le centre de la carte ni changer son jeton,
un bloc d'or natif nouvellement relevé, un dézoom au-delà de 256 blocs par pixel puis au
niveau maximal, l'emprise du monde sur les deux axes, le retour à la couleur
exacte du bloc, Pause, FR/EN GUI 2/3/4 et le retrait de l'aptitude Atlas.
Une tuile chargée pour le seul contrôle opérateur, à 12 000 blocs, apparaît
dans son aperçu réduit sans entrer dans les relevés personnels, puis disparaît
quand les commandes sont retirées. Le chargement forcé appartient uniquement
à la fixture du monde de test ; les requêtes Atlas n'en créent pas.
Les captures de vue d'ensemble FR GUI 4, de retour au détail, d'échelle du
monde et d'opérateur ont été inspectées. Les cinq petits carrés colorés sont
des relevés de contrôle, pas des captures du relief des continents.
Captures : `build/map279-screenshots/` ; log :
`build/map279-client-vulkan.log`, marqueur `MAP279_CLIENT_PASS`.
La construction complète et les **20 tests serveur ciblés** passent : relevé
natif au-delà de l'ancien plafond, six couches, absence de découverte chez un
autre habitant ou un spectateur, droits Atlas, claims, Demeure et Waystones.
Le log est `build/map279-check-build-pack.log`. Les contrôles purs du dépôt
passent également. La capacité maximale en multijoueur et le coût de sauvegarde
d'une couche entièrement remplie ne sont pas mesurés par ce lot.
## Artefacts locaux
La dernière passe `check build assemblePack` passe en **3 min 54 s**.
Le MRpack est exporté et vérifié : intégrité ZIP, Minecraft 26.3, Loader 0.19.5,
un seul JAR Sanctuary beta.279 identique au build local, nouvelle pyramide
présente, fixtures de tests absentes et trois libellés FR/EN embarqués. Le JAR
JEI est également identique au build local ; les trois dépendances distantes
gardent leurs empreintes. Reçu : `build/map279-validation.json`.
- JAR : `mods/sanctuary/build/libs/sanctuary-beta.279.jar`, 16 239 330 octets.
- MRpack : `build/Sanctuary-beta.279.mrpack`, 16 593 044 octets.
- SHA-256 du MRpack : `1ebe43f559cec3727fea17ed8cf5593702109ceb6151f5e45125beda83cda6c8`.
Cette livraison est locale : aucun canal, tag distant, serveur personnel ou
instance Prism n'est modifié.
+21
View File
@@ -0,0 +1,21 @@
# Sanctuary beta.289
Six nouveaux infectés rejoignent Sanctuary : Infecté, Charger, Spitter, Boomer,
Smoker et Hunter, chacun avec son œuf et son comportement. Le Spitter laisse
une flaque d’acide ; le Smoker doit toucher avec son lasso avant de tirer sa
cible ; le Hunter prépare un bond qui peut être esquivé.
Neuf tenues aléatoires sont partagées avec le zombie classique. Les bras restent
en avant, avec le Hunter accroupi au sol. Les infectés restent sans variante
bébé ; leurs familiers reprennent le modèle adulte en miniature, à taille
variable, avec leurs pouvoirs.
Les objets lumineux tenus en main éclairent de nouveau le terrain avec Sodium,
y compris sans PBR. JEI est intégré au même JAR Sanctuary sur le client et le
serveur. Le grand dézoom de carte et les relevés lointains sont conservés.
Minecraft 26.3 · Fabric 0.19.5. Construction complète, 31 tests serveur et parcours
Vulkan des zombies/familiers vérifiés pour cette livraison. Les lumières utilisent
le correctif de beta.288, dont les classes sont conservées à l’identique.
Le téléchargement du site reste l’instance Prism prête à suivre le canal stable.
+52 -39
View File
@@ -1,5 +1,42 @@
# Distribution packwiz et Prism
Le [téléchargement direct de Sanctuary Web](beta-download.md) donne une instance
Prism déjà configurée. Le premier lancement télécharge les mods ; les suivants
suivent les publications explicitement demandées via Codex.
La [procédure de reprise et de publication complète](updates.md) relie aussi
le serveur dédié, le téléchargement du site et l'instance locale. Les reçus
des anciennes versions ci-dessous restent historiques.
## beta.280 — JEI embarqué et profil serveur
JEI rejoint le JAR Sanctuary ; `mods/sanctuary.pw.toml` reste le seul artefact
Sanctuary à publier. `python3 scripts/pack.py release <sanctuary-https-url>`
prépare désormais le client et `server/pack.toml`, qui partagent le même JAR,
la même URL et le même hash. Le profil serveur contient seulement Fabric API
et Sanctuary ; Sodium et Sound Physics restent facultatifs côté client.
Les deux profils sont publiés dans le même commit du canal, après vérification
du JAR immuable. L'ancienne entrée JEI est retirée de l'index client.
`./gradlew assembleServerPack` construit un kit Linux avec les outils épinglés
et les commandes de synchronisation/démarrage. [Mode d'emploi](server-pack.md),
[contrat et vérifications](jei-server-beta280.md). La configuration Sanctuary Web,
MariaDB et les secrets restent hors de l'index. La préparation locale ne publie
pas le canal et ne met pas à jour une installation personnelle.
Livraison du 25 octobre : [release beta.280](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.280)
publiée, canal `338e8a62387048c7e703ef036fda759ef57b4130`, puis nouveau serveur
CT 350 sur `gpu` installé avec le kit vérifié. Le monde neuf, les paramètres
et les services Web sont vérifiés dans le [reçu](jei-server-beta280.md).
Une synchronisation du serveur conserve 61 fichiers suivis. L'Alpha et
l'instance Prism personnelle restent hors de cette mise en service.
La règle Bbox permanente est ensuite vérifiée et le secours UPnP Beta retiré.
Le domaine Minecraft `beta.sanctuary-minecraft.net` répond sans ce secours,
avec 20 places. [MRpack client beta.280](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.280/Sanctuary-beta.280.mrpack)
vérifié et disponible ; le [reçu](jei-server-beta280.md) conserve les empreintes
et la limite de validation réseau.
## beta.257 — Mods client séparés
JEI Sanctuary, Sound Physics Remastered et Sodium sont facultatifs et séparés
@@ -2078,18 +2115,18 @@ La procédure complète, également utilisable manuellement :
1. Incrémenter ensemble les versions de `gradle.properties` et `packwiz/pack.toml`
selon la [convention `beta.xxx`](versioning.md).
2. Exécuter `./gradlew check build assemblePack` et les vérifications du ticket.
3. Préparer les métadonnées de la livraison, avec les deux URL exactes :
3. Pour préparer les métadonnées manuellement depuis beta.280, utiliser la
seule URL du JAR Sanctuary contenant JEI :
```sh
python3 scripts/pack.py release \
https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.257/sanctuary-beta.257.jar \
https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.257/jei-sanctuary-26.3-30.32.0-sanctuary.4.jar
https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.xxx/sanctuary-beta.xxx.jar
```
4. Pousser le commit source vérifié. Créer une release Gitea correspondant à ce
commit et joindre les JAR Sanctuary et JEI construits, sans remplacer les artefacts d'une release
commit et joindre le JAR Sanctuary construit, sans remplacer les artefacts d'une release
antérieure. Vérifier que le téléchargement public possède le SHA-256 indiqué
dans `build/packwiz-release/mods/sanctuary.pw.toml` et `jei-sanctuary.pw.toml`.
dans `build/packwiz-release/mods/sanctuary.pw.toml`, identique au profil serveur.
5. Publier le contenu de `build/packwiz-release/` à la racine de la branche
`packwiz` dans un seul commit, puis vérifier les fichiers et hashes depuis
l'URL publique. Préserver l'historique de ce canal et ne jamais y copier le
@@ -2111,45 +2148,21 @@ python3 scripts/pack.py prism https://git.botsu.net/koka/sanctuary-beta/raw/bran
Il contient les versions exactes Minecraft/Fabric, la commande de lancement et
`packwiz-installer-bootstrap` v0.0.3. Le script vérifie le SHA-256 du bootstrap
officiel avant d'écrire `build/Sanctuary-Prism-auto-update.zip`. Ce ZIP est réservé
officiel avant d'écrire `build/Sanctuary-Prism-beta.xxx.zip`. Ce ZIP est réservé
aux nouvelles installations ; les instances déjà branchées sur packwiz suivent
le canal sans être réimportées.
## Raccorder l'instance Prism existante
## Importer l'instance Prism prête
Fermer Minecraft avant de modifier ses mods. Fermer également Prism avant de
modifier directement `instance.cfg`, pour éviter que son état en mémoire écrase
les changements. Dans l'instance Sanctuary Beta :
Le bouton **Télécharger** de [Sanctuary Web](https://sanctuary-minecraft.net)
télécharge directement [Sanctuary-Beta.zip](https://sanctuary-minecraft.net/downloads/sanctuary-prism.zip).
Dans Prism : **Ajouter une instance → Importer depuis un ZIP**, choisir ce fichier,
puis lancer **Sanctuary Beta**. Packwiz télécharge automatiquement les mods.
La commande avant lancement et l'outil sont déjà dans l'archive.
1. Sauvegarder `instance.cfg` et les anciens JAR Sanctuary dans un dossier de
sauvegarde hors de `mods/`. Vérifier que l'instance utilise Minecraft
`26.3-pre-2` et Fabric `0.19.5` ; ne pas convertir une instance 26.2.
2. Mettre le bootstrap issu du ZIP dans son dossier `minecraft/` (ou `.minecraft/`
selon l'installation).
3. Dans **Modifier → Paramètres → Commandes personnalisées**, activer les
commandes et renseigner la commande avant lancement ci-dessous.
4. Pour une instance issue d'un ancien `.mrpack`, déplacer seulement l'ancien
JAR Sanctuary non géré dans la sauvegarde préparée, puis faire la première
synchronisation packwiz. Conserver les autres mods et données personnelles.
Si la synchronisation échoue, remettre ce JAR et les réglages sauvegardés.
5. Vérifier qu'il reste un seul mod Sanctuary actif et que `packwiz.json` est
présent dans le dossier Minecraft. Les prochaines mises à jour sont suivies
par ce manifeste local : ne pas le supprimer entre les versions.
Commande à coller dans l'interface Prism :
```text
"$INST_JAVA" -jar "$INST_MC_DIR/packwiz-installer-bootstrap.jar" --bootstrap-main-jar "$INST_MC_DIR/packwiz-installer.jar" --pack-folder "$INST_MC_DIR" --multimc-folder "$INST_DIR" https://git.botsu.net/koka/sanctuary-beta/raw/branch/packwiz/pack.toml
```
Une modification directe de `instance.cfg` doit échapper les guillemets au format
Qt INI (`\"`) et activer `OverrideCommands=true`. Ces clés et
`PreLaunchCommand` doivent être dans la section **`[General]`**, jamais ajoutées
après la section `[UI]`. Le ZIP préparé le fait déjà. Rouvrir les commandes
personnalisées dans Prism pour vérifier que la commande est effectivement lue.
Une instance importée d'un `.mrpack` peut être marquée `ManagedPack=true` :
dès que packwiz en assure les mises à jour, utiliser `ManagedPack=false` pour
éviter deux mécanismes de gestion sur le même pack.
Conserver cette même instance pour les versions suivantes : une publication
explicitement demandée via Codex est suivie au lancement suivant. Une instance
déjà branchée sur le canal stable n'a pas besoin d'être réimportée.
## Données locales et mondes
+95
View File
@@ -0,0 +1,95 @@
# RELEASE-289 — zombies, lumière portée et mise à jour complète
Branche `codex/release-beta289`. Publication demandée le 26 octobre 2026.
État : **publiée et installée le 26 octobre 2026** sur la Beta dédiée,
le site et l’instance Prism existante.
## Contenu
- Six infectés : Infecté, Charger, Spitter, Boomer, Smoker et Hunter, leurs œufs
et leurs familiers adultes miniatures à taille variable, sans infecté bébé.
- Neuf tenues fournies par le créateur, choisies par individu et conservées au
rechargement ; zombie classique compris. Bras natifs en avant pour tous,
Hunter accroupi au sol et bond offensif esquivable.
- Correction des lumières portées avec Sodium, reprise du commit `6f44214`.
- Réintégration du conditionnement JEI et de la chaîne client/serveur déjà
publiée en beta.280, puis des outils de publication Web/Prism du runbook
`fb20160e`. Aucun retour aux anciens réglages de carte de cette branche :
le dézoom et les relevés de beta.279 restent conservés.
Le binaire local beta.288 reste immuable. La livraison beta.289
contient aussi JEI, comme la Beta dédiée précédente. Minecraft 26.3,
Fabric Loader 0.19.5, Fabric API 0.160.5+26.3, JEI 30.32.0-sanctuary.4,
Sodium 0.9.2+mc26.3 et Sound Physics 1.5.1+26.3 restent épinglés.
## Cibles autorisées
Commit/push des sources, release Gitea immuable, canal packwiz stable client
et serveur, CT Beta 350, téléchargement Web CT 320, puis synchronisation de
la même instance personnelle Sanctuary Beta. Arrêt propre et sauvegarde de
la Beta avant avancement du canal. Alpha CT 300, comptes, permissions,
configuration privée, mondes et paramètres de génération conservés.
Le site reçoit la publication de distribution ; aucun checkout live global
n’est remplacé. Le bouton Télécharger reste le ZIP Prism prêt.
## Vérification et reçu
`./gradlew check build assemblePack assembleServerPack
-PsanctuaryFocusedTests=recipes,progression,lights,hunter286
-PsanctuaryAtlasOnly=true` réussit en **3 min 21 s**, avec **31 tests serveur**
et les contrôles de la chaîne serveur/Web. Le parcours des zombies/familiers
est rejoué sous Vulkan avec Sodium et beta.289 : réussi en 51 s.
Les 2 650 classes Sanctuary de beta.288 sont identiques ; le nouveau JAR
ajoute le JEI embarqué et ses métadonnées/notices. Un seul JEI exact, côté
commun ; aucune classe de test, aucun JEI séparé dans le MRpack. ZIP Prism prêt,
kit serveur et archive MRpack contrôlés ; leurs contenus restent hors Git.
Les hashes et les journaux se trouvent dans `build/release289/`.
Les preuves propres à beta.288 restent dans `docs/dynamic-hand-light-beta288.md`.
Elles ne sont pas présentées comme une validation du nouveau conditionnement.
## Publication et installation effectives
- Sources et tag `beta.289` : `6821f6ee12095583663f1aafb02c92fbb105683e`,
poussés depuis `codex/release-beta289`.
- [Release immuable](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.289)
et trois archives publiques relues avec leurs SHA-256 exacts, HTTP 200.
- Canal packwiz client et serveur :
`56e3462a39d6b911c27ec926780ba5eb509e599c`, version `beta.289`.
- CT 350 : même service `sanctuary-beta.service`, actif, JAR beta.289 et JEI
embarqué confirmés dans le démarrage ; RCON répond, 0/20 joueurs au contrôle.
Les 239 fichiers protégés et l’environnement privé sont identiques entre
l’arrêt propre et la mise à jour, avant redémarrage.
- CT 320 : bascule atomique de la distribution vers
`storage/app/distribution/releases/beta.289/prism`. Le code live n’a pas été
remplacé. Footer et attribut de survol du bouton public annoncent beta.289.
Le ZIP anonyme répond HTTP 200, nom `Sanctuary-Beta.zip`, et le lien MRpack
historique répond aussi avec le hash attendu.
- Même communauté des deux côtés, métriques fraîches et serveur en marche.
L’API d’admission répond 422 à un code volontairement invalide, sans appel
de consommation d’un code.
- Même instance personnelle **Sanctuary Beta**, dossier historique
`Sanctuary-0.1.0-alpha.1` : passage de beta.144 à beta.289, un seul JAR
Sanctuary, deux synchronisations identiques. Les 999 fichiers personnels
contrôlés et les composants Minecraft/Fabric sont conservés. Aucun monde
personnel n’a été ouvert ; l’installation isolée a aussi passé deux cycles.
Sauvegarde serveur complète avant avancement du canal :
`/root/sanctuary-beta-backups/before-beta289-20261026T002155Z`,
archive de 167 964 197 octets, SHA-256
`724533e56bf4ab5319bbd519d123c6731fc882f684cfc0998d876c34ed09805d`.
Sauvegarde locale complète de l’instance avant synchronisation :
`sanctuary-backups/before-beta289-20261026T011713/instance.tar.gz`,
230 400 000 octets. La publication Web beta.280 reste disponible pour reprise.
Le ping Minecraft via `beta.sanctuary-minecraft.net` et son SRV 25567 répond
en 26.3, 0/20, depuis le Mac sur le LAN. **L’accès depuis un réseau extérieur
n’a pas été testé.** Les vérifications graphiques de cette livraison ciblent
Vulkan uniquement. Reçus détaillés ignorés sous `build/release289/`.
## Artefacts vérifiés
- `Sanctuary-beta.289.mrpack` : 16,715,428 octets ; SHA-256 `7a8935225a97119ba278f8a7ccd51aae7efef588244be782661545c67270b8fc`.
- `Sanctuary-Prism-beta.289.zip` : 92,417 octets ; SHA-256 `30dd37f3a6e1d79f8b443f6312918258276c85d5b6a5e7fbdc9c3fea7562dbf2`.
- `Sanctuary-Server-beta.289.zip` : 4,036,416 octets ; SHA-256 `5b86b16e9d0fd944363016b79df397391eda88f24dc80b9cf0e2bd2f71fe75d9`.
- JAR Sanctuary SHA-256 : `feec13d0b0e708473179ac3234060fb58858d94d19bc9ddf9b7137512875dc41`.
+100
View File
@@ -0,0 +1,100 @@
# Sanctuary — serveur Linux avec packwiz
Prérequis : Java 25, Python 3.11 ou plus récent, Linux et accès HTTPS sortant.
Plateforme épinglée : Minecraft **26.3**, Fabric Loader **0.19.5**, Fabric API
**0.160.5+26.3**. Le même JAR Sanctuary contient le fork JEI
**30.32.0-sanctuary.4**, son réseau commun et son interface client.
## Préparer le kit
Dans les sources : `./gradlew check build assemblePack assembleServerPack`.
Le kit est `build/Sanctuary-Server-beta.289.zip` ; les manifestes locaux sont
dans `build/packwiz-server/`. Le ZIP est un amorçage : il télécharge les mods
depuis le canal au premier démarrage, il n'est pas une copie hors ligne du pack.
Il n'est utilisable sur le canal public qu'après publication de cette version.
Décompresser le kit **une seule fois**, dans un dossier serveur neuf. Pour
reprendre une installation existante, arrêter le service, sauvegarder le dossier
entier, vérifier Minecraft/Loader et poser uniquement les fichiers du kit en
conservant le monde et la configuration. Ne pas écraser un fichier déjà présent
sans comparaison. Un ancien JEI externe géré par packwiz est retiré par le nouvel
index ; un JAR ajouté manuellement doit être retiré après sauvegarde pour éviter
de charger une autre version du fork. Conserver `config/jei/`.
Canal prévu, publié avec le client dans le même commit de distribution :
```text
https://git.botsu.net/koka/sanctuary-beta/raw/branch/packwiz/server/pack.toml
```
`./server.sh update` synchronise les mods sans démarrer Minecraft.
`./server.sh start` synchronise puis lance Fabric en console. En cas d'échec,
le serveur ne démarre pas. Accepter l'EULA Minecraft et régler le serveur
explicitement avant ouverture aux joueurs ; le kit ne les remplit pas.
Le lanceur Fabric télécharge les fichiers Mojang/Loader nécessaires au lancement.
Les outils de démarrage sont épinglés par hash dans `.tools/platform.json`.
Un changement de Minecraft ou Loader est refusé avant mise à jour et exige un
nouveau kit et un contrat de migration. Le verrou empêche une mise à jour en
parallèle d'un serveur lancé par ce kit. Arrêter aussi tout service historique
qui utiliserait un autre lanceur.
Variables facultatives : `SANCTUARY_JAVA` (chemin du binaire Java),
`SANCTUARY_MEMORY` (`4G` par défaut), `SANCTUARY_PACK_URL` (autre canal ou
serveur HTTP de laboratoire). Les variables sont héritées de l'environnement ;
le script ne charge pas automatiquement un `.env`.
Un exemple systemd est fourni sous `examples/sanctuary-beta.service`.
Installer le kit dans `/srv/minecraft/sanctuary-beta`, créer l'utilisateur
`minecraft`, donner ce dossier à cet utilisateur et placer l'environnement
privé dans `/etc/sanctuary/beta.env` (root, mode 600). Copier ensuite l'unité
dans `/etc/systemd/system/`, recharger systemd et activer le service. Le mode
`KillMode=mixed` transmet l'arrêt au lanceur, qui attend la sauvegarde Java
avant de rendre le verrou de mise à jour.
## Relier Sanctuary Web
L'API d'accueil et la communauté sont deux services existants distincts.
Le pack ne contient ni secret ni configuration de production.
Pour l'accès/candidature, fournir dans l'environnement du service Minecraft :
```text
SANCTUARY_APP_URL=https://sanctuary-minecraft.net
SANCTUARY_API_TOKEN=<jeton API du site>
```
Le site doit fournir les POST authentifiés `/api/v1/access-codes/verify` et
`/redeem`, avec les reçus d'admission de beta.166. Aucun appel d'essai ne doit
consommer un vrai code de joueur. Sans ces deux paramètres, le mod autonome
laisse l'accueil sans code ; une configuration partielle ferme l'accueil.
Pour Gazette/Tableau et les statistiques, reprendre le **serverId existant**
dans `config/sanctuary-community.json`, configurer `mode: database`, le JDBC
MariaDB, l'utilisateur et `passwordEnv: SANCTUARY_COMMUNITY_DB_PASSWORD`.
Le mot de passe est uniquement dans l'environnement. Utiliser le même UUID dans
`SANCTUARY_COMMUNITY_SERVER_ID` côté Laravel. L'exemple sous `examples/` doit être
complété, puis copié volontairement ; il ne remplace aucune configuration.
Garder `online-mode=true` pour l'identité et la liaison des comptes.
Les migrations SQL sont celles du site et doivent être vérifiées sur la base
cible avant activation. Basculer du mode fichier vers MariaDB n'importe pas les
anciennes publications : cela demande un contrat de migration séparé.
Le kit n'effectue aucune migration SQL ni modification de sauvegarde.
## Mises à jour et retour arrière
Arrêter le service, sauvegarder, lancer `./server.sh update`, vérifier les logs,
puis redémarrer le même dossier serveur. `packwiz.json` doit rester présent.
Le pack ne possède que les mods Fabric API et Sanctuary. Il ne gère ni monde,
`server.properties`, `eula.txt`, `config/`, listes de joueurs, permissions,
captures ou secrets. Les réglages des opérateurs restent en place.
Sauvegarder aussi l'identifiant d'admission et ses reçus avec le monde, ainsi
que les configurations et l'UUID communautaire. Un retour arrière reprend le
dernier artefact immuable et la sauvegarde correspondant à son contrat de
données ; ne pas supprimer de journaux pour forcer le chargement.
Référence : [installation packwiz sur serveur](https://packwiz.infra.link/tutorials/installing/packwiz-installer/#using-a-modpack-with-a-server).
Dans le dépôt, consulter aussi `docs/inscription-web-beta166.md` et
`docs/community-contract-v1.md` pour les contrats et droits SQL complets.
+92
View File
@@ -0,0 +1,92 @@
# ZOMBIES-285 — Smoker, lasso et traction en tyrolienne
Branche `codex/smoker`, sur le socle local beta.284 conservé. État : implémenté et vérifié localement en beta.285.
## Contrat demandé
Le Smoker doit toucher réellement sa cible avec un lasso avant de la ramener
vers lui par un trajet de tyrolienne. Le projectile garde sa direction de tir :
une esquive pendant le vol, un obstacle ou un tir trop court peut le faire rater.
- Modèle adulte du zombie vanilla 26.3, t-shirt gris clair et pantalon vert
olive recolorés par script, tête/peau/chaussures/UV/alpha conservés.
Identifiants `sanctuary:smoker` et `sanctuary:smoker_spawn_egg`. Aucun bébé,
y compris via œuf, données chargées ou proxy du familier ; pas de noyé.
- Sauvage : 10 PV, vitesse 0,23, mêlée 3, poursuite 36 blocs. Tir à 4–14 blocs
avec ligne de vue, préparation de 18 ticks, lasso à 1,05 bloc/tick, recharge
de 160 ticks engagée au lancer, même si le lasso rate.
- Un câble visible relie le Smoker au lasso en vol, puis à la cible touchée.
Un seul câble par source et une seule traction par victime. La source reste
immobile. Traction à **0,58 bloc/tick**, même vitesse que les tyroliennes,
jusqu'à environ 1,8 bloc de la source, sans pendule et sans dégâts périodiques.
- Le volume complet de la victime est vérifié tous les 0,20 bloc : mur, bordure,
hauteur invalide ou chunk non chargé arrêtent la traction. Aucun passage à
travers les blocs, aucune génération de terrain ou installation de poulie.
La gravité reprend ensuite normalement ; les chutes restent possibles.
- Blesser la source, frapper le crochet, tuer la source, quitter la dimension,
téléporter l'un des acteurs ou dépasser la durée coupe le câble. Le trajet
hostile impose le déplacement après une touche ; s'accroupir ne le désactive
pas. Les montures/passagers, créatifs/spectateurs et boss ne sont pas capturés.
- Familier rare, adulte miniature à taille variable persistante : 12 PV,
vitesse 0,26, lasso automatique et G à 14 blocs. Le tir doit aussi toucher ;
dégâts d'impact et contrôle passent par les protections communes des familiers
(propriétaire, alliés, claims, duels et résistance aux contrôles répétés).
- Œuf créatif, apparition rare dans les habitats de zombies de l'Overworld,
poids 4, un individu. Recharge sauvegardée ; câble et trajet transitoires non
sauvegardés. Aucun identifiant ou format existant remplacé.
## Vérification
- **19 tests ciblés Smoker** réussis : 18 nouveaux scénarios et le contrôle
du framework. Œuf/profil/spawn, adulte, préparation et recharge au lancer,
contact réel préalable au déplacement, vitesse 0,58, esquive en vol, mur,
obstacle bas sur le corps, blessure de la source même après guérison, crochet
coupé, source retirée/déplacée, téléportation et créatif, exclusivité des
trajets, sauvegarde sans corde/bébé, chunk non chargé, G avec dégâts/contrôle
communs, raté sans remboursement, annulation par ordre, propriétaire/animaux/
boss protégés, arrêt avant une cellule interdite et tailles 120 à 6000.
- **77 régressions** réussies dans une autre session neuve : tyroliennes fixes
et portatives, Boomer, Spitter, Charger, Infecté et socle des familiers. Les
deux suites incluent chacune le contrôle du framework. Le mélange des fixtures
de joueurs simulés du Smoker avec les scénarios d'IA asynchrones a provoqué
des interférences dans un monde partagé ; les suites sont donc isolées,
sans modifier les tests des infectés précédents. Journaux :
`build/smoker285-check-build-pack.log` et `build/smoker285-regressions.log`.
- `./gradlew check build assemblePack` réussi en **2 min 49 s**, avec
`-PsanctuaryFocusedTests=smoker285 -PsanctuaryAtlasOnly=true`. Les régressions
utilisent `ziplines242,boomer284,spitter283,charger282,infected280,familiar260,companions`
avec le même monde plat explicite de graine `0`, dans une session distincte.
- Parcours client natif **Vulkan / MoltenVK 1.4.2 / Apple M1**, Java 25,
Minecraft 26.3, Loader 0.19.5, monde neuf de graine `285` : réussi avec arrêt
normal du client. Appui réel sur G, vol puis touche et traction de l'ennemi,
câble natif segmenté, recharge, trois tailles adultes 500/1000/1500 et
infobulles/écran FR/EN GUI 2/3/4 vérifiés.
- Le même parcours joue le rôle de la victime : un déplacement latéral réel
au clavier fait rater le premier lasso. Le second touche réellement, puis
transporte le joueur malgré Reculer/Accroupir, jusqu'au Smoker. Position du
joueur vérifiée côté serveur et dans le client rendu, câble supprimé à
l'arrivée, gravité normale conservée. Le familier reprend son positionnement
de combat habituel après avoir achevé sa traction ; l'arrivée est contrôlée
par rapport au point d'ancrage effectif du tir.
- Texture 64×64 comparée en RGBA au zombie vanilla épinglé : **331 pixels de
t-shirt et 239 de pantalon** recolorés, coordonnées/alpha/tête inchangés.
Les quatre anciennes textures sont identiques. Œuf 16×16 comparé à sa
recoloration attendue, silhouette intacte. Aucun outil de génération d'images.
- JAR et export MRpack : ZIP intègres, version beta.285, dépendances exactes,
classes, œuf, modèle, texture, profil, butin, tags et traductions présents.
Classes de tests exclues ; JAR du pack identique au JAR compilé.
Le pack beta.284 précédent est conservé octet pour octet.
[Pack local beta.285](../build/Sanctuary-beta.285.mrpack), **16 666 919 octets**.
SHA-256 : `49dbe9798ecd567f6f53b0482515682b25f4a0dc2c09c24126a3d1579cacf5d0`.
[Reçu de validation et empreintes](../build/smoker285-validation.json).
![Smoker adulte et familier miniature](../build/smoker285-screenshots/0003_smoker285-miniature-1000.png)
![Joueur capturé par le câble](../build/smoker285-screenshots/0012_smoker285-player-captured.png)
Livraison locale uniquement : aucun tag/release, canal packwiz, serveur personnel
ou instance Prism modifié. Les œufs restent créatifs ; leur acquisition en
survie relève d'un autre ticket. Essais de transport sur serveur natif et
serveur intégré ; latence de clients distants non couverte. Aucun monde
personnel ni format de sauvegarde existant modifié.
+80
View File
@@ -0,0 +1,80 @@
# ZOMBIES-283 — Spitter et zone d'acide
Branche `codex/spitter`, sur le socle local beta.282 conservé. État : implémenté et vérifié localement en beta.283.
## Contrat demandé
Le Spitter utilise toujours le modèle adulte du zombie, un t-shirt orange et
un pantalon bleu ardoise. Son œuf est `sanctuary:spitter_spawn_egg` et son
familier reprend l'adulte en miniature, avec le trait de taille existant.
Les textures sont recolorées par script à partir du PNG vanilla 26.3, avec
peau, visage, chaussures, transparence et UV conservés.
**Le comportement diffère de 26.2**, selon le retour du créateur : le crachat
ne blesse ni n'empoisonne directement. Il dépose à l'impact une **zone verte
persistante**, utilisant les particules et le rayon d'un nuage de potion.
Une attente de 10 ticks laisse réagir, puis la zone inflige 1 dégât par seconde
aux entités admissibles qui restent dedans. En sortir arrête immédiatement les
dégâts ; aucun poison ou ralentissement ne prolonge l'effet après la sortie.
- Spitter : 10 PV de base, vitesse 0,24, poursuite 36 blocs, mêlée 3 si la cible
approche à moins de 3 blocs. À 3–15 blocs avec ligne de vue : préparation de
14 ticks, crachat visible, recharge de 100 ticks.
- Zone sauvage : rayon initial de 2,5 blocs qui diminue, durée de 100 ticks
après l'attente, au sol ; les murs et étages séparent les victimes. Un seul
nuage actif par source, le suivant remplace le précédent. Source et autres
infectés de la famille immunisés contre la zone sauvage.
- Projectile : collision native, durée et distance bornées, arrêt avant les
chunks non chargés. Une surface solide proche de l'impact est nécessaire.
Aucun bloc changé, aucun terrain ou chunk généré par la mécanique.
- Œuf créatif et spawn rare dans les habitats de zombies de l'Overworld :
poids 4, un seul individu. Aucun bébé, pas de transformation en noyé.
- Familier rare : 12 PV, vitesse 0,28, positionnement à distance ; initiative
de petite zone et technique G de zone persistante de 2,5 blocs / 5 secondes.
Dégâts périodiques et destinataires passent par les règles des familiers,
avec protection du propriétaire, des alliés et des duels. Les tirs automatiques
attendent la fin du projectile puis de la zone active, afin de préserver G.
- Projectiles et zones sont transitoires et ne sont pas sauvegardés. La recharge
du mob est conservée ; aucun format existant ni identifiant livré modifié.
Les autres infectés et l'acquisition des œufs en survie restent hors périmètre.
Aucun monde personnel ouvert ; publication ou mise à jour Prism non demandée.
## Vérification
- `./gradlew check build assemblePack` réussi, avec
`-PsanctuaryFocusedTests=spitter283,charger282,infected280,familiar260,companions`
et `-PsanctuaryAtlasOnly=true` : **54 tests serveur**, dont **9 nouveaux**.
Fixtures plates explicites, sans ouvrir de sauvegarde personnelle.
- Couverture Spitter : œuf/profil/spawn, adulte et tailles persistantes, délai,
pulses, sortie sans statut, rétrécissement/expiration, murs/étages, famille,
remplacement et nettoyage, non-sauvegarde, surface absente, chunks non chargés,
vraie préparation/tir sauvage, interruption/recharge conservée, vrai projectile
de familier, propriétaire et foule de 13 animaux protégés. Les entités immunes
sont écartées avant le budget de 12 victimes admissibles par pulse.
- Parcours client natif **Vulkan / MoltenVK 1.4.2 / Apple M1**, graine `283`,
Minecraft **26.3**, Fabric Loader **0.19.5**, Java **25** : réussi avec arrêt
normal du client. Appui réel sur G, projectile/zone/dégâts, sortie sans nouveaux
dégâts, recharge et préparation/zone sauvage vérifiés. Modèle adulte aux tailles
500/1000/1500 et écran/infobulles FR/EN aux échelles GUI 2/3/4 contrôlés.
- Le parcours intégré a révélé une collision prédite côté client qui retirait
l'entrée serveur du projectile avant l'impact réel. Le suivi et la résolution
des projectiles gérés restent désormais côté serveur ; le test G couvre ce cas.
- Texture 64×64 : **331 pixels de t-shirt et 239 de pantalon** recolorés.
Résultat RGBA comparé au script et au PNG natif épinglé ; visage, peau,
chaussures, alpha et UV conservés. Empreintes des textures Infecté/Chargeur
précédentes inchangées. Icône d'œuf historique comparée octet par octet.
- JAR et export MRpack : ZIP intègres, version exacte beta.283, versions
Minecraft/Loader vérifiées, classes et données Spitter présentes, tests exclus.
Le JAR contenu dans le pack correspond exactement au JAR compilé.
[Pack local beta.283](../build/Sanctuary-beta.283.mrpack), **16 634 641 octets**.
SHA-256 : `399077b7dba950550fcbfd2492859e26acc0939d45e8206673a44baa88a9a77f`.
[Reçu de validation et empreintes](../build/spitter283-validation.json).
![Zone persistante et tenue orange sous Vulkan](../build/spitter283-screenshots/0000_spitter283-acid-zone.png)
Les essais utilisent des mondes neufs de développement. La distribution reste
locale : aucun tag/release, canal packwiz, serveur personnel ou instance Prism
modifié. L'acquisition des œufs en survie et les autres infectés restent à traiter.
+235
View File
@@ -0,0 +1,235 @@
# UPDATE-OPS — développer et publier Sanctuary
Procédure de reprise demandée par Koka le **26 octobre 2026**, branche
`codex/update-workflow`. Elle relie les sources du jeu, packwiz, le serveur Beta,
Sanctuary Web et l'instance Prism locale. Les anciennes livraisons restent dans
[packwiz.md](packwiz.md) ; le parcours de téléchargement actuel est décrit dans
[beta-download.md](beta-download.md).
## Reprendre un chantier
Lire `AGENTS.md`, `README.md`, le ticket et l'état Git des dépôts concernés.
Choisir la branche source qui contient les dernières livraisons voulues, puis
travailler sur `codex/<sujet>`. Les branches des collègues peuvent avancer : ne
pas prendre automatiquement `main`, le plus grand compteur ou un autre worktree
comme base. Comparer les commits et les changements avant toute intégration.
| Élément | Référence à retrouver |
| --- | --- |
| Sources du jeu | `https://git.botsu.net/koka/sanctuary-beta.git` |
| Sources du site | `https://git.botsu.net/chris/sanctuary-web.git` |
| Canal client | `https://git.botsu.net/koka/sanctuary-beta/raw/branch/packwiz/pack.toml` |
| Canal serveur | Même adresse, avec `packwiz/server/pack.toml` |
| Site actif | `https://sanctuary-minecraft.net` — Sanctuary Web, pas un ancien site de release |
| Entrée Minecraft | `beta.sanctuary-minecraft.net` — SRV vers le port public 25567 |
| Téléchargement unique du site | `/downloads/sanctuary-prism.zip`, nom reçu `Sanctuary-Beta.zip` |
Développer/tester localement ne publie pas. Un simple `commit et push` pousse
les sources ; il ne redémarre pas les services. Quand Koka demande une livraison
complète ou « mets tout à jour », dérouler les cibles demandées jusqu'à leur
vérification sans redemander les choix déjà établis. Cette note mémorise la
procédure ; elle ne lance aucune publication périodique.
## Préparer une livraison du jeu
Pour chaque nouveau binaire Sanctuary, prendre le prochain numéro **libre**
`beta.xxx` après examen des branches/releases. Aligner `mod_version` et
`pack_version` dans `gradle.properties`, puis `version` dans `packwiz/pack.toml`.
Une modification documentaire ou du site seule ne change pas ce compteur.
Utiliser **Java 25** et **Python 3.11+**. Sur le Mac actuel, `python3` peut encore
être Python 3.9 : utiliser `python3.14` pour les outils opérationnels. Aucun
changement de Minecraft/Fabric ou de format de sauvegarde n'est implicite.
```sh
./gradlew check build assemblePack assembleServerPack
python3.14 scripts/pack.py prism https://git.botsu.net/koka/sanctuary-beta/raw/branch/packwiz/pack.toml
```
Ajouter les vérifications utiles au ticket ; les essais graphiques du jeu
ciblent **Vulkan uniquement**. Le profil ciblé employé pour beta.280 était
`-PsanctuaryFocusedTests=recipes,progression -PsanctuaryAtlasOnly=true` : ce reçu
historique ne remplace pas les tests d'un futur changement touchant un autre
comportement. Consigner les limites, ne pas présenter un contrôle exclu comme
réussi.
Les sorties attendues pour la version choisie sont :
- `mods/sanctuary/build/libs/sanctuary-beta.xxx.jar` : un JAR Sanctuary contenant JEI ;
- `build/Sanctuary-beta.xxx.mrpack` : format conservé pour les liens existants ;
- `build/Sanctuary-Prism-beta.xxx.zip` : instance prête pour le bouton du site ;
- `build/Sanctuary-Server-beta.xxx.zip` : kit Linux ;
- les métadonnées client/serveur de `build/packwiz-release/` après publication.
Vérifier les versions, SHA-256, index et contenus avant publication. Ne pas
versionner les binaires, dépendances, mondes ni secrets. Le ZIP Prism contient
seulement sa configuration, son icône et le bootstrap ; le premier lancement
télécharge les mods. Ne pas lui ajouter des réglages ou sauvegardes personnels.
## Publier les artefacts et le canal
Committer et pousser la branche source vérifiée, puis exécuter depuis cette
même branche propre, en remplaçant `beta.xxx` et le chemin des notes :
```sh
python3.14 scripts/publish_pack.py \
--notes-file docs/notes-du-ticket.md \
--asset build/Sanctuary-beta.xxx.mrpack \
--asset build/Sanctuary-Prism-beta.xxx.zip \
--asset build/Sanctuary-Server-beta.xxx.zip
```
Cette commande crée/vérifie la release, joint les artefacts, publie ensemble
les profils client et serveur et vérifie leurs téléchargements publics.
Elle utilise les identifiants du gestionnaire Git en mémoire. Le tag est
exactement `beta.xxx`, sans `v`. Une release déjà publiée reste immuable : ne
pas déplacer le tag ou remplacer une pièce jointe différente. Une reprise se
fait depuis le commit source exact de la release, pas depuis un commit
documentaire ultérieur portant le même compteur.
`publish_pack.py` avance déjà le canal : ne pas faire un deuxième push manuel
du pack. Il ne met à jour ni le serveur, ni Prism, ni le téléchargement Web.
Tester deux synchronisations avec le véritable installateur dans un dossier
isolé ; vérifier l'absence de doublon Sanctuary et la stabilité du second
passage. Conserver le reçu et les hashes dans `build/`, hors de Git.
## Mettre à jour le serveur Beta dédié
La cible est la **CT 350 sur le nœud `gpu`**, dossier
`/srv/minecraft/sanctuary-beta`, service `sanctuary-beta.service`, utilisateur
`minecraft`. La CT 300 Alpha et les autres serveurs sont hors cible.
L'accès et les chemins propres au poste sont dans la compétence locale
`sanctuary-updates`, référence `references/local-environment.md`.
Quand la livraison comprend le serveur, après les builds et avant d'avancer
le canal, arrêter proprement la Beta. Attendre la fin de Java et de la
sauvegarde, puis sauvegarder son dossier entier et son environnement privé
dans un emplacement root privé hors du dossier géré par packwiz. Noter le
commit du canal et les hashes précédents. Cela évite que des clients à jour
rejoignent brièvement l'ancien serveur.
Après publication du canal, ces commandes s'exécutent **dans la CT 350** :
```sh
systemctl stop sanctuary-beta.service
runuser -u minecraft -- /srv/minecraft/sanctuary-beta/server.sh update
systemctl start sanctuary-beta.service
systemctl is-active sanctuary-beta.service
journalctl -u sanctuary-beta.service -n 100 --no-pager
```
La sauvegarde précède `update` ; le premier `stop` ci-dessus reste nécessaire
si cette étape est exécutée séparément. Ne redémarrer qu'après une mise à jour
réussie. L'outil refuse un changement implicite de Minecraft/Fabric et une
mise à jour concurrente au lancement. Il ne charge pas automatiquement
`/etc/sanctuary/beta.env` : reprendre seulement les variables non secrètes
nécessaires si le Java ou le canal diffèrent de ceux du kit. Le service charge
l'environnement privé au démarrage.
Conserver le monde, `packwiz.json`, `config/`, `server.properties`,
`eula.txt`, les listes de joueurs, opérateurs, permissions et reçus d'admission.
Garder l'UUID communautaire existant. Ne pas recréer une CT, réextraire un kit
sur tout le serveur ou réinitialiser Gazette/Tableau/Intendance pour une mise
à jour courante. [Contrat complet du serveur](server-pack.md).
Contrôler le JAR effectivement chargé, Fabric API, l'admission sans consommer
un vrai code, les logs, le ping Minecraft et la fraîcheur des métriques Web.
Un ping depuis le LAN via l'IP publique ne prouve pas un accès depuis un réseau
extérieur. Ne pas remettre l'ancien secours UPnP : la règle Bbox permanente
existe déjà.
## Actualiser le téléchargement et la version du site
Après vérification de la CT Beta, transférer le script versionné
`scripts/publish_web_pack.py` sur **`gpu`** dans un dossier temporaire privé et
vérifier que sa copie a le même SHA-256. Lancer sur `gpu`, où `pct` existe :
```sh
python3 /chemin/temporaire/publish_web_pack.py \
--web-ct 320 --server-ct 350 --version beta.xxx \
--mrpack-url https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.xxx/Sanctuary-beta.xxx.mrpack \
--prism-url https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.xxx/Sanctuary-Prism-beta.xxx.zip \
--pack-url https://git.botsu.net/koka/sanctuary-beta/raw/branch/packwiz/pack.toml \
--dry-run
```
Si le contrôle passe, reprendre la même commande sans `--dry-run`. Ce script
vérifie le serveur en marche, les deux profils, les archives et leurs hashes,
puis bascule atomiquement `storage/app/distribution/current` dans la CT Web.
Il ne modifie pas le code du site et ne redémarre pas Minecraft.
Le footer et le survol du bouton lisent cette publication via
`App\Services\PackDistribution`. Ne pas remplacer leurs versions en dur ni
reconfigurer le bouton à chaque livraison. Le bouton **Télécharger** donne
directement le ZIP prêt ; le joueur l'importe une fois dans Prism puis lance
pour télécharger le pack. Aucun guide manuel ni téléchargement séparé ne
remplace ce parcours. L'ancien `/downloads/sanctuary-beta.mrpack` reste valide.
Vérifier le ZIP reçu anonymement (HTTP 200, nom `Sanctuary-Beta.zip`, SHA-256),
le footer et le survol, puis le lien MRpack historique. Une bascule de version
ne nécessite pas de reconstruire le cache Laravel.
## Livrer une modification du site
Travailler dans les sources **Sanctuary Web** et lire leur `AGENTS.md` ainsi
que les règles/compétences applicables. Tester les comportements modifiés,
formater le PHP avec `vendor/bin/pint --dirty --format agent`, construire les
assets concernés et vérifier le rendu navigateur. Les contrôles existants
pour cette chaîne sont `tests/Feature/DownloadTest.php` et
`tests/Feature/ServerMetricsTest.php`.
**La CT 320 est un checkout de production avec des changements préexistants.**
Ne pas y faire `git pull`, `reset --hard`, `clean`, ou une copie avec suppression
depuis `origin/main`. Inspecter les fichiers concernés et leurs hashes, préparer
un patch limité conservant les autres différences, sauvegarder les fichiers
et caches affectés dans `/root/sanctuary-beta-maintenance/`, puis appliquer
avec refus si les fichiers ont changé depuis l'inspection. Conserver
propriétaires/modes et écrire les assets avant les vues qui les référencent.
Ne vider/reconstruire que les caches concernés : `view:clear` pour Blade ;
`config:cache` si la configuration change ; `route:cache` si des routes changent
et étaient déjà mises en cache. Exécuter Artisan comme `www-data` depuis
`/var/www/sanctuary`. Aucun changement de base, réinitialisation de données ou
migration ne découle d'une simple livraison de présentation.
Contrôler le site public, les métriques et la distribution. Les comptes admins,
groupes staff et permissions restent identiques ; comparer leurs empreintes si
l'intervention touche leur contexte. Les scripts ponctuels dans `build/`
restent des reçus historiques, à ne pas rejouer avec leurs anciennes hypothèses.
La procédure complémentaire du dépôt Web est `docs/updates.md`.
## Synchroniser l'instance locale existante
Retrouver l'instance **Sanctuary Beta** et lire sa configuration. Son dossier
historique peut encore s'appeler `Sanctuary-0.1.0-alpha.1` ; ce nom n'indique
pas sa version courante. Ne pas créer une nouvelle instance à chaque livraison,
ne pas réimporter le ZIP et ne pas toucher aux anciennes instances 26.2.
Si la demande inclut la mise à jour locale : attendre la fermeture du jeu,
sauvegarder les fichiers gérés et les métadonnées de l'instance hors de `mods/`,
relever les hashes des données personnelles, puis exécuter sa commande packwiz
avant lancement avec ses vrais `INST_JAVA`, `INST_MC_DIR`, `INST_DIR`.
Pour une synchronisation sans lancer Minecraft, ajouter `-g` à l'installateur.
Ne jamais lancer `instance.cfg` comme du shell ni évaluer une chaîne de commande
non vérifiée. Les chemins avec espaces doivent rester des arguments séparés.
Faire deux passages et comparer : un seul JAR Sanctuary attendu, mêmes fichiers
gérés au second passage, mêmes données personnelles avant/après. Conserver
`saves/`, captures, favoris, réglages, packs graphiques et configurations tierces.
Ne pas ouvrir un monde personnel pour prouver une simple synchronisation.
## Reprise et reçu de livraison
Pour un échec, conserver l'état/les logs, revenir au dernier artefact immuable
et à la sauvegarde compatible. Un retour du canal doit former un nouveau commit
de métadonnées sans réécrire l'historique. Une reprise du téléchargement Web
rebascule son lien `current` vers une publication complète conservée ; elle ne
change pas à elle seule le serveur ou le canal. Ne pas démarrer un ancien JAR
sur des données migrées sans vérifier le contrat de sauvegarde.
Consigner pour chaque livraison : commit source, version/tag, commit du canal,
hashes et URL des artefacts, CT/service et version chargée, publication Web,
version locale effectivement synchronisée, sauvegardes, vérifications et limites.
Mettre à jour la référence d'environnement de la compétence lorsque les cibles
ou chemins changent. La mémorisation de cette procédure ne prétend pas que
toutes les installations sont déjà synchronisées.
+18 -1
View File
@@ -522,7 +522,24 @@ Un sneak + clic droit permet de porter un mob sur sa tête, avec une limite d'un
### Hostiles et fantômes
Une famille de zombies inspirée de Left 4 Dead comprend le **Hunter**, le **Charger**, le **Spitter**, le **Boomer** et l'**Infecté**. Le **Tank** et le **zombie géant** sont des boss futurs.
Une famille de zombies inspirée de Left 4 Dead comprend le **Hunter**, le **Charger**, le **Spitter**, le **Boomer**, le **Smoker** et l'**Infecté**. Le **Tank** et le **zombie géant** sont des boss futurs.
Les infectés n'ont pas de variante bébé. Leurs familiers utilisent le modèle
adulte en miniature, avec une taille variable propre à chaque individu.
Le Spitter dépose une zone d’acide persistante au sol, comme une potion
persistante : sortir de la zone arrête les dégâts, sans poison résiduel.
Le Boomer projette une bile à courte portée qui attire les zombies présents
vers la cible enduite, et éclabousse en mourant sans abîmer le terrain. Son
familier affaiblit les ennemis par sa bile et reste vivant après son pouvoir.
Le Smoker doit toucher sa cible avec un lasso physique qui peut rater, puis
la ramène vers lui par une traction de tyrolienne. Les obstacles ou les
blessures de la source coupent ce câble temporaire.
Le Hunter prépare puis bondit physiquement vers sa cible. Seul le contact
inflige des dégâts ; la direction reste fixe après le départ, permettant
l’esquive. Son familier miniature possède le même bond offensif.
Depuis beta.287, le zombie classique et ces six infectés partagent neuf tenues
aléatoires stables par individu. Tous gardent les bras classiques en avant. Le Hunter
conserve sa posture accroupie au sol, familiers compris.
Le système d'insomnie et les phantoms vanilla disparaissent lorsque le monde passe au temps réel. Ils laissent place à d'autres fantômes, dont une **baleine volante, fantomatique et apaisante**, qui passe autour des joueurs et peut s'inviter dans le paysage comme un photobomb.
+70
View File
@@ -0,0 +1,70 @@
# ZOMBIES-287 — tenues aléatoires et posture du Hunter
Branche `codex/zombie-appearances`, sur le socle local beta.286 conservé.
État : implémenté et vérifié ; pack local beta.287 construit et contrôlé.
## Contrat
Les neuf PNG fournis par le créateur remplacent les tenues fixes du zombie
classique et des six infectés Sanctuary. Même collection aléatoire pour tous,
choix stable par UUID natif ; les familiers utilisent leur identité persistante.
Les fichiers adultes sont copiés à l’identique, sans génération d’image.
Les bébés du zombie classique gardent leur modèle natif avec une adaptation
UV déterministe ; les infectés restent exclusivement adultes, leurs familiers
restent des adultes miniatures à taille variable.
Tous gardent les deux bras en avant avec l’animation native du zombie. Le
Hunter conserve sa posture accroupie au sol, avec la marche native des jambes ;
il se redresse pendant son bond. La même présentation s’applique aux familiers.
La posture ne change ni les collisions, ni la vitesse, ni les dégâts, ni les
règles de sauvegarde. Aucun nouvel état réseau ou format persistant n’est ajouté.
Aucun changement de génération ou d’identifiant.
## Vérification
- `python3 scripts/prepare_zombie_skins.py --check` : neuf fichiers adultes
identiques aux PNG fournis et neuf adaptations bébé conformes aux UV natifs
de `BabyZombieModel` 26.3. Reçu : `docs/zombie-skins-beta287.json`.
- Parcours `:sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuaryZombie287ClientTests=true -PsanctuaryQuickTests=true
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8
-PsanctuaryClientNoVsync=true` : **réussi**. Vulkan / MoltenVK 1.4.2 /
Apple M1, monde plat de développement temporaire, graine `287`.
Neuf tenues accessibles pour chacun des sept types ; texture stable après
mouvement et reconstruction avec la même identité ; bébé classique avec son
modèle natif ; les deux bras natifs en avant pour tous ; 21 combinaisons de
familiers (sept types × tailles 500/1000/1500), proportions adultes des
infectés, rééquipement sans changement de tenue ; Hunter réellement en marche
accroupie, puis redressé et en l’air pendant son bond.
Captures finales : `build/zombie287-screenshots/`.
- Régressions `ziplines242,boomer284,spitter283,charger282,infected280,familiar260,companions` :
**77 tests requis réussis**, en session distincte des joueurs simulés du Smoker
et des arènes du Hunter.
- Régression `smoker285` dans une autre session : **19 tests requis réussis**.
Les comptes incluent chacun leur test d’infrastructure.
- `./gradlew check build assemblePack -PsanctuaryFocusedTests=hunter286
-PsanctuaryAtlasOnly=true` : **réussi en 2 min 40 s**, y compris les
**16 tests Hunter** et tous les contrôles purs configurés par `check`.
- Export packwiz, intégrité ZIP et métadonnées vérifiés : `beta.287`,
Minecraft `26.3`, Fabric Loader `0.19.5`, un seul JAR Sanctuary identique
au fichier construit. Les dix-huit PNG correspondent au reçu ; les neuf
adultes correspondent aussi aux fichiers fournis. Classes de test exclues,
rendu natif des bras confirmé, archive beta.286 conservée à l’identique.
## Artefacts locaux
- [Pack beta.287](../build/Sanctuary-beta.287.mrpack), 16 713 076 octets.
SHA-256 : `58c9375b8de43f77d55114bc8683a524416a4cb13eca7c6e18cb6fd93c3fd6f0`.
- JAR `mods/sanctuary/build/libs/sanctuary-beta.287.jar`.
SHA-256 : `26ba7a1f488896dd7e099e24113a1049ec96ea95f3dbcf0559456bd9cb688324`.
- Reçu détaillé : `build/zombie287-validation.json`. Logs et captures finales
ignorés sous `build/zombie287-*` ; aperçu recadré de la collection :
`build/zombie287-wardrobe-preview.png`.
## Limites
La collection concerne le zombie classique et les six infectés ajoutés ici.
Les noyés, husks et zombies villageois gardent leurs modèles et tenues propres.
Les neuf fichiers adultes sont exploités tels quels ; seuls les dérivés destinés
au bébé classique sont rééchantillonnés. Aucun infecté bébé n’est introduit.
Les sauvegardes et réglages d’une installation personnelle ne sont pas touchés.
+65
View File
@@ -0,0 +1,65 @@
[
{
"source_file": "zombie.png",
"asset": "zombie1.png",
"sha256": "37cb6e101f6832172bd597341a6b1e37b215268e7ece5256079b4df30a98b4b4",
"baby_asset": "zombie1_baby.png",
"baby_sha256": "46e85f677761df2c54bed402fcc7f080342ad004e68375ad60494c229828be4d"
},
{
"source_file": "zombie2.png",
"asset": "zombie2.png",
"sha256": "3c84f78cd6c3beac5cd27b9ac4d2da4b8d0a679326dcdfc991271359aababbe6",
"baby_asset": "zombie2_baby.png",
"baby_sha256": "dbaf18b2c026b01c26dc9b3f156ca8757d43acca64a85a335e980d53046c6a58"
},
{
"source_file": "zombie3.png",
"asset": "zombie3.png",
"sha256": "af3e6a9f529276b853609e8389a6dea1aa92d3f2cdae6fdecb906424b959794a",
"baby_asset": "zombie3_baby.png",
"baby_sha256": "18249a78f397937ffdb335cf9eb3558c0a24a7bf4f488056c37227b94f880ba6"
},
{
"source_file": "zombie4.png",
"asset": "zombie4.png",
"sha256": "f7b4b9024efc810aef3c768a7a7024dbf90d2fabffd06031551d3f4bad8f4cd7",
"baby_asset": "zombie4_baby.png",
"baby_sha256": "55df3c6b44096503ca38ee5ad5322bfc957f993ebd1269b59038c8a95d7035e4"
},
{
"source_file": "zombie5.png",
"asset": "zombie5.png",
"sha256": "dc6b84f3f9b679c2de8c7cf29eaa0feb6b2762f0c9854d86a26f45123e697feb",
"baby_asset": "zombie5_baby.png",
"baby_sha256": "8fd1e604493ff472657d9162abb4b1144674cb4c8a4a333d32d7335bff9da018"
},
{
"source_file": "zombie6.png",
"asset": "zombie6.png",
"sha256": "948f1e9823a15ace01bbc3fdeae8f3a8448e4fc4f90c2d79f35fe95dac3f9e37",
"baby_asset": "zombie6_baby.png",
"baby_sha256": "08a008c5b0618242784381a74955e20ddcfcc4752b739b07eba318e1c9553daa"
},
{
"source_file": "zombie7.png",
"asset": "zombie7.png",
"sha256": "3494c1ecff8bd5dd39d483f8de3747c784dbef161a96aa731e505eea25fdd4bd",
"baby_asset": "zombie7_baby.png",
"baby_sha256": "661c6ac403226c38d15d7af40318ef4d1ccf71e9e7d4d34f69b5b182cb4e45bf"
},
{
"source_file": "zombie8.png",
"asset": "zombie8.png",
"sha256": "d9798a366c0c91629ceb7bb4414b8deaf85516e733ee6d1ff11efa568125356f",
"baby_asset": "zombie8_baby.png",
"baby_sha256": "5ea53fbe31de2ec5286906fc4589a6adf547d0b3c0c991dd81c7226a00dbcffb"
},
{
"source_file": "zombie9.png",
"asset": "zombie9.png",
"sha256": "99e918bebd7f449e9c36c123b0b4b4ad86cc0091d276785674180bd07361886b",
"baby_asset": "zombie9_baby.png",
"baby_sha256": "3391e90916558dfe31c08a9e298f058f917b0d4c64027f9deff5355912c9458d"
}
]
+2 -2
View File
@@ -9,8 +9,8 @@ loom_version=1.17.20
fabric_api_version=0.160.5+26.3
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
mod_version=beta.278
pack_version=beta.278
mod_version=beta.289
pack_version=beta.289
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.4
+4 -2
View File
@@ -27,8 +27,10 @@ le cache ignoré manque, extrait le commit fixé et applique `patches/26.3.patch
La première reconstruction nécessite Internet, Git, patch et Python 3.
`./gradlew :jei:sourcesJar` produit aussi les sources Java effectivement compilées.
Depuis beta.257, JEI est un JAR client séparé et facultatif. Sanctuary conserve
son pont de catalogue ; le retrait de JEI ne supprime aucune connaissance serveur.
Depuis beta.280, JEI est de nouveau embarqué dans Sanctuary, avec ses métadonnées
Fabric, ses entrées communes/cliente et son pont de catalogue. Il n'est plus
distribué séparément dans packwiz. La séparation facultative beta.257–278
n'a modifié aucune connaissance serveur ; ces données restent conservées.
JEI conserve ses deux bibliothèques internes nécessaires à la recherche :
- `net.mezzdev:baked-substring-index:0.1.0`, MIT, James Mitchell.
+31 -1
View File
@@ -208,6 +208,10 @@ tasks.named('processGametestResources') {
inputs.property('sanctuaryColored139ClientTests', colored139Client)
if (colored139Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.ColoredLights139ClientChecks') }
def handLightClient = providers.gradleProperty('sanctuaryHandLightClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryHandLightClientTests', handLightClient)
if (handLightClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.HandLight283ClientChecks') }
def pbr134Client = providers.gradleProperty('sanctuaryPbr134ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPbr134ClientTests', pbr134Client)
if (pbr134Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Pbr134ClientChecks') }
@@ -278,7 +282,7 @@ tasks.named('processGametestResources') {
def villageOnly = providers.gradleProperty('sanctuaryVillageOnly').map { it.toBoolean() }.getOrElse(false)
def focused = providers.gradleProperty('sanctuaryFocusedTests').getOrElse('').split(',').findAll { !it.isEmpty() }
def focusClasses = [claims278: 'ClaimBattle278GameTests', claims277: 'ClaimDemeure277GameTests', menu275: 'Menu275GameTests', claims273: 'ClaimMap273GameTests', claims272: 'ChunkClaims272GameTests', overview271: 'Inhabitant271GameTests', property270: 'Property270GameTests', providence269: 'Providence269GameTests', providence268: 'Providence268GameTests', providence265: 'Providence265GameTests', familiar260: 'Familiar260GameTests', census: 'CensusGameTestsTerrain', insects245: 'Insect245GameTests', map244: 'Atlas244GameTests', ziplines242: 'Zipline242GameTests', waystones239: 'Waystone239GameTests', waystonesLarge239: 'WaystoneLarge239GameTests', forgerie238: 'Forgerie238GameTests', aptitudes236: 'Aptitudes236GameTests', solo232: 'Solo232GameTests', diagonal231: 'Diagonal231GameTests', diagonal230: 'Diagonal230GameTests', diagonal229: 'Diagonal229GameTests', diagonal228: 'Diagonal228GameTests', diagonal227: 'Diagonal227GameTests', diagonal226: 'Diagonal226GameTests', diagonal225: 'Diagonal225GameTests', east224: 'East224GameTests', east223: 'East223GameTests', rock222: 'YellowRock222GameTests', west221: 'West221GameTests', west220: 'West220GameTests', west219: 'West219GameTests', west218: 'West218GameTests', west217: 'West217GameTests', south216: 'South216GameTests', relays215: 'Relays215GameTests', south214: 'South214GameTests', south213: 'South213GameTests', south212: 'South212GameTests', south211: 'South211GameTests', south210: 'South210GameTests', south209: 'South209GameTests', north208: 'North208GameTests', north207: 'North207GameTests', north206: 'North206GameTests', north205: 'North205GameTests', base204: 'Base204GameTests', palace173: 'Palace173GameTests', horde172: 'Horde172GameTests', horde171: 'Horde171GameTests', horde170: 'Horde170GameTests', horde169: 'Horde169GameTests', horde168: 'Horde168GameTests', horde167: 'Horde167GameTests', plans072: 'Plans072GameTests', starter052: 'Starter052GameTests', dispenser051: 'Dispenser051GameTests', head050: 'Head050GameTests', boatshead: 'BoatsHead049GameTests', poultry: 'Poultry048GameTests', starinspect: 'StarInspection047GameTests', skyflight: 'SkyFlight046GameTests', eggaudit: 'Companion044GameTests', headblocks: 'HeadBlocks043GameTests', operator: 'OperatorStars040GameTests', starter: 'Starter039GameTests', gravelayout: 'GraveInventory039GameTests', foodknowledge: 'FoodKnowledge039GameTests', graves: 'GravesFood038GameTests', realtime: 'Realtime037GameTests', corrections: 'Corrections036GameTests', collections: 'Collections035GameTests', notifications: 'Notifications034GameTests', menus: 'Menus033GameTests', aerial: 'Aerial24WorldGameTests', transit: 'Transit29WorldGameTests', village: 'Village306GameTests', drained: 'Drained307GameTests', expeditions: 'AncientExpeditions001GameTests', progression: 'Progression003GameTests', map: 'Atlas005GameTests', demeure: 'Demeure006GameTests', mining: 'Mining008GameTests', building: 'Building009GameTests', inventory: 'Inventory010GameTests', cycle: 'Cycle011GameTests', inventoryflow: 'Inventory012GameTests', sorting: 'Inventory014GameTests', tutorial: 'Tutorial016GameTests', movement: 'Movement017GameTests', accessories: 'Accessory018GameTests', companions: 'Companion019GameTests', familiar: 'Familiar020GameTests', carry: 'Carry027GameTests', familiarhit: 'Familiar029GameTests', refonte: 'Companion032GameTests', lights: 'Dynamic030GameTests', cosmetics: 'HeadCosmetic021GameTests', startup: 'StartupPerformanceGameTests', recipes: 'Recipes024GameTests']
def focusClasses = [hunter286: 'Hunter286GameTests', smoker285: 'Smoker285GameTests', boomer284: 'Boomer284GameTests', spitter283: 'Spitter283GameTests', charger282: 'Charger282GameTests', infected280: 'Infected280GameTests', claims278: 'ClaimBattle278GameTests', claims277: 'ClaimDemeure277GameTests', menu275: 'Menu275GameTests', claims273: 'ClaimMap273GameTests', claims272: 'ChunkClaims272GameTests', overview271: 'Inhabitant271GameTests', property270: 'Property270GameTests', providence269: 'Providence269GameTests', providence268: 'Providence268GameTests', providence265: 'Providence265GameTests', familiar260: 'Familiar260GameTests', census: 'CensusGameTestsTerrain', insects245: 'Insect245GameTests', map244: 'Atlas244GameTests', ziplines242: 'Zipline242GameTests', waystones239: 'Waystone239GameTests', waystonesLarge239: 'WaystoneLarge239GameTests', forgerie238: 'Forgerie238GameTests', aptitudes236: 'Aptitudes236GameTests', solo232: 'Solo232GameTests', diagonal231: 'Diagonal231GameTests', diagonal230: 'Diagonal230GameTests', diagonal229: 'Diagonal229GameTests', diagonal228: 'Diagonal228GameTests', diagonal227: 'Diagonal227GameTests', diagonal226: 'Diagonal226GameTests', diagonal225: 'Diagonal225GameTests', east224: 'East224GameTests', east223: 'East223GameTests', rock222: 'YellowRock222GameTests', west221: 'West221GameTests', west220: 'West220GameTests', west219: 'West219GameTests', west218: 'West218GameTests', west217: 'West217GameTests', south216: 'South216GameTests', relays215: 'Relays215GameTests', south214: 'South214GameTests', south213: 'South213GameTests', south212: 'South212GameTests', south211: 'South211GameTests', south210: 'South210GameTests', south209: 'South209GameTests', north208: 'North208GameTests', north207: 'North207GameTests', north206: 'North206GameTests', north205: 'North205GameTests', base204: 'Base204GameTests', palace173: 'Palace173GameTests', horde172: 'Horde172GameTests', horde171: 'Horde171GameTests', horde170: 'Horde170GameTests', horde169: 'Horde169GameTests', horde168: 'Horde168GameTests', horde167: 'Horde167GameTests', plans072: 'Plans072GameTests', starter052: 'Starter052GameTests', dispenser051: 'Dispenser051GameTests', head050: 'Head050GameTests', boatshead: 'BoatsHead049GameTests', poultry: 'Poultry048GameTests', starinspect: 'StarInspection047GameTests', skyflight: 'SkyFlight046GameTests', eggaudit: 'Companion044GameTests', headblocks: 'HeadBlocks043GameTests', operator: 'OperatorStars040GameTests', starter: 'Starter039GameTests', gravelayout: 'GraveInventory039GameTests', foodknowledge: 'FoodKnowledge039GameTests', graves: 'GravesFood038GameTests', realtime: 'Realtime037GameTests', corrections: 'Corrections036GameTests', collections: 'Collections035GameTests', notifications: 'Notifications034GameTests', menus: 'Menus033GameTests', aerial: 'Aerial24WorldGameTests', transit: 'Transit29WorldGameTests', village: 'Village306GameTests', drained: 'Drained307GameTests', expeditions: 'AncientExpeditions001GameTests', progression: 'Progression003GameTests', map: 'Atlas005GameTests', demeure: 'Demeure006GameTests', mining: 'Mining008GameTests', building: 'Building009GameTests', inventory: 'Inventory010GameTests', cycle: 'Cycle011GameTests', inventoryflow: 'Inventory012GameTests', sorting: 'Inventory014GameTests', tutorial: 'Tutorial016GameTests', movement: 'Movement017GameTests', accessories: 'Accessory018GameTests', companions: 'Companion019GameTests', familiar: 'Familiar020GameTests', carry: 'Carry027GameTests', familiarhit: 'Familiar029GameTests', refonte: 'Companion032GameTests', lights: 'Dynamic030GameTests', cosmetics: 'HeadCosmetic021GameTests', startup: 'StartupPerformanceGameTests', recipes: 'Recipes024GameTests']
focused.each { if (!focusClasses.containsKey(it)) throw new GradleException('Unknown focused native test: ' + it) }
inputs.property('focusedNative', focused)
def yellowRock222Client = providers.gradleProperty('sanctuaryYellowRock222ClientTests').map { it.toBoolean() }.getOrElse(false)
@@ -400,6 +404,30 @@ tasks.named('processGametestResources') {
inputs.property('insects245Client', insects245Client)
if (insects245Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Insect245ClientChecks') }
def zombie287Client = providers.gradleProperty('sanctuaryZombie287ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('zombie287Client', zombie287Client)
if (zombie287Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Zombie287ClientChecks') }
def hunter286Client = providers.gradleProperty('sanctuaryHunter286ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('hunter286Client', hunter286Client)
if (hunter286Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Hunter286ClientChecks') }
def smoker285Client = providers.gradleProperty('sanctuarySmoker285ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('smoker285Client', smoker285Client)
if (smoker285Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Smoker285ClientChecks') }
def boomer284Client = providers.gradleProperty('sanctuaryBoomer284ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('boomer284Client', boomer284Client)
if (boomer284Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Boomer284ClientChecks') }
def spitter283Client = providers.gradleProperty('sanctuarySpitter283ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('spitter283Client', spitter283Client)
if (spitter283Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Spitter283ClientChecks') }
def charger282Client = providers.gradleProperty('sanctuaryCharger282ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('charger282Client', charger282Client)
if (charger282Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Charger282ClientChecks') }
def infected280Client = providers.gradleProperty('sanctuaryInfected280ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('infected280Client', infected280Client)
if (infected280Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Infected280ClientChecks') }
def map279Client = providers.gradleProperty('sanctuaryMap279ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('map279Client', map279Client)
if (map279Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Map279ClientChecks') }
def map244Client = providers.gradleProperty('sanctuaryMap244ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('map244Client', map244Client)
if (map244Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Map244ClientChecks') }
@@ -686,6 +714,8 @@ dependencies {
if (providers.gradleProperty('sanctuaryJeiRuntime').map { it.toBoolean() }.getOrElse(true)) {
runtimeOnly project(':jei')
}
// One distributed Sanctuary JAR; keep the exact JEI fork and its own metadata.
include project(':jei')
minecraft "com.mojang:minecraft:${rootProject.minecraft_version}"
implementation "net.fabricmc:fabric-loader:${rootProject.loader_version}"
implementation "net.fabricmc.fabric-api:fabric-api:${rootProject.fabric_api_version}"
@@ -0,0 +1,153 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.cosmetics.FamiliarService;
import fr.koka.sanctuary.cosmetics.FamiliarEntity;
import fr.koka.sanctuary.cosmetics.FamiliarSize;
import fr.koka.sanctuary.familiar.FamiliarBattle;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.infected.Infected;
import fr.koka.sanctuary.infected.BoomerEntity;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
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.FabricClientGameTest;
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.core.BlockPos;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.gamerules.GameRules;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** The custom renderer and profile must also work through ordinary familiar UI paths. */
public final class Boomer284ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var wild=new java.util.concurrent.atomic.AtomicReference<BoomerEntity>();
var enemy=new java.util.concurrent.atomic.AtomicReference<net.minecraft.world.entity.monster.zombie.Zombie>();
var preparing=new AtomicBoolean();var coated=new AtomicBoolean();var lured=new AtomicBoolean();
var cow=new java.util.concurrent.atomic.AtomicReference<net.minecraft.world.entity.animal.cow.Cow>();
var recruit=new java.util.concurrent.atomic.AtomicReference<net.minecraft.world.entity.monster.zombie.Zombie>();
net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents.END_SERVER_TICK.register(s->{var mob=wild.get();if(mob!=null&&!mob.isRemoved()){
preparing.set(mob.windingUp());if(cow.get()!=null)coated.set(cow.get().hasEffect(net.minecraft.world.effect.MobEffects.GLOWING));
if(recruit.get()!=null)lured.set(recruit.get().getTarget()==cow.get());
}});
fr.koka.sanctuary.testing.QuickTestWorld.enable();var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);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());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Boomer284-"+UUID.randomUUID());s.setSeed("284");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->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
s.setDifficulty(net.minecraft.world.Difficulty.NORMAL,true);
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set midnight");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"weather clear");
p.level().getGameRules().set(GameRules.SPAWN_MOBS,false,s);
p.getAbilities().invulnerable=true;p.getInventory().clearContent();p.teleportTo(.5,1,.5);p.setYRot(0);p.setXRot(0);
for(int x=-16;x<=16;x++)for(int z=-16;z<=16;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
for(int x=-2;x<=2;x++)p.level().setBlockAndUpdate(new BlockPos(x,0,3),Blocks.SEA_LANTERN.defaultBlockState());
var egg=new ItemStack(Infected.BOOMER_EGG);
var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",1000);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.getInventory().setItem(0,egg.copy());p.inventoryMenu.broadcastChanges();
var adult=Infected.BOOMER.create(p.level(),EntitySpawnReason.COMMAND);adult.setNoAi(true);adult.setPermanentlyInvulnerable(true);wild.set(adult);adult.setPos(.5,1,4.5);adult.setYRot(180);adult.yHeadRot=180;p.level().addFreshEntity(adult);
var zombie=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);zombie.setNoAi(true);zombie.setPermanentlyInvulnerable(true);enemy.set(zombie);zombie.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).setBaseValue(100);zombie.setHealth(100);zombie.setPos(-1.5,1,4.5);zombie.setYRot(180);zombie.yHeadRot=180;p.level().addFreshEntity(zombie);
var red=Infected.TYPE.create(p.level(),EntitySpawnReason.COMMAND);red.setNoAi(true);red.setPermanentlyInvulnerable(true);red.setPos(-3.5,1,4.5);red.setYRot(180);red.yHeadRot=180;p.level().addFreshEntity(red);
});
c.waitFor(m->FamiliarClient.profile!=null&&FamiliarClient.profile.id().equals("sanctuary:boomer"),400);c.waitTicks(15);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(FamiliarService.active(p)!=null&&!FamiliarService.active(p).babyAppearance(),"The integrated server equips an adult miniature");FamiliarBattle.send(p,"");});
c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);m.getWindow().setWindowed(1280,960);
var mob=Infected.BOOMER.create(m.level,EntitySpawnReason.MOB_SUMMONED);
check(m.getEntityRenderDispatcher().getRenderer(mob) instanceof InfectedRenderer,"Custom renderer is installed for adults and familiar proxies");
mob.setBaby(true);check(!mob.isBaby(),"The render proxy rejects native baby proportions");
check(m.getResourceManager().getResource(SanctuaryMod.id("textures/entity/boomer.png")).isPresent(),"Custom skin is packaged");
check(CompanionTooltips.rarity(new ItemStack(Infected.BOOMER_EGG))==fr.koka.sanctuary.companion.CompanionType.Rarity.RARE,"The new profile supplies tooltip rarity without a legacy enum");
m.player.getInventory().setSelectedSlot(1);
});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();FamiliarBattle.command(p,"follow");
var pet=FamiliarService.active(p);pet.teleportTo(.5,1,2.5);pet.setOnGround(true);
enemy.get().teleportTo(.5,1,4.5);enemy.get().setPermanentlyInvulnerable(false);
});
c.runOnClient(m->{var key=net.minecraft.client.KeyMapping.get("sanctuary.companion.key");key.setKey(com.mojang.blaze3d.platform.InputConstants.Type.KEYBOARD.getOrCreate(com.mojang.blaze3d.platform.InputConstants.KEY_G));net.minecraft.client.KeyMapping.resetMapping();});
c.waitTicks(8);c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_G);c.waitTicks(24);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);
check(enemy.get().getHealth()<100&&enemy.get().hasEffect(net.minecraft.world.effect.MobEffects.WEAKNESS)&&enemy.get().hasEffect(net.minecraft.world.effect.MobEffects.GLOWING),"Real G splashes, weakens and reveals the enemy");
check(pet.isAlive()&&pet.getHealth()==14&&FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now(),"G keeps the miniature alive and commits cooldown");
});
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(3);c.takeScreenshot("boomer284-bile-burst");
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(20);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
check(FamiliarBattle.state(p).cast==null,"Signature and first automatic preparation finish normally");
enemy.get().setPermanentlyInvulnerable(true);enemy.get().removeAllEffects();enemy.get().teleportTo(-1.5,1,4.5);
});
for(int value:new int[]{500,1000,1500}) {
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
var egg=FamiliarBattle.egg(p).copy();var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",value);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.inventoryMenu.broadcastChanges();
});
c.waitFor(m->m.level.getEntitiesOfClass(FamiliarEntity.class,m.player.getBoundingBox().inflate(12)).stream()
.anyMatch(pet->FamiliarSize.value(pet.egg())==value),200);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);
check(pet!=null&&!pet.babyAppearance(),"Every size keeps the adult form");
pet.setNoAi(true);pet.teleportTo(2.5,1,4.5);pet.setYRot(180);pet.yHeadRot=180;pet.yBodyRot=180;pet.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO);
});c.waitTicks(8);
c.runOnClient(m->{var pet=m.level.getEntitiesOfClass(FamiliarEntity.class,m.player.getBoundingBox().inflate(12)).stream()
.filter(e->FamiliarSize.value(e.egg())==value).findFirst().orElseThrow();
var renderer=(FamiliarRenderer)m.getEntityRenderDispatcher().getRenderer(pet);
var state=renderer.createRenderState(pet,1);
check(state.appearance instanceof net.minecraft.client.renderer.entity.state.ZombieRenderState zombie&&!zombie.isBaby,"The real familiar renderer uses adult geometry");
check(Math.abs(state.size*state.appearance.boundingBoxHeight-.85*value/1000)<.001,"The displayed familiar preserves its size multiplier");
});
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(3);c.takeScreenshot("boomer284-miniature-"+value);
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
}
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(scale);m.resizeGui();
check(!new ItemStack(Infected.BOOMER_EGG).getHoverName().getString().contains("item.sanctuary"),"Egg label is translated");
var tooltip=new ItemStack(Infected.BOOMER_EGG).getTooltipLines(Item.TooltipContext.of(m.level),m.player,TooltipFlag.NORMAL);
String bite=locale.equals("fr_fr")?"Gerbe de bile":"Bile spray";
check(tooltip.stream().anyMatch(line->line.getString().contains(bite)),"Native tooltip callback includes the custom familiar technique in both languages");
m.gui.setScreen(new FamiliarScreen(null));
});c.waitTicks(4);c.takeScreenshot("boomer284-familiar-"+locale+"-gui"+scale);
}
c.runOnClient(m->m.gui.setScreen(null));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setGameMode(net.minecraft.world.level.GameType.CREATIVE);
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);
var target=EntityTypes.COW.create(p.level(),EntitySpawnReason.COMMAND);target.setNoAi(true);target.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).setBaseValue(100);target.setHealth(100);target.setPos(.5,1,8.5);p.level().addFreshEntity(target);cow.set(target);
var zombie=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);zombie.setPos(-3.5,1,10.5);p.level().addFreshEntity(zombie);recruit.set(zombie);
var mob=wild.get();mob.setPermanentlyInvulnerable(false);mob.setTarget(target);mob.setOnGround(true);mob.setNoAi(false);
});
c.waitFor(m->preparing.get(),200);c.waitTicks(2);
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(2);c.takeScreenshot("boomer284-windup");
c.waitFor(m->coated.get()&&lured.get(),150);c.waitTicks(3);c.takeScreenshot("boomer284-wild-bile");
server.runOnServer(s->{check(cow.get().hasEffect(net.minecraft.world.effect.MobEffects.WEAKNESS)&&recruit.get().getTarget()==cow.get(),"Wild bile marks its victim and redirects an existing native zombie");
var mob=wild.get();mob.setNoAi(true);recruit.get().setNoAi(true);recruit.get().teleportTo(-5.5,1,9.5);
cow.get().teleportTo(mob.getX(),1,mob.getZ()+1.5);cow.get().setHealth(100);cow.get().setInvulnerableTime(0);cow.get().removeAllEffects();
mob.hurtServer((net.minecraft.server.level.ServerLevel)mob.level(),mob.damageSources().generic(),100);
check(!mob.isAlive()&&cow.get().getHealth()==98&&cow.get().hasEffect(net.minecraft.world.effect.MobEffects.GLOWING),"Native lethal damage triggers the Boomer's harmless-to-terrain death splash");
});c.waitTicks(2);c.takeScreenshot("boomer284-death-splash");
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);
wild.get().discard();wild.set(null);cow.get().discard();enemy.get().discard();recruit.get().discard();
p.level().getEntitiesOfClass(fr.koka.sanctuary.infected.InfectedEntity.class,p.getBoundingBox().inflate(20)).forEach(Entity::discard);
});c.waitTicks(20);
System.out.println("BOOMER284_CLIENT_PASS: Vulkan, adult skin, three miniature sizes, real G bile/effects/cooldown without sacrifice, wild windup/bile/recruitment/death splash, FR/EN GUI 2/3/4");
}
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,140 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.cosmetics.FamiliarService;
import fr.koka.sanctuary.cosmetics.FamiliarEntity;
import fr.koka.sanctuary.cosmetics.FamiliarSize;
import fr.koka.sanctuary.familiar.FamiliarBattle;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.infected.Infected;
import fr.koka.sanctuary.infected.ChargerEntity;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
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.FabricClientGameTest;
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.core.BlockPos;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.gamerules.GameRules;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** The custom renderer and profile must also work through ordinary familiar UI paths. */
public final class Charger282ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var wild=new java.util.concurrent.atomic.AtomicReference<ChargerEntity>();
var enemy=new java.util.concurrent.atomic.AtomicReference<net.minecraft.world.entity.monster.zombie.Zombie>();
var phase=new java.util.concurrent.atomic.AtomicReference<>(ChargerEntity.Phase.IDLE);
net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents.END_SERVER_TICK.register(s->{var mob=wild.get();if(mob!=null&&!mob.isRemoved())phase.set(mob.phase());});
fr.koka.sanctuary.testing.QuickTestWorld.enable();var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);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());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Charger282-"+UUID.randomUUID());s.setSeed("282");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->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
s.setDifficulty(net.minecraft.world.Difficulty.NORMAL,true);
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set midnight");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"weather clear");
p.level().getGameRules().set(GameRules.SPAWN_MOBS,false,s);
p.getAbilities().invulnerable=true;p.getInventory().clearContent();p.teleportTo(.5,1,.5);p.setYRot(0);p.setXRot(0);
for(int x=-16;x<=16;x++)for(int z=-16;z<=16;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
for(int x=-2;x<=2;x++)p.level().setBlockAndUpdate(new BlockPos(x,0,3),Blocks.SEA_LANTERN.defaultBlockState());
var egg=new ItemStack(Infected.CHARGER_EGG);
var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",1000);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.getInventory().setItem(0,egg.copy());p.inventoryMenu.broadcastChanges();
var adult=Infected.CHARGER.create(p.level(),EntitySpawnReason.COMMAND);adult.setNoAi(true);adult.setPermanentlyInvulnerable(true);wild.set(adult);adult.setPos(.5,1,4.5);adult.setYRot(180);adult.yHeadRot=180;p.level().addFreshEntity(adult);
var zombie=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);zombie.setNoAi(true);zombie.setPermanentlyInvulnerable(true);enemy.set(zombie);zombie.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).setBaseValue(100);zombie.setHealth(100);zombie.setPos(-1.5,1,4.5);zombie.setYRot(180);zombie.yHeadRot=180;p.level().addFreshEntity(zombie);
var red=Infected.TYPE.create(p.level(),EntitySpawnReason.COMMAND);red.setNoAi(true);red.setPermanentlyInvulnerable(true);red.setPos(-3.5,1,4.5);red.setYRot(180);red.yHeadRot=180;p.level().addFreshEntity(red);
});
c.waitFor(m->FamiliarClient.profile!=null&&FamiliarClient.profile.id().equals("sanctuary:charger"),400);c.waitTicks(15);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(FamiliarService.active(p)!=null&&!FamiliarService.active(p).babyAppearance(),"The integrated server equips an adult miniature");FamiliarBattle.send(p,"");});
c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);m.getWindow().setWindowed(1280,960);
var mob=Infected.CHARGER.create(m.level,EntitySpawnReason.MOB_SUMMONED);
check(m.getEntityRenderDispatcher().getRenderer(mob) instanceof InfectedRenderer,"Custom renderer is installed for adults and familiar proxies");
mob.setBaby(true);check(!mob.isBaby(),"The render proxy rejects native baby proportions");
check(m.getResourceManager().getResource(SanctuaryMod.id("textures/entity/charger.png")).isPresent(),"Custom skin is packaged");
check(CompanionTooltips.rarity(new ItemStack(Infected.CHARGER_EGG))==fr.koka.sanctuary.companion.CompanionType.Rarity.RARE,"The new profile supplies tooltip rarity without a legacy enum");
m.player.getInventory().setSelectedSlot(1);
});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();FamiliarBattle.command(p,"follow");
var pet=FamiliarService.active(p);pet.teleportTo(.5,1,1.5);pet.setOnGround(true);
enemy.get().teleportTo(.5,1,6.5);enemy.get().setPermanentlyInvulnerable(false);
});
c.runOnClient(m->{var key=net.minecraft.client.KeyMapping.get("sanctuary.companion.key");key.setKey(com.mojang.blaze3d.platform.InputConstants.Type.KEYBOARD.getOrCreate(com.mojang.blaze3d.platform.InputConstants.KEY_G));net.minecraft.client.KeyMapping.resetMapping();});
c.waitTicks(8);c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_G);c.waitTicks(90);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
check(enemy.get().getHealth()<100,"Native G input triggers a real straight familiar charge and impact: ready="+FamiliarBattle.bond(p).readyAt()+" pet="+FamiliarService.active(p).position()+" cast="+FamiliarBattle.state(p).cast+" message="+(FamiliarClient.view==null?null:FamiliarClient.view.message()));
check(FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now()&&FamiliarBattle.state(p).cast==null,"G commits cooldown and finishes recovery");
enemy.get().setPermanentlyInvulnerable(true);enemy.get().teleportTo(-1.5,1,4.5);
});c.waitTicks(10);
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(3);c.takeScreenshot("charger282-adult-zombie");
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
for(int value:new int[]{500,1000,1500}) {
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
var egg=FamiliarBattle.egg(p).copy();var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",value);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.inventoryMenu.broadcastChanges();
});
c.waitFor(m->m.level.getEntitiesOfClass(FamiliarEntity.class,m.player.getBoundingBox().inflate(12)).stream()
.anyMatch(pet->FamiliarSize.value(pet.egg())==value),200);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);
check(pet!=null&&!pet.babyAppearance(),"Every size keeps the adult form");
pet.setNoAi(true);pet.teleportTo(2.5,1,4.5);pet.setYRot(180);pet.yHeadRot=180;pet.yBodyRot=180;pet.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO);
});c.waitTicks(8);
c.runOnClient(m->{var pet=m.level.getEntitiesOfClass(FamiliarEntity.class,m.player.getBoundingBox().inflate(12)).stream()
.filter(e->FamiliarSize.value(e.egg())==value).findFirst().orElseThrow();
var renderer=(FamiliarRenderer)m.getEntityRenderDispatcher().getRenderer(pet);
var state=renderer.createRenderState(pet,1);
check(state.appearance instanceof net.minecraft.client.renderer.entity.state.ZombieRenderState zombie&&!zombie.isBaby,"The real familiar renderer uses adult geometry");
check(Math.abs(state.size*state.appearance.boundingBoxHeight-.85*value/1000)<.001,"The displayed familiar preserves its size multiplier");
});
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(3);c.takeScreenshot("charger282-miniature-"+value);
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
}
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(scale);m.resizeGui();
check(!new ItemStack(Infected.CHARGER_EGG).getHoverName().getString().contains("item.sanctuary"),"Egg label is translated");
var tooltip=new ItemStack(Infected.CHARGER_EGG).getTooltipLines(Item.TooltipContext.of(m.level),m.player,TooltipFlag.NORMAL);
String bite=locale.equals("fr_fr")?"Coup d’épaule":"Shoulder strike";
check(tooltip.stream().anyMatch(line->line.getString().contains(bite)),"Native tooltip callback includes the custom familiar technique in both languages");
m.gui.setScreen(new FamiliarScreen(null));
});c.waitTicks(4);c.takeScreenshot("charger282-familiar-"+locale+"-gui"+scale);
}
c.runOnClient(m->m.gui.setScreen(null));
var cow=new java.util.concurrent.atomic.AtomicReference<net.minecraft.world.entity.animal.cow.Cow>();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setGameMode(net.minecraft.world.level.GameType.CREATIVE);
var target=EntityTypes.COW.create(p.level(),EntitySpawnReason.COMMAND);target.setNoAi(true);target.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).setBaseValue(100);target.setHealth(100);target.setPos(.5,1,13.5);p.level().addFreshEntity(target);cow.set(target);
var mob=wild.get();mob.setPermanentlyInvulnerable(false);mob.setTarget(target);mob.setOnGround(true);mob.setNoAi(false);
});
c.waitFor(m->phase.get()==ChargerEntity.Phase.WINDUP,200);c.waitTicks(3);
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(2);c.takeScreenshot("charger282-windup");
c.waitFor(m->phase.get()==ChargerEntity.Phase.CHARGING,100);c.waitTicks(2);c.takeScreenshot("charger282-charging");
c.waitFor(m->phase.get()==ChargerEntity.Phase.RECOVERING,100);
server.runOnServer(s->check(cow.get().getHealth()==96,"The wild charger reaches actual body contact and deals four damage"));
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);
wild.get().discard();wild.set(null);cow.get().discard();enemy.get().discard();
p.level().getEntitiesOfClass(fr.koka.sanctuary.infected.InfectedEntity.class,p.getBoundingBox().inflate(20)).forEach(Entity::discard);
});c.waitTicks(20);
System.out.println("CHARGER282_CLIENT_PASS: Vulkan, adult skin, three miniature sizes, native G impact and cooldown, wild windup/charge/impact, FR/EN GUI 2/3/4");
}
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,155 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.cosmetics.FamiliarService;
import fr.koka.sanctuary.cosmetics.FamiliarEntity;
import fr.koka.sanctuary.cosmetics.FamiliarSize;
import fr.koka.sanctuary.familiar.FamiliarBattle;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.infected.Infected;
import fr.koka.sanctuary.infected.HunterEntity;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
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.FabricClientGameTest;
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.core.BlockPos;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.gamerules.GameRules;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** The custom renderer and profile must also work through ordinary familiar UI paths. */
public final class Hunter286ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var wild=new java.util.concurrent.atomic.AtomicReference<HunterEntity>();
var enemy=new java.util.concurrent.atomic.AtomicReference<net.minecraft.world.entity.monster.zombie.Zombie>();
var phase=new java.util.concurrent.atomic.AtomicReference<>(HunterEntity.Phase.IDLE);
var familyAir=new AtomicBoolean();var familyPeak=new java.util.concurrent.atomic.AtomicReference<Double>(1D);var wildPeak=new java.util.concurrent.atomic.AtomicReference<Double>(1D);
var signature=new java.util.concurrent.atomic.AtomicReference<fr.koka.sanctuary.familiar.FamiliarTechniques.Cast>();
net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents.END_SERVER_TICK.register(s->{
var mob=wild.get();if(mob!=null&&!mob.isRemoved()){phase.set(mob.phase());wildPeak.set(Math.max(wildPeak.get(),mob.getY()));}
if(s.getPlayerList().getPlayers().isEmpty())return;var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);
var cast=FamiliarBattle.state(p).cast;if(cast!=null&&!cast.automatic&&cast.technique.effect().equals("pounce"))signature.compareAndSet(null,cast);
if(pet!=null&&signature.get()!=null){familyPeak.set(Math.max(familyPeak.get(),pet.getY()));if(pet.getY()>1.4&&!pet.onGround())familyAir.set(true);}
});
fr.koka.sanctuary.testing.QuickTestWorld.enable();var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);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());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Hunter286-"+UUID.randomUUID());s.setSeed("286");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->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
s.setDifficulty(net.minecraft.world.Difficulty.NORMAL,true);
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set midnight");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"weather clear");
p.level().getGameRules().set(GameRules.SPAWN_MOBS,false,s);
p.getAbilities().invulnerable=true;p.getInventory().clearContent();p.teleportTo(.5,1,.5);p.setYRot(0);p.setXRot(0);
for(int x=-16;x<=16;x++)for(int z=-16;z<=16;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
for(int x=-2;x<=2;x++)for(int z:new int[]{3,7,11})p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.SEA_LANTERN.defaultBlockState());
var egg=new ItemStack(Infected.HUNTER_EGG);
var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",1000);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.getInventory().setItem(0,egg.copy());p.inventoryMenu.broadcastChanges();
var adult=Infected.HUNTER.create(p.level(),EntitySpawnReason.COMMAND);adult.setNoAi(true);adult.setPermanentlyInvulnerable(true);wild.set(adult);adult.setPos(.5,1,4.5);adult.setYRot(180);adult.yHeadRot=180;p.level().addFreshEntity(adult);
var zombie=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);zombie.setNoAi(true);zombie.setPermanentlyInvulnerable(true);enemy.set(zombie);zombie.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).setBaseValue(100);zombie.setHealth(100);zombie.setPos(-1.5,1,4.5);zombie.setYRot(180);zombie.yHeadRot=180;p.level().addFreshEntity(zombie);
var red=Infected.TYPE.create(p.level(),EntitySpawnReason.COMMAND);red.setNoAi(true);red.setPermanentlyInvulnerable(true);red.setPos(-3.5,1,4.5);red.setYRot(180);red.yHeadRot=180;p.level().addFreshEntity(red);
});
c.waitFor(m->FamiliarClient.profile!=null&&FamiliarClient.profile.id().equals("sanctuary:hunter"),400);c.waitTicks(15);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(FamiliarService.active(p)!=null&&!FamiliarService.active(p).babyAppearance(),"The integrated server equips an adult miniature");FamiliarBattle.send(p,"");});
c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);m.getWindow().setWindowed(1280,960);
var mob=Infected.HUNTER.create(m.level,EntitySpawnReason.MOB_SUMMONED);
check(m.getEntityRenderDispatcher().getRenderer(mob) instanceof InfectedRenderer,"Custom renderer is installed for adults and familiar proxies");
mob.setBaby(true);check(!mob.isBaby(),"The render proxy rejects native baby proportions");
check(m.getResourceManager().getResource(SanctuaryMod.id("textures/entity/hunter.png")).isPresent(),"Custom skin is packaged");
check(CompanionTooltips.rarity(new ItemStack(Infected.HUNTER_EGG))==fr.koka.sanctuary.companion.CompanionType.Rarity.RARE,"The new profile supplies tooltip rarity without a legacy enum");
m.player.getInventory().setSelectedSlot(1);
});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();FamiliarBattle.command(p,"follow");
wild.get().teleportTo(3.5,1,4.5);var pet=FamiliarService.active(p);pet.teleportTo(.5,1,1.5);pet.setOnGround(true);
enemy.get().teleportTo(.5,1,8.5);enemy.get().setPermanentlyInvulnerable(false);
});
c.runOnClient(m->{var key=net.minecraft.client.KeyMapping.get("sanctuary.companion.key");key.setKey(com.mojang.blaze3d.platform.InputConstants.Type.KEYBOARD.getOrCreate(com.mojang.blaze3d.platform.InputConstants.KEY_G));net.minecraft.client.KeyMapping.resetMapping();});
c.waitTicks(8);c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_G);
c.waitFor(m->familyAir.get(),250);c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(1);c.takeScreenshot("hunter286-familiar-airborne");
c.waitTicks(60);c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);
check(signature.get()!=null&&signature.get().hit.size()==1&&enemy.get().getHealth()<100&&familyPeak.get()>2,"Real G uses an airborne native body and one signature hit");
check(pet.isAlive()&&FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now(),"The miniature survives and retains its durable G cooldown");
FamiliarBattle.command(p,"follow");wild.get().teleportTo(.5,1,4.5);enemy.get().setPermanentlyInvulnerable(true);enemy.get().teleportTo(-1.5,1,4.5);
});
for(int value:new int[]{500,1000,1500}) {
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
var egg=FamiliarBattle.egg(p).copy();var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",value);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.inventoryMenu.broadcastChanges();
});
c.waitFor(m->m.level.getEntitiesOfClass(FamiliarEntity.class,m.player.getBoundingBox().inflate(12)).stream()
.anyMatch(pet->FamiliarSize.value(pet.egg())==value),200);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);
check(pet!=null&&!pet.babyAppearance(),"Every size keeps the adult form");
pet.setNoAi(true);pet.teleportTo(2.5,1,4.5);pet.setYRot(180);pet.yHeadRot=180;pet.yBodyRot=180;pet.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO);
});c.waitTicks(8);
c.runOnClient(m->{var pet=m.level.getEntitiesOfClass(FamiliarEntity.class,m.player.getBoundingBox().inflate(12)).stream()
.filter(e->FamiliarSize.value(e.egg())==value).findFirst().orElseThrow();
var renderer=(FamiliarRenderer)m.getEntityRenderDispatcher().getRenderer(pet);
var state=renderer.createRenderState(pet,1);
check(state.appearance instanceof net.minecraft.client.renderer.entity.state.ZombieRenderState zombie&&!zombie.isBaby,"The real familiar renderer uses adult geometry");
check(Math.abs(state.size*state.appearance.boundingBoxHeight-.85*value/1000)<.001,"The displayed familiar preserves its size multiplier");
});
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(3);c.takeScreenshot("hunter286-miniature-"+value);
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
}
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(scale);m.resizeGui();
check(!new ItemStack(Infected.HUNTER_EGG).getHoverName().getString().contains("item.sanctuary"),"Egg label is translated");
var tooltip=new ItemStack(Infected.HUNTER_EGG).getTooltipLines(Item.TooltipContext.of(m.level),m.player,TooltipFlag.NORMAL);
String bite=locale.equals("fr_fr")?"Griffure":"Claw strike";
check(tooltip.stream().anyMatch(line->line.getString().contains(bite)),"Native tooltip callback includes the custom familiar technique in both languages");
m.gui.setScreen(new FamiliarScreen(null));
});c.waitTicks(4);c.takeScreenshot("hunter286-familiar-"+locale+"-gui"+scale);
}
c.runOnClient(m->m.gui.setScreen(null));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);
p.getAbilities().invulnerable=false;p.teleportTo(.5,1,.5);p.setYRot(0);p.setXRot(0);p.setHealth(6);
var mob=wild.get();mob.teleportTo(.5,1,8.5);mob.setPermanentlyInvulnerable(false);mob.setOnGround(true);mob.setTarget(p);mob.setNoAi(false);phase.set(HunterEntity.Phase.IDLE);wildPeak.set(1D);
});c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);});
c.waitFor(m->phase.get()==HunterEntity.Phase.WINDUP,200);c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(2);c.takeScreenshot("hunter286-windup");
c.waitFor(m->phase.get()==HunterEntity.Phase.LEAPING,150);c.waitTicks(2);c.takeScreenshot("hunter286-wild-airborne");
c.waitFor(m->phase.get()==HunterEntity.Phase.RECOVERING,150);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
check(p.getHealth()<6&&p.getHealth()>0&&wildPeak.get()>2,"The wild Hunter physically jumps onto the real player and attacks");
check(wild.get().pounceCooldown()>0&&!wild.get().doHurtTarget(p.level(),p),"Recovery prevents a second melee strike after impact");p.setGameMode(net.minecraft.world.level.GameType.CREATIVE);
});c.takeScreenshot("hunter286-contact");c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(28);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(wild.get().onGround()&&!wild.get().isNoGravity(),"The Hunter lands with normal gravity");
wild.get().discard();var mob=Infected.HUNTER.create(p.level(),EntitySpawnReason.COMMAND);mob.setPos(.5,1,8.5);mob.setOnGround(true);wild.set(mob);p.level().addFreshEntity(mob);
p.setGameMode(net.minecraft.world.level.GameType.SURVIVAL);p.setHealth(6);p.teleportTo(.5,1,.5);p.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO);p.setYRot(0);p.setXRot(0);mob.setTarget(p);phase.set(HunterEntity.Phase.IDLE);wildPeak.set(1D);
});c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);});
c.waitFor(m->phase.get()==HunterEntity.Phase.LEAPING,250);c.getInput().holdKey(o->o.keyRight);c.waitTicks(12);c.getInput().releaseKey(o->o.keyRight);c.waitTicks(8);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
check(p.getHealth()==6&&Math.abs(p.getX()-.5)>.7&&wildPeak.get()>2&&wild.get().pounceCooldown()>0,"Real sideways keyboard input dodges the fixed airborne trajectory");p.setGameMode(net.minecraft.world.level.GameType.CREATIVE);
});
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.takeScreenshot("hunter286-dodged");c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();wild.get().discard();wild.set(null);enemy.get().discard();
p.level().getEntitiesOfClass(fr.koka.sanctuary.infected.InfectedEntity.class,p.getBoundingBox().inflate(24)).forEach(Entity::discard);
});c.waitTicks(20);
System.out.println("HUNTER286_CLIENT_PASS: Vulkan, adult skin, native airborne G/contact/cooldown, real wild jump/player attack/recovery/landing, real keyboard dodge, three adult miniature sizes, FR/EN GUI 2/3/4");
}
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,104 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.cosmetics.FamiliarService;
import fr.koka.sanctuary.cosmetics.FamiliarEntity;
import fr.koka.sanctuary.cosmetics.FamiliarSize;
import fr.koka.sanctuary.familiar.FamiliarBattle;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.infected.Infected;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
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.FabricClientGameTest;
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.core.BlockPos;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.gamerules.GameRules;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** The custom renderer and profile must also work through ordinary familiar UI paths. */
public final class Infected280ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
fr.koka.sanctuary.testing.QuickTestWorld.enable();var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);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());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Infected281-"+UUID.randomUUID());s.setSeed("281");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->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
s.setDifficulty(net.minecraft.world.Difficulty.NORMAL,true);
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set midnight");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"weather clear");
p.level().getGameRules().set(GameRules.SPAWN_MOBS,false,s);
p.getAbilities().invulnerable=true;p.getInventory().clearContent();p.teleportTo(.5,1,.5);p.setYRot(0);p.setXRot(0);
for(int x=-8;x<=8;x++)for(int z=-8;z<=8;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
for(int x=-2;x<=2;x++)p.level().setBlockAndUpdate(new BlockPos(x,0,3),Blocks.SEA_LANTERN.defaultBlockState());
var egg=new ItemStack(Infected.SPAWN_EGG);
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.getInventory().setItem(0,egg.copy());p.inventoryMenu.broadcastChanges();
var adult=Infected.TYPE.create(p.level(),EntitySpawnReason.COMMAND);adult.setNoAi(true);adult.setPos(.5,1,4.5);adult.setYRot(180);adult.yHeadRot=180;p.level().addFreshEntity(adult);
var zombie=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);zombie.setNoAi(true);zombie.setPos(-1.5,1,4.5);zombie.setYRot(180);zombie.yHeadRot=180;p.level().addFreshEntity(zombie);
});
c.waitFor(m->FamiliarClient.profile!=null&&FamiliarClient.profile.id().equals("sanctuary:infected"),400);c.waitTicks(15);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(FamiliarService.active(p)!=null&&!FamiliarService.active(p).babyAppearance(),"The integrated server equips an adult miniature");FamiliarBattle.send(p,"");});
c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);m.getWindow().setWindowed(1280,960);
var mob=Infected.TYPE.create(m.level,EntitySpawnReason.MOB_SUMMONED);
check(m.getEntityRenderDispatcher().getRenderer(mob) instanceof InfectedRenderer,"Custom renderer is installed for adults and familiar proxies");
mob.setBaby(true);check(!mob.isBaby(),"The render proxy rejects native baby proportions");
check(m.getResourceManager().getResource(InfectedRenderer.TEXTURE).isPresent(),"Custom skin is packaged");
check(CompanionTooltips.rarity(new ItemStack(Infected.SPAWN_EGG))==fr.koka.sanctuary.companion.CompanionType.Rarity.UNCOMMON,"The new profile supplies tooltip rarity without a legacy enum");
m.player.getInventory().setSelectedSlot(1);
});c.waitTicks(60);
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(3);c.takeScreenshot("infected281-adult-zombie");
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
for(int value:new int[]{500,1000,1500}) {
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
var egg=FamiliarBattle.egg(p).copy();var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",value);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.inventoryMenu.broadcastChanges();
});
c.waitFor(m->m.level.getEntitiesOfClass(FamiliarEntity.class,m.player.getBoundingBox().inflate(12)).stream()
.anyMatch(pet->FamiliarSize.value(pet.egg())==value),200);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);
check(pet!=null&&!pet.babyAppearance(),"Every size keeps the adult form");
pet.setNoAi(true);pet.teleportTo(2.5,1,4.5);pet.setYRot(180);pet.yHeadRot=180;pet.yBodyRot=180;pet.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO);
});c.waitTicks(8);
c.runOnClient(m->{var pet=m.level.getEntitiesOfClass(FamiliarEntity.class,m.player.getBoundingBox().inflate(12)).stream()
.filter(e->FamiliarSize.value(e.egg())==value).findFirst().orElseThrow();
var renderer=(FamiliarRenderer)m.getEntityRenderDispatcher().getRenderer(pet);
var state=renderer.createRenderState(pet,1);
check(state.appearance instanceof net.minecraft.client.renderer.entity.state.ZombieRenderState zombie&&!zombie.isBaby,"The real familiar renderer uses adult geometry");
check(Math.abs(state.size*state.appearance.boundingBoxHeight-.85*value/1000)<.001,"The displayed familiar preserves its size multiplier");
});
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(3);c.takeScreenshot("infected281-miniature-"+value);
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
}
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(scale);m.resizeGui();
check(!new ItemStack(Infected.SPAWN_EGG).getHoverName().getString().contains("item.sanctuary"),"Egg label is translated");
var tooltip=new ItemStack(Infected.SPAWN_EGG).getTooltipLines(Item.TooltipContext.of(m.level),m.player,TooltipFlag.NORMAL);
String bite=locale.equals("fr_fr")?"Morsure":"Bite";
check(tooltip.stream().anyMatch(line->line.getString().contains(bite)),"Native tooltip callback includes the custom familiar technique in both languages");
m.gui.setScreen(new FamiliarScreen(null));
});c.waitTicks(4);c.takeScreenshot("infected281-familiar-"+locale+"-gui"+scale);
}
c.runOnClient(m->m.gui.setScreen(null));
System.out.println("INFECTED281_CLIENT_PASS: Vulkan, adult-only infected, three real miniature sizes, translated egg and FR/EN GUI 2/3/4");
}
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,112 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.atlas.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.progression.*;
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.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.PauseScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.material.MapColor;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Five separated survey fixtures, real remote chunk delivery and native Vulkan rendering. */
public final class Map279ClientChecks implements FabricClientGameTest {
private static final int[][] ISLANDS={{0,0},{8192,0},{-8192,0},{0,8192},{0,-8192}};
public void runTest(ClientGameTestContext c) {
fr.koka.sanctuary.testing.QuickTestWorld.enable();var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get() && m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);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());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Map279-"+UUID.randomUUID());s.setSeed("279");s.setAllowCommands(false);
s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null
&& e.preset().getRegisteredName().equals("minecraft:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->m.player!=null && ProgressionClient.active() && 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.getAbilities().flying=true;p.onUpdateAbilities();p.setNoGravity(true);
p.teleportTo(p.level(),.5,80,.5,Set.of(),0,0,true);
var r=ProgressRecord.INITIAL.buy("atlas",1);p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");
var store=AtlasService.observations(p);byte[] history=new byte[256];Arrays.fill(history,(byte)42);
for(int i=0;i<16384;i++)store.observe(new AtlasStore.Tile(i,3000,history));
for(int i=0;i<ISLANDS.length;i++) {
byte[] colors=new byte[256];Arrays.fill(colors,(byte)((MapColor.GRASS.id+i*2)*4+2));
for(int z=-8;z<=8;z++)for(int x=-8;x<=8;x++)store.observe(new AtlasStore.Tile(ISLANDS[i][0]/16+x,ISLANDS[i][1]/16+z,colors));
}
check(store.size()>16384 && !store.full(),"Old full atlas resumes surveying");
});
c.waitFor(m->ProgressionClient.progress.atlas(),200);
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.options.guiScale().set(2);m.resizeGui();m.gui.setScreen(new SanctuaryAtlasScreen(null));});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen a && a.isComplete() && a.observedTiles()>0,400);
c.runOnClient(m->zoom((SanctuaryAtlasScreen)m.gui.screen(),-23));
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen a && a.detail()>0 && a.isComplete() && allIslands(a),500);
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();check(a.zoom()<1./32,"Zoom passes one screen pixel per chunk");check(a.renderedPixelAt(9008,0)==0,"Remote destination is initially unknown");});
c.takeScreenshot("map279-five-islands-overview");
int token=c.computeOnClient(m->field(m.gui.screen(),"token",Integer.class));
// The camera remains at the origin while native chunks arrive beyond the remote survey fixture.
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(p.level(),9008.5,80,.5,Set.of(),0,0,true);});
var gold=new BlockPos(9008,100,0);
server.runOnServer(s->s.overworld().getChunk(563,0));
c.waitTicks(30);server.runOnServer(s->s.overworld().setBlockAndUpdate(gold,Blocks.GOLD_BLOCK.defaultBlockState()));
c.waitFor(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();return a.renderedPixelAt(9008,0)!=0;},200);
for(int attempt=0;attempt<30;attempt++) {
boolean surveyed=server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var tile=AtlasService.observations(p).get(563,0);return tile!=null && Byte.toUnsignedInt(tile.colors()[0])/4==MapColor.GOLD.id;});
if(surveyed)break;c.waitTicks(2);
}
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var tile=AtlasService.observations(p).get(563,0);
check(tile!=null && Byte.toUnsignedInt(tile.colors()[0])/4==MapColor.GOLD.id,"Native remote construction is surveyed after old capacity");});
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();check(field(a,"token",Integer.class)==token && Math.abs(a.centerX())<1,"Remote travel updates the same fixed overview stream");});
for(String lang:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.getLanguageManager().setSelected(lang);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(scale);m.resizeGui();});
c.waitTicks(15);c.takeScreenshot("map279-overview-"+lang+"-gui"+scale);
c.runOnClient(m->check(allIslands((SanctuaryAtlasScreen)m.gui.screen()),"Five islands remain painted after GUI resize"));
}
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();zoom(a,-12);});
c.waitFor(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();return a.zoom()<1./256 && a.isComplete();},400);
c.takeScreenshot("map279-beyond-256-blocks-per-pixel");
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();zoom(a,-100);});
c.waitFor(m->((SanctuaryAtlasScreen)m.gui.screen()).detail()==16 && ((SanctuaryAtlasScreen)m.gui.screen()).isComplete(),400);
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();check(Math.min(a.width,a.height)/a.zoom()>60000000,"The whole world fits both window axes");});
c.takeScreenshot("map279-world-scale");
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();zoom(a,100);a.recenter();});
c.waitFor(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();return a.detail()==0 && a.isComplete() && a.renderedPixelAt(9008,0)==MapColor.GOLD.calculateARGBColor(MapColor.Brightness.NORMAL);},400);
c.takeScreenshot("map279-return-to-blocks");
c.runOnClient(m->m.gui.setScreen(new PauseScreen(true)));
c.waitFor(m->{var map=((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap();return map!=null && map.knownPixels()>0;},300);
c.runOnClient(m->m.gui.setScreen(new SanctuaryAtlasScreen(null)));
c.waitFor(m->((SanctuaryAtlasScreen)m.gui.screen()).observedTiles()>0,300);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
s.overworld().setChunkForced(750,0,true);s.overworld().getChunk(750,0);s.overworld().setBlockAndUpdate(new BlockPos(12000,100,0),Blocks.DIAMOND_BLOCK.defaultBlockState());
check(!AtlasService.observations(p).contains(750,0),"Loaded operator fixture is outside personal discoveries");
((net.minecraft.client.server.IntegratedServer)s).setWorldAllowCommands(true);
});
c.waitFor(m->ProgressionClient.operator && field(m.gui.screen(),"operator",Boolean.class) && ((SanctuaryAtlasScreen)m.gui.screen()).observedTiles()>0,400);
c.runOnClient(m->zoom((SanctuaryAtlasScreen)m.gui.screen(),-25));
try{c.waitFor(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();return a.detail()>0 && a.isComplete() && a.renderedPixelAt(12000,0)!=0;},600);}
catch(AssertionError e){c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();System.out.println("MAP279_OPERATOR_DEBUG status="+a.status()+" detail="+a.detail()+" complete="+a.isComplete()+" tiles="+a.observedTiles()+" center="+a.centerX()+","+a.centerZ()+" operator="+field(a,"operator",Boolean.class));});server.runOnServer(s->System.out.println("MAP279_OPERATOR_LOADED="+(s.overworld().getChunkSource().getChunkNow(750,0)!=null)));throw e;}
c.takeScreenshot("map279-operator-overview");
server.runOnServer(s->{check(!AtlasService.observations(s.getPlayerList().getPlayers().getFirst()).contains(750,0),"Operator reduction never adds personal discoveries");((net.minecraft.client.server.IntegratedServer)s).setWorldAllowCommands(false);});
c.waitFor(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();return !ProgressionClient.operator && !field(a,"operator",Boolean.class) && a.isComplete() && a.observedTiles()>0 && a.renderedPixelAt(12000,0)==0;},400);
server.runOnServer(s->{s.overworld().setChunkForced(750,0,false);var p=s.getPlayerList().getPlayers().getFirst();p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);ProgressionService.send(p,"");});
c.waitFor(m->((SanctuaryAtlasScreen)m.gui.screen()).observedTiles()==0,200);
System.out.println("MAP279_CLIENT_PASS: Vulkan, expanded exploration, five separated surveys, fixed distant overview, world scale, return to exact blocks, Pause, FR/EN GUI 2/3/4, operator overview isolation and revocation");
}finally{intro.set(false);c.runOnClient(m->m.gui.setScreen(null));}
}
private static boolean allIslands(SanctuaryAtlasScreen a){for(var island:ISLANDS)if(a.renderedPixelAt(island[0],island[1])==0)return false;return true;}
private static void zoom(SanctuaryAtlasScreen a,double scroll){
double y=field(a,"focusY",Integer.class)+field(a,"focusSize",Integer.class)/2.;
check(a.mouseScrolled(a.width/2.,y,0,scroll),"Native map wheel accepted");
}
private static <T> T field(Object target,String name,Class<T> type){try{var f=target.getClass().getDeclaredField(name);f.setAccessible(true);return type.cast(f.get(target));}catch(Exception e){throw new RuntimeException(e);}}
}
@@ -0,0 +1,172 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.cosmetics.FamiliarService;
import fr.koka.sanctuary.cosmetics.FamiliarEntity;
import fr.koka.sanctuary.cosmetics.FamiliarSize;
import fr.koka.sanctuary.familiar.FamiliarBattle;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.infected.Infected;
import fr.koka.sanctuary.infected.SmokerEntity;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
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.FabricClientGameTest;
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.core.BlockPos;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.gamerules.GameRules;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** The custom renderer and profile must also work through ordinary familiar UI paths. */
public final class Smoker285ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var wild=new java.util.concurrent.atomic.AtomicReference<SmokerEntity>();
var enemy=new java.util.concurrent.atomic.AtomicReference<net.minecraft.world.entity.monster.zombie.Zombie>();
var familyFlying=new AtomicBoolean();var familyCaught=new AtomicBoolean();var wildFlying=new AtomicBoolean();var wildCaught=new AtomicBoolean();
var familyWire=new java.util.concurrent.atomic.AtomicReference<fr.koka.sanctuary.infected.SmokerLassoEntity>();
var familyAnchor=new java.util.concurrent.atomic.AtomicReference<net.minecraft.world.phys.Vec3>();
var wildWire=new java.util.concurrent.atomic.AtomicReference<fr.koka.sanctuary.infected.SmokerLassoEntity>();
net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents.END_SERVER_TICK.register(s->{
if(s.getPlayerList().getPlayers().isEmpty())return;var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);
var f=pet==null?null:fr.koka.sanctuary.infected.SmokerLassoEntity.active(pet);familyWire.set(f);
if(f!=null&&familyAnchor.get()==null)familyAnchor.set(pet.position());
familyFlying.set(f!=null&&!f.isReeling());if(f!=null&&f.isReeling())familyCaught.set(true);
var mob=wild.get();var w=mob==null||mob.isRemoved()?null:fr.koka.sanctuary.infected.SmokerLassoEntity.active(mob);wildWire.set(w);
wildFlying.set(w!=null&&!w.isReeling());if(w!=null&&w.isReeling()&&w.caughtEntity()==p)wildCaught.set(true);
});
fr.koka.sanctuary.testing.QuickTestWorld.enable();var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);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());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Smoker285-"+UUID.randomUUID());s.setSeed("285");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->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
s.setDifficulty(net.minecraft.world.Difficulty.NORMAL,true);
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set midnight");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"weather clear");
p.level().getGameRules().set(GameRules.SPAWN_MOBS,false,s);
p.getAbilities().invulnerable=true;p.getInventory().clearContent();p.teleportTo(.5,1,.5);p.setYRot(0);p.setXRot(0);
for(int x=-16;x<=16;x++)for(int z=-16;z<=22;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
for(int x=-2;x<=2;x++)for(int z:new int[]{3,7,11,15})p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.SEA_LANTERN.defaultBlockState());
var egg=new ItemStack(Infected.SMOKER_EGG);
var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",1000);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.getInventory().setItem(0,egg.copy());p.inventoryMenu.broadcastChanges();
var adult=Infected.SMOKER.create(p.level(),EntitySpawnReason.COMMAND);adult.setNoAi(true);adult.setPermanentlyInvulnerable(true);wild.set(adult);adult.setPos(.5,1,4.5);adult.setYRot(180);adult.yHeadRot=180;p.level().addFreshEntity(adult);
var zombie=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);zombie.setNoAi(true);zombie.setPermanentlyInvulnerable(true);enemy.set(zombie);zombie.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).setBaseValue(100);zombie.setHealth(100);zombie.setPos(-1.5,1,4.5);zombie.setYRot(180);zombie.yHeadRot=180;p.level().addFreshEntity(zombie);
var red=Infected.TYPE.create(p.level(),EntitySpawnReason.COMMAND);red.setNoAi(true);red.setPermanentlyInvulnerable(true);red.setPos(-3.5,1,4.5);red.setYRot(180);red.yHeadRot=180;p.level().addFreshEntity(red);
});
c.waitFor(m->FamiliarClient.profile!=null&&FamiliarClient.profile.id().equals("sanctuary:smoker"),400);c.waitTicks(15);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(FamiliarService.active(p)!=null&&!FamiliarService.active(p).babyAppearance(),"The integrated server equips an adult miniature");FamiliarBattle.send(p,"");});
c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);m.getWindow().setWindowed(1280,960);
var mob=Infected.SMOKER.create(m.level,EntitySpawnReason.MOB_SUMMONED);
check(m.getEntityRenderDispatcher().getRenderer(mob) instanceof InfectedRenderer,"Custom renderer is installed for adults and familiar proxies");
mob.setBaby(true);check(!mob.isBaby(),"The render proxy rejects native baby proportions");
check(m.getResourceManager().getResource(SanctuaryMod.id("textures/entity/smoker.png")).isPresent(),"Custom skin is packaged");
check(CompanionTooltips.rarity(new ItemStack(Infected.SMOKER_EGG))==fr.koka.sanctuary.companion.CompanionType.Rarity.RARE,"The new profile supplies tooltip rarity without a legacy enum");
m.player.getInventory().setSelectedSlot(1);
});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();FamiliarBattle.command(p,"follow");
wild.get().teleportTo(3.5,1,4.5);var pet=FamiliarService.active(p);pet.teleportTo(.5,1,1.5);pet.setOnGround(true);
enemy.get().teleportTo(.5,1,10.5);enemy.get().setPermanentlyInvulnerable(false);
});
c.runOnClient(m->{var key=net.minecraft.client.KeyMapping.get("sanctuary.companion.key");key.setKey(com.mojang.blaze3d.platform.InputConstants.Type.KEYBOARD.getOrCreate(com.mojang.blaze3d.platform.InputConstants.KEY_G));net.minecraft.client.KeyMapping.resetMapping();});
c.waitTicks(8);c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_G);
c.waitFor(m->familyFlying.get(),250);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(enemy.get().getHealth()==100,"Releasing G does not hit before physical contact");check(FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now(),"G commits cooldown when the lasso is launched");});
c.runOnClient(m->{var hook=m.level.getEntitiesOfClass(fr.koka.sanctuary.infected.SmokerLassoEntity.class,m.player.getBoundingBox().inflate(24)).stream().findFirst().orElseThrow();
var renderer=(SmokerLassoRenderer)m.getEntityRenderDispatcher().getRenderer(hook);var state=renderer.createRenderState(hook,1);
check(state.leashStates!=null&&!state.leashStates.isEmpty(),"The real client draws the native taut zipline cable");
});
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.takeScreenshot("smoker285-familiar-lasso-flight");
c.waitFor(m->familyCaught.get(),150);c.waitTicks(1);c.takeScreenshot("smoker285-familiar-pull");c.waitTicks(22);
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);
check(enemy.get().getHealth()<100&&enemy.get().position().distanceTo(familyAnchor.get())<2.3,"A real G impact drags the actual enemy near the miniature: hp="+enemy.get().getHealth()+" target="+enemy.get().position()+" pet="+pet.position());
check(pet.isAlive()&&FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now(),"The familiar survives and retains spent cooldown");
FamiliarBattle.command(p,"follow");wild.get().teleportTo(.5,1,4.5);enemy.get().setPermanentlyInvulnerable(true);enemy.get().teleportTo(-1.5,1,4.5);
});
for(int value:new int[]{500,1000,1500}) {
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
var egg=FamiliarBattle.egg(p).copy();var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",value);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.inventoryMenu.broadcastChanges();
});
c.waitFor(m->m.level.getEntitiesOfClass(FamiliarEntity.class,m.player.getBoundingBox().inflate(12)).stream()
.anyMatch(pet->FamiliarSize.value(pet.egg())==value),200);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);
check(pet!=null&&!pet.babyAppearance(),"Every size keeps the adult form");
pet.setNoAi(true);pet.teleportTo(2.5,1,4.5);pet.setYRot(180);pet.yHeadRot=180;pet.yBodyRot=180;pet.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO);
});c.waitTicks(8);
c.runOnClient(m->{var pet=m.level.getEntitiesOfClass(FamiliarEntity.class,m.player.getBoundingBox().inflate(12)).stream()
.filter(e->FamiliarSize.value(e.egg())==value).findFirst().orElseThrow();
var renderer=(FamiliarRenderer)m.getEntityRenderDispatcher().getRenderer(pet);
var state=renderer.createRenderState(pet,1);
check(state.appearance instanceof net.minecraft.client.renderer.entity.state.ZombieRenderState zombie&&!zombie.isBaby,"The real familiar renderer uses adult geometry");
check(Math.abs(state.size*state.appearance.boundingBoxHeight-.85*value/1000)<.001,"The displayed familiar preserves its size multiplier");
});
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(3);c.takeScreenshot("smoker285-miniature-"+value);
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
}
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(scale);m.resizeGui();
check(!new ItemStack(Infected.SMOKER_EGG).getHoverName().getString().contains("item.sanctuary"),"Egg label is translated");
var tooltip=new ItemStack(Infected.SMOKER_EGG).getTooltipLines(Item.TooltipContext.of(m.level),m.player,TooltipFlag.NORMAL);
String bite=locale.equals("fr_fr")?"Petit lasso":"Short lasso";
check(tooltip.stream().anyMatch(line->line.getString().contains(bite)),"Native tooltip callback includes the custom familiar technique in both languages");
m.gui.setScreen(new FamiliarScreen(null));
});c.waitTicks(4);c.takeScreenshot("smoker285-familiar-"+locale+"-gui"+scale);
}
c.runOnClient(m->m.gui.setScreen(null));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);
p.getAbilities().invulnerable=false;p.teleportTo(.5,1,.5);p.setYRot(0);p.setXRot(0);
var mob=wild.get();mob.teleportTo(.5,1,14.5);mob.setPermanentlyInvulnerable(false);mob.setTarget(p);mob.setOnGround(true);mob.setNoAi(false);
});
c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);});
c.waitFor(m->wildFlying.get(),250);
server.runOnServer(s->wild.get().setNoAi(true));
c.getInput().holdKey(o->o.keyRight);c.waitTicks(12);c.getInput().releaseKey(o->o.keyRight);c.waitTicks(20);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
check(!wildCaught.get()&&Math.abs(p.getX()-.5)>.65&&p.getZ()<2&&fr.koka.sanctuary.infected.SmokerLassoEntity.active(wild.get())==null,"A real sideways keyboard input dodges the flying lasso without a guaranteed grab");
check(wild.get().lassoCooldown()>0,"A missed wild shot still pays its cooldown");
wild.get().discard();var mob=Infected.SMOKER.create(p.level(),EntitySpawnReason.COMMAND);mob.setPos(.5,1,14.5);mob.setOnGround(true);wild.set(mob);p.level().addFreshEntity(mob);
p.teleportTo(.5,1,.5);p.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO);p.setYRot(0);p.setXRot(0);mob.setTarget(p);wildCaught.set(false);wildFlying.set(false);
});c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);});
c.waitFor(m->wildFlying.get(),250);server.runOnServer(s->wild.get().setNoAi(true));
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.takeScreenshot("smoker285-wild-lasso-flight");
c.waitFor(m->wildCaught.get(),150);c.waitTicks(1);c.takeScreenshot("smoker285-player-captured");
double capturedZ=server.computeOnServer(s->s.getPlayerList().getPlayers().getFirst().getZ());
c.getInput().holdKey(o->o.keyDown);c.getInput().holdKey(o->o.keyShift);c.waitTicks(5);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(p.getZ()>capturedZ+.5,"A successful hostile hit reels the player even against backward/sneak input");});
c.getInput().releaseKey(o->o.keyDown);c.getInput().releaseKey(o->o.keyShift);
c.waitFor(m->wildWire.get()==null,200);c.waitTicks(3);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(p.distanceTo(wild.get())<2.2&&!fr.koka.sanctuary.infected.SmokerLassoEntity.pulled(p)&&!p.isNoGravity(),"The real player arrives near the Smoker, releases the cable and keeps normal gravity");});
c.runOnClient(m->check(m.player.getZ()>11,"The rendered player travels with the authoritative zipline"));
c.takeScreenshot("smoker285-player-arrival");c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
wild.get().discard();wild.set(null);enemy.get().discard();
p.level().getEntitiesOfClass(fr.koka.sanctuary.infected.SmokerLassoEntity.class,p.getBoundingBox().inflate(32)).forEach(Entity::discard);
p.level().getEntitiesOfClass(fr.koka.sanctuary.infected.InfectedEntity.class,p.getBoundingBox().inflate(32)).forEach(Entity::discard);
});c.waitTicks(20);
System.out.println("SMOKER285_CLIENT_PASS: Vulkan, native cable renderer, real G physical hit and pull, real keyboard dodge, hostile player zipline against input and arrival, three adult miniature sizes, FR/EN GUI 2/3/4");
}
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,153 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.cosmetics.FamiliarService;
import fr.koka.sanctuary.cosmetics.FamiliarEntity;
import fr.koka.sanctuary.cosmetics.FamiliarSize;
import fr.koka.sanctuary.familiar.FamiliarBattle;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.infected.Infected;
import fr.koka.sanctuary.infected.SpitterEntity;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
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.FabricClientGameTest;
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.core.BlockPos;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.gamerules.GameRules;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** The custom renderer and profile must also work through ordinary familiar UI paths. */
public final class Spitter283ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var wild=new java.util.concurrent.atomic.AtomicReference<SpitterEntity>();
var enemy=new java.util.concurrent.atomic.AtomicReference<net.minecraft.world.entity.monster.zombie.Zombie>();
var preparing=new AtomicBoolean();
var cloud=new java.util.concurrent.atomic.AtomicReference<fr.koka.sanctuary.infected.AcidZoneEntity>();
net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents.END_SERVER_TICK.register(s->{var mob=wild.get();if(mob!=null&&!mob.isRemoved()){
preparing.set(mob.windingUp());cloud.set(fr.koka.sanctuary.infected.AcidZoneEntity.active(mob));
}});
fr.koka.sanctuary.testing.QuickTestWorld.enable();var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);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());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Spitter283-"+UUID.randomUUID());s.setSeed("283");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->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
s.setDifficulty(net.minecraft.world.Difficulty.NORMAL,true);
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set midnight");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"weather clear");
p.level().getGameRules().set(GameRules.SPAWN_MOBS,false,s);
p.getAbilities().invulnerable=true;p.getInventory().clearContent();p.teleportTo(.5,1,.5);p.setYRot(0);p.setXRot(0);
for(int x=-16;x<=16;x++)for(int z=-16;z<=16;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
for(int x=-2;x<=2;x++)p.level().setBlockAndUpdate(new BlockPos(x,0,3),Blocks.SEA_LANTERN.defaultBlockState());
var egg=new ItemStack(Infected.SPITTER_EGG);
var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",1000);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.getInventory().setItem(0,egg.copy());p.inventoryMenu.broadcastChanges();
var adult=Infected.SPITTER.create(p.level(),EntitySpawnReason.COMMAND);adult.setNoAi(true);adult.setPermanentlyInvulnerable(true);wild.set(adult);adult.setPos(.5,1,4.5);adult.setYRot(180);adult.yHeadRot=180;p.level().addFreshEntity(adult);
var zombie=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);zombie.setNoAi(true);zombie.setPermanentlyInvulnerable(true);enemy.set(zombie);zombie.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).setBaseValue(100);zombie.setHealth(100);zombie.setPos(-1.5,1,4.5);zombie.setYRot(180);zombie.yHeadRot=180;p.level().addFreshEntity(zombie);
var red=Infected.TYPE.create(p.level(),EntitySpawnReason.COMMAND);red.setNoAi(true);red.setPermanentlyInvulnerable(true);red.setPos(-3.5,1,4.5);red.setYRot(180);red.yHeadRot=180;p.level().addFreshEntity(red);
});
c.waitFor(m->FamiliarClient.profile!=null&&FamiliarClient.profile.id().equals("sanctuary:spitter"),400);c.waitTicks(15);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(FamiliarService.active(p)!=null&&!FamiliarService.active(p).babyAppearance(),"The integrated server equips an adult miniature");FamiliarBattle.send(p,"");});
c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);m.getWindow().setWindowed(1280,960);
var mob=Infected.SPITTER.create(m.level,EntitySpawnReason.MOB_SUMMONED);
check(m.getEntityRenderDispatcher().getRenderer(mob) instanceof InfectedRenderer,"Custom renderer is installed for adults and familiar proxies");
mob.setBaby(true);check(!mob.isBaby(),"The render proxy rejects native baby proportions");
check(m.getResourceManager().getResource(SanctuaryMod.id("textures/entity/spitter.png")).isPresent(),"Custom skin is packaged");
check(CompanionTooltips.rarity(new ItemStack(Infected.SPITTER_EGG))==fr.koka.sanctuary.companion.CompanionType.Rarity.RARE,"The new profile supplies tooltip rarity without a legacy enum");
m.player.getInventory().setSelectedSlot(1);
});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();FamiliarBattle.command(p,"follow");
var pet=FamiliarService.active(p);pet.teleportTo(.5,1,1.5);pet.setOnGround(true);
enemy.get().teleportTo(.5,1,6.5);enemy.get().setPermanentlyInvulnerable(false);
});
c.runOnClient(m->{var key=net.minecraft.client.KeyMapping.get("sanctuary.companion.key");key.setKey(com.mojang.blaze3d.platform.InputConstants.Type.KEYBOARD.getOrCreate(com.mojang.blaze3d.platform.InputConstants.KEY_G));net.minecraft.client.KeyMapping.resetMapping();});
c.waitTicks(8);c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_G);c.waitTicks(55);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
var zone=fr.koka.sanctuary.infected.AcidZoneEntity.active(FamiliarService.active(p));
check(zone!=null&&!zone.isRemoved()&&zone instanceof AreaEffectCloud,"G creates a real native potion-style cloud");
check(enemy.get().getHealth()<100&&enemy.get().getActiveEffects().isEmpty(),"Only the grounded zone damages without poison or slowness");
check(FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now()&&FamiliarBattle.state(p).cast==null,"G commits cooldown when the projectile is released");
});
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(3);c.takeScreenshot("spitter283-acid-zone");
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
var healthAfterExit=new java.util.concurrent.atomic.AtomicReference<Float>();
server.runOnServer(s->{healthAfterExit.set(enemy.get().getHealth());enemy.get().teleportTo(-3.5,1,6.5);});c.waitTicks(25);
server.runOnServer(s->{check(enemy.get().getHealth()==healthAfterExit.get(),"Leaving the field stops subsequent damage immediately");
enemy.get().setPermanentlyInvulnerable(true);enemy.get().teleportTo(-1.5,1,4.5);
});
for(int value:new int[]{500,1000,1500}) {
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
var egg=FamiliarBattle.egg(p).copy();var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",value);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.inventoryMenu.broadcastChanges();
});
c.waitFor(m->m.level.getEntitiesOfClass(FamiliarEntity.class,m.player.getBoundingBox().inflate(12)).stream()
.anyMatch(pet->FamiliarSize.value(pet.egg())==value),200);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);
check(pet!=null&&!pet.babyAppearance(),"Every size keeps the adult form");
pet.setNoAi(true);pet.teleportTo(2.5,1,4.5);pet.setYRot(180);pet.yHeadRot=180;pet.yBodyRot=180;pet.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO);
});c.waitTicks(8);
c.runOnClient(m->{var pet=m.level.getEntitiesOfClass(FamiliarEntity.class,m.player.getBoundingBox().inflate(12)).stream()
.filter(e->FamiliarSize.value(e.egg())==value).findFirst().orElseThrow();
var renderer=(FamiliarRenderer)m.getEntityRenderDispatcher().getRenderer(pet);
var state=renderer.createRenderState(pet,1);
check(state.appearance instanceof net.minecraft.client.renderer.entity.state.ZombieRenderState zombie&&!zombie.isBaby,"The real familiar renderer uses adult geometry");
check(Math.abs(state.size*state.appearance.boundingBoxHeight-.85*value/1000)<.001,"The displayed familiar preserves its size multiplier");
});
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(3);c.takeScreenshot("spitter283-miniature-"+value);
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
}
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(scale);m.resizeGui();
check(!new ItemStack(Infected.SPITTER_EGG).getHoverName().getString().contains("item.sanctuary"),"Egg label is translated");
var tooltip=new ItemStack(Infected.SPITTER_EGG).getTooltipLines(Item.TooltipContext.of(m.level),m.player,TooltipFlag.NORMAL);
String bite=locale.equals("fr_fr")?"Petite flaque acide":"Small acid pool";
check(tooltip.stream().anyMatch(line->line.getString().contains(bite)),"Native tooltip callback includes the custom familiar technique in both languages");
m.gui.setScreen(new FamiliarScreen(null));
});c.waitTicks(4);c.takeScreenshot("spitter283-familiar-"+locale+"-gui"+scale);
}
c.runOnClient(m->m.gui.setScreen(null));
var cow=new java.util.concurrent.atomic.AtomicReference<net.minecraft.world.entity.animal.cow.Cow>();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setGameMode(net.minecraft.world.level.GameType.CREATIVE);
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);
var target=EntityTypes.COW.create(p.level(),EntitySpawnReason.COMMAND);target.setNoAi(true);target.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).setBaseValue(100);target.setHealth(100);target.setPos(.5,1,10.5);p.level().addFreshEntity(target);cow.set(target);
var mob=wild.get();mob.setPermanentlyInvulnerable(false);mob.setTarget(target);mob.setOnGround(true);mob.setNoAi(false);
});
c.waitFor(m->preparing.get(),200);c.waitTicks(2);
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(2);c.takeScreenshot("spitter283-windup");
c.waitFor(m->cloud.get()!=null&&!cloud.get().isRemoved(),100);c.waitTicks(25);
server.runOnServer(s->check(cow.get().getHealth()<100&&cow.get().getActiveEffects().isEmpty(),"Wild projectile creates a damaging zone without status effects"));
c.takeScreenshot("spitter283-wild-zone");
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);
var zone=fr.koka.sanctuary.infected.AcidZoneEntity.active(wild.get());if(zone!=null)zone.discard();
wild.get().discard();wild.set(null);cow.get().discard();enemy.get().discard();
p.level().getEntitiesOfClass(fr.koka.sanctuary.infected.AcidSpitEntity.class,p.getBoundingBox().inflate(24)).forEach(Entity::discard);
p.level().getEntitiesOfClass(fr.koka.sanctuary.infected.InfectedEntity.class,p.getBoundingBox().inflate(20)).forEach(Entity::discard);
});c.waitTicks(20);
System.out.println("SPITTER283_CLIENT_PASS: Vulkan, adult skin, three miniature sizes, real G projectile/cloud/damage/exit/cooldown, wild windup/cloud, FR/EN GUI 2/3/4");
}
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,110 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.infected.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
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.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.monster.zombie.Zombie;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.gamerules.GameRules;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Real Vulkan models, stable wardrobes, native baby UVs, native forward arms and Hunter locomotion. */
public final class Zombie287ClientChecks implements FabricClientGameTest {
private static final List<EntityType<? extends Zombie>> TYPES=List.of(EntityTypes.ZOMBIE,Infected.TYPE,Infected.HUNTER,Infected.BOOMER,Infected.CHARGER,Infected.SPITTER,Infected.SMOKER);
private static final List<Item> EGGS=List.of(Items.ZOMBIE_SPAWN_EGG,Infected.SPAWN_EGG,Infected.HUNTER_EGG,Infected.BOOMER_EGG,Infected.CHARGER_EGG,Infected.SPITTER_EGG,Infected.SMOKER_EGG);
private static final List<String> NAMES=List.of("Zombie","Infecté","Hunter","Boomer","Charger","Spitter","Smoker");
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
private static ItemStack sized(ItemStack egg,int size){
var result=egg.copy();var tag=new net.minecraft.nbt.CompoundTag();tag.putInt("schema",1);tag.putInt("per_mille",size);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,result,t->t.put(FamiliarSize.KEY,tag));return result;
}
private static InfectedRenderer.State state(net.minecraft.client.Minecraft m,Zombie mob){
check(m.getEntityRenderDispatcher().getRenderer(mob) instanceof InfectedRenderer,"Ordinary zombies and every infected share the new renderer");
return ((InfectedRenderer)m.getEntityRenderDispatcher().getRenderer(mob)).createRenderState(mob,1);
}
private static void posture(InfectedRenderer.State s,boolean hunter) {
var model=new net.minecraft.client.model.monster.zombie.ZombieModel<InfectedRenderer.State>(net.minecraft.client.Minecraft.getInstance().getEntityModels().bakeLayer(net.minecraft.client.model.geom.ModelLayers.ZOMBIE));
s.walkAnimationSpeed=.6F;s.walkAnimationPos=2;s.swingAnimation=0;model.setupAnim(s);
check(model.rightArm.xRot< -.5&&model.leftArm.xRot< -.5,"Every zombie keeps both native arms forward");
if(hunter)check(s.isCrouching&&model.body.xRot>.4,"Hunter keeps its crouched walking skeleton");
else check(!s.isCrouching&&Math.abs(model.body.xRot)<.01,"Other roles retain their native standing posture");
float leg=model.rightLeg.xRot;s.walkAnimationPos=5;model.setupAnim(s);
check(Math.abs(model.rightLeg.xRot-leg)>.1,"Role silhouettes retain native leg locomotion");
}
public void runTest(ClientGameTestContext c) {
var staged=new ArrayList<Zombie>();var intro=new AtomicBoolean(true);var hunterId=new AtomicInteger(-1);
fr.koka.sanctuary.testing.QuickTestWorld.enable();
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);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());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Zombies287-"+UUID.randomUUID());s.setSeed("287");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->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();s.setDifficulty(net.minecraft.world.Difficulty.NORMAL,true);
for(String command:List.of("sanctuary time vanilla","time set midnight","sanctuary weather vanilla","weather clear"))s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),command);
p.level().getGameRules().set(GameRules.SPAWN_MOBS,false,s);p.getAbilities().invulnerable=true;p.teleportTo(.5,1,-2.5);p.getInventory().clearContent();
for(int x=-26;x<=26;x++)for(int z=-16;z<=26;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
for(int x=-14;x<=14;x++)for(int z:new int[]{6,10,14})p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.SEA_LANTERN.defaultBlockState());
for(int n=0;n<9;n++){var mob=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);mob.setUUID(new UUID(0,n));mob.setNoAi(true);mob.setPermanentlyInvulnerable(true);mob.setPos((n-4)*2+.5,1,8.5);mob.setYRot(180);mob.yHeadRot=180;mob.yBodyRot=180;p.level().addFreshEntity(mob);staged.add(mob);}
});c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.player.setYRot(0);m.player.setXRot(0);});c.waitTicks(20);
c.runOnClient(m->{var found=new HashSet<Integer>();
for(var type:TYPES)for(int i=0;i<9;i++){
var mob=type.create(m.level,EntitySpawnReason.COMMAND);mob.setId(100000+i);mob.setUUID(new UUID(0,i));mob.setOnGround(true);var a=state(m,mob);
check(a.skin==i,"Every role can wear all nine supplied skins");found.add(a.skin);var renderer=(InfectedRenderer)m.getEntityRenderDispatcher().getRenderer(mob);
check(renderer.getTextureLocation(a).equals(ZombieSkins.texture(i,false))&&m.getResourceManager().getResource(renderer.getTextureLocation(a)).isPresent(),"The selected adult texture is available");
mob.tickCount+=200;mob.setPos(5,2,8);check(state(m,mob).skin==i,"Time/movement do not reroll clothing");
var clone=type.create(m.level,EntitySpawnReason.LOAD);clone.setId(100100+i);clone.setUUID(mob.getUUID());check(state(m,clone).skin==i,"Reloaded identity retains its clothing");
}
check(found.size()==9,"All nine variants are reachable");
});c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(2);c.takeScreenshot("zombie287-nine-skins");c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();staged.forEach(Entity::discard);staged.clear();
for(int i=0;i<TYPES.size();i++){var mob=TYPES.get(i).create(p.level(),EntitySpawnReason.COMMAND);mob.setUUID(new UUID(10,i));mob.setNoAi(true);mob.setPermanentlyInvulnerable(true);mob.setPos((i-3)*2.4+.5,1,8.5);mob.setYRot(180);mob.yHeadRot=180;mob.yBodyRot=180;mob.setCustomName(Component.literal(NAMES.get(i)));mob.setCustomNameVisible(true);p.level().addFreshEntity(mob);staged.add(mob);}
var baby=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);baby.setBaby(true);baby.setNoAi(true);baby.setPos(9.5,1,8.5);baby.setYRot(180);baby.yHeadRot=180;p.level().addFreshEntity(baby);staged.add(baby);
});c.waitTicks(18);
c.runOnClient(m->{for(int i=0;i<TYPES.size();i++){final int at=i;var mob=m.level.getEntitiesOfClass(Zombie.class,m.player.getBoundingBox().inflate(30)).stream().filter(e->e.getUUID().equals(new UUID(10,at))).findFirst().orElseThrow();var a=state(m,mob);posture(a,i==2);}
var baby=m.level.getEntitiesOfClass(Zombie.class,m.player.getBoundingBox().inflate(30)).stream().filter(Zombie::isBaby).findFirst().orElseThrow();var a=state(m,baby);check(a.isBaby&&m.getResourceManager().getResource(ZombieSkins.texture(a.skin,true)).isPresent(),"Ordinary baby has its native model and adapted UV texture");
});c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(2);c.takeScreenshot("zombie287-role-postures");c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
server.runOnServer(s->{staged.forEach(Entity::discard);staged.clear();s.getPlayerList().getPlayers().getFirst().teleportTo(.5,1,-6.5);});
// No nearby monsters during wardrobe checks: changing species must not enter combat.
for(int i=0;i<EGGS.size();i++)for(int size:new int[]{500,1000,1500}){
final int at=i;server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var current=FamiliarBattle.egg(p);var egg=!current.isEmpty()&&current.is(EGGS.get(at))?current:new ItemStack(EGGS.get(at));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,sized(egg,size));FamiliarService.tick(p);p.inventoryMenu.broadcastChanges();});
c.waitFor(m->m.level.getEntitiesOfClass(FamiliarEntity.class,m.player.getBoundingBox().inflate(15)).stream().anyMatch(e->e.egg().is(EGGS.get(at))&&FamiliarSize.value(e.egg())==size),200);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);check(pet!=null,"Each egg equips a familiar");pet.setNoAi(true);pet.teleportTo(.5,1,-2.5);pet.setYRot(180);pet.yHeadRot=180;pet.yBodyRot=180;});c.waitTicks(4);
c.runOnClient(m->{var pet=m.level.getEntitiesOfClass(FamiliarEntity.class,m.player.getBoundingBox().inflate(15)).stream().filter(e->e.egg().is(EGGS.get(at))).findFirst().orElseThrow();var renderer=(FamiliarRenderer)m.getEntityRenderDispatcher().getRenderer(pet);var render=renderer.createRenderState(pet,1);var a=(InfectedRenderer.State)render.appearance;
check(a.isBaby==(at==0),"Infected familiars keep adult geometry at every scale");check(a.skin==ZombieSkins.index(FamiliarIdentity.get(pet.egg())),"Familiar clothing uses its persistent bond identity");if(at>0)posture(a,at==2);
});
if(size==1000){c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.waitTicks(1);c.takeScreenshot("zombie287-familiar-"+NAMES.get(at));c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);}
}
var remembered=new AtomicReference<UUID>();var savedEgg=new AtomicReference<ItemStack>();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();savedEgg.set(FamiliarBattle.egg(p).copy());remembered.set(FamiliarIdentity.get(savedEgg.get()));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);});c.waitTicks(5);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,savedEgg.get());FamiliarService.tick(p);});c.waitFor(m->m.level.getEntitiesOfClass(FamiliarEntity.class,m.player.getBoundingBox().inflate(15)).stream().anyMatch(e->remembered.get().equals(FamiliarIdentity.get(e.egg()))),200);
server.runOnServer(s->FamiliarService.active(s.getPlayerList().getPlayers().getFirst()).setNoAi(true));c.waitTicks(5);
c.runOnClient(m->{var pet=m.level.getEntitiesOfClass(FamiliarEntity.class,m.player.getBoundingBox().inflate(15)).stream().findFirst().orElseThrow();var a=(InfectedRenderer.State)((FamiliarRenderer)m.getEntityRenderDispatcher().getRenderer(pet)).createRenderState(pet,1).appearance;check(a.skin==ZombieSkins.index(remembered.get()),"Re-equipping the same individual retains clothing");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);p.teleportTo(.5,1,-6.5);p.getAbilities().invulnerable=false;
var mob=Infected.HUNTER.create(p.level(),EntitySpawnReason.COMMAND);mob.setPos(.5,1,10.5);mob.setOnGround(true);p.level().addFreshEntity(mob);mob.setTarget(p);hunterId.set(mob.getId());staged.add(mob);
});c.waitFor(m->{var e=m.level.getEntity(hunterId.get());return e instanceof HunterEntity h&&state(m,h).isCrouching&&state(m,h).walkAnimationSpeed>.05;},250);
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.takeScreenshot("zombie287-hunter-walking-sneak");c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
c.waitFor(m->m.level.getEntity(hunterId.get()) instanceof HunterEntity h&&h.getY()>1.5&&!state(m,h).isCrouching,300);
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);c.takeScreenshot("zombie287-hunter-jumping");c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_F1);
server.runOnServer(s->{staged.forEach(Entity::discard);staged.clear();});c.waitTicks(20);
System.out.println("ZOMBIE287_CLIENT_PASS: Vulkan, nine shared stable skins, normal zombie/native baby UV, native forward arms for all seven types, all familiar roles at three sizes, persistent re-equip wardrobe, real walking Hunter crouch and airborne standing jump");
}
}
}
@@ -0,0 +1,109 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
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.*;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.CameraType;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.item.*;
import net.minecraft.world.level.LightLayer;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.gamerules.GameRules;
/** GPU brightness on real terrain, independent of the PBR response or held-item pixels. */
public final class HandLight283ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
fr.koka.sanctuary.testing.QuickTestWorld.enable();
var intro=new AtomicBoolean(true);
c.runOnClient(m->{
check(com.mojang.blaze3d.systems.RenderSystem.getDevice().getDeviceInfo().backendName().equalsIgnoreCase("vulkan"),"Vulkan required");
m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;
m.options.bobView().set(false);m.options.setCameraType(CameraType.FIRST_PERSON);
m.getWindow().setWindowed(1280,800);VanillaLight.setEnabled(false);
});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);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("HandLight283-"+UUID.randomUUID());s.setSeed("283");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()) {
c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{
command(s,"sanctuary time vanilla");command(s,"sanctuary weather vanilla");command(s,"time set midnight");command(s,"weather clear");
s.getGlobalGameRules().set(GameRules.ADVANCE_TIME,false,s);s.getGlobalGameRules().set(GameRules.SPAWN_MOBS,false,s);
var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();
for(int x=-10;x<=10;x++)for(int z=-10;z<=10;z++)for(int y=99;y<=107;y++)
s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),(y==99||y==107||Math.abs(x)==10||Math.abs(z)==10?Blocks.STONE:Blocks.AIR).defaultBlockState());
p.teleportTo(.5,100,.5);
});
c.waitFor(m->m.player.getY()>99,200);look(c,server,0,35);c.waitTicks(60);
c.getInput().pressKey(InputConstants.KEY_F1);settle(c);
var dark=capture(c,"hand283-dark");double darkMean=mean(dark);
equip(c,server,EquipmentSlot.MAINHAND,Items.TORCH);var torch=capture(c,"hand283-main-torch");
double torchMean=mean(torch);System.out.println("HAND283_BRIGHTNESS dark="+darkMean+" torch="+torchMean);
check(torchMean>darkMean+12,"Held torch must brighten terrain with PBR and shader OFF: "+darkMean+" -> "+torchMean);
Runnable pinned=FabricLoader.getInstance().isModLoaded("sodium")?SodiumHandLight283Checks.capture(c):()->{};
for(boolean ao:new boolean[]{false,true}) {
c.runOnClient(m->{m.options.ambientOcclusion().set(ao);m.levelExtractor.allChanged();});settle(c);
check(mean(capture(c,"hand283-ao-"+ao))>darkMean+12,"Both flat and smooth terrain receive hand light");
}
equip(c,server,EquipmentSlot.MAINHAND,null);check(Math.abs(mean(capture(c,null))-darkMean)<2,"Removing torch restores darkness");
pinned.run();
equip(c,server,EquipmentSlot.OFFHAND,Items.LANTERN);check(mean(capture(c,"hand283-offhand-lantern"))>darkMean+12,"Offhand lantern brightens terrain");
c.runOnClient(m->{VanillaLight.setProfile(ShaderProfile.MEDIUM);VanillaLight.setEnabled(true);VanillaLight.setPbr(false);});settle(c);
check(mean(capture(c,"hand283-medium-pbr-off"))>darkMean+12,"Medium shader keeps actual light without PBR");
c.runOnClient(m->VanillaLight.setPbr(true));settle(c);check(mean(capture(c,"hand283-medium-pbr-on"))>darkMean+12,"PBR keeps terrain illumination");
c.runOnClient(m->VanillaLight.setEnabled(false));equip(c,server,EquipmentSlot.OFFHAND,null);
check(Math.abs(mean(capture(c,"hand283-removed"))-darkMean)<2,"No stale light remains");
equip(c,server,EquipmentSlot.MAINHAND,Items.TORCH);
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(8.5,100,.5));c.waitTicks(12);settle(c);
c.runOnClient(m->{var lights=DynamicLightClient.snapshot(m.level);
check((lights.apply(0,8.5,101.5,.5)&255)>200,"Source follows the moving holder");
check((lights.apply(0,.5,101.5,.5)&255)<120,"Old position no longer has full brightness");});
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(.5,100,.5));
equip(c,server,EquipmentSlot.MAINHAND,null);
server.runOnServer(s->{var drop=new net.minecraft.world.entity.item.ItemEntity(s.overworld(),.5,100,2.5,new ItemStack(Items.TORCH));
drop.setNoGravity(true);drop.setPickUpDelay(32767);s.overworld().addFreshEntity(drop);});c.waitTicks(12);settle(c);
check(mean(capture(c,"hand283-dropped"))>darkMean+12,"Dropped sources use the same terrain bridge");
server.runOnServer(s->{var drops=new java.util.ArrayList<net.minecraft.world.entity.Entity>();s.overworld().getAllEntities().forEach(e->{if(e instanceof net.minecraft.world.entity.item.ItemEntity)drops.add(e);});drops.forEach(net.minecraft.world.entity.Entity::discard);});
c.waitTicks(12);settle(c);check(Math.abs(mean(capture(c,null))-darkMean)<2,"Removing dropped source restores darkness");
c.runOnClient(m->{check(m.level.getBrightness(LightLayer.BLOCK,new BlockPos(0,101,2))==0,"World light engine remains untouched");
check(DynamicLightClient.snapshot(m.level).size()==0,"Sources are cleared");});
System.out.println("HAND283_PASS sodium="+FabricLoader.getInstance().isModLoaded("sodium")+" dark="+darkMean+" torch="+torchMean+" Vulkan hands, AO, shader, PBR, movement, drop, cleanup");
} finally {intro.set(false);}
c.runOnClient(m->check(DynamicLightClient.pendingSections()==0,"Disconnect clears pending sections"));
}
private static void equip(ClientGameTestContext c,TestServerContext server,EquipmentSlot slot,Item item) {
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setItemSlot(slot,item==null?ItemStack.EMPTY:new ItemStack(item));p.inventoryMenu.broadcastChanges();});
c.waitTicks(12);settle(c);
}
private static net.minecraft.client.input.KeyEvent enter() {return new net.minecraft.client.input.KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void settle(ClientGameTestContext c) {
c.waitFor(m->DynamicLightClient.pendingSections()==0&&m.levelRenderer.hasRenderedAllSections(),1200);c.waitTicks(25);
}
private static double mean(Frame frame) {
double sum=0;int count=0;
// Centre-left floor, away from GUI, arm, item and room edges.
for(int y=frame.height()/3;y<frame.height()*2/3;y++)for(int x=frame.width()/4;x<frame.width()/2;x++) {
int pixel=frame.pixels()[y*frame.width()+x];sum+=.2126*(pixel>>16&255)+.7152*(pixel>>8&255)+.0722*(pixel&255);count++;
}
return sum/count;
}
}
@@ -0,0 +1,38 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.check;
import fr.koka.sanctuary.client.DynamicLightRegion;
import fr.koka.sanctuary.lighting.DynamicLightField;
import java.util.concurrent.CompletableFuture;
import net.caffeinemc.mods.sodium.client.world.LevelSlice;
import net.caffeinemc.mods.sodium.client.world.cloned.ClonedChunkSectionCache;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.core.BlockPos;
import net.minecraft.core.SectionPos;
import net.minecraft.world.level.LightLayer;
/** Loaded only with the optional exact pack dependency. Exercise late/reused worker jobs. */
final class SodiumHandLight283Checks {
private static final BlockPos PROBE=new BlockPos(0,101,2);
static Runnable capture(ClientGameTestContext c) {
var context=c.computeOnClient(m->LevelSlice.prepare(m.level,SectionPos.of(PROBE),new ClonedChunkSectionCache(m.level)));
var snapshot=((DynamicLightRegion)context).sanctuary$lightSnapshot();
check(snapshot.size()==1,"Sodium job captures the hand source");
var slice=c.computeOnClient(m->new LevelSlice(m.level));
return ()->{
var fresh=c.computeOnClient(m->LevelSlice.prepare(m.level,SectionPos.of(PROBE),new ClonedChunkSectionCache(m.level)));
var future=CompletableFuture.runAsync(()->{
slice.copyData(context);
check(((DynamicLightRegion)(Object)slice).sanctuary$lightSnapshot()==snapshot,"Late worker keeps its submitted immutable snapshot");
check(slice.getBrightness(LightLayer.BLOCK,PROBE)>10,"Delayed job stays lit after live source removal");
check(slice.getBrightness(LightLayer.SKY,PROBE)==0,"Sodium sky light remains unchanged");
slice.copyData(fresh);
check(slice.getBrightness(LightLayer.BLOCK,PROBE)==0,"Reused worker replaces old source data");
slice.reset();
check(((DynamicLightRegion)(Object)slice).sanctuary$lightSnapshot()==DynamicLightField.Snapshot.EMPTY,"Reset releases the snapshot");
});
c.waitFor(m->future.isDone(),200);future.join();
System.out.println("HAND283_SODIUM_SNAPSHOT_PASS late worker, replacement, sky, reset");
};
}
}
@@ -52,6 +52,18 @@ public final class Atlas244GameTests {
&& h.getLevel().getChunkSource().getChunkNow(far,far)==null,"No chunk ticket or terrain generation for the atlas");
h.succeed();
}
@GameTest public void deliveredTerrainContinuesBeyondOldCapacity(GameTestHelper h) {
var player=inhabitant(h,GameType.CREATIVE);var store=AtlasService.observations(player);
byte[] history=new byte[256];Arrays.fill(history,(byte)42);
for(int i=0;i<16384;i++)store.observe(new AtlasStore.Tile(i,1000,history));
var origin=h.absolutePos(BlockPos.ZERO);
var chunk=h.getLevel().getChunkSource().getChunkNow(origin.getX()>>4,origin.getZ()>>4);
h.assertTrue(!store.full(),"Old full atlas can record new islands");
AtlasService.observeSentChunk(player,chunk);
h.assertTrue(store.size()>16384 && store.contains(chunk.getPos().x(),chunk.getPos().z()),"Native sent chunk is recorded after the old ceiling");
for(int altitude:AtlasAltitude.LEVELS)h.assertTrue(AtlasService.observations(player,altitude).contains(chunk.getPos().x(),chunk.getPos().z()),"All six layers still survey delivered terrain");
h.succeed();
}
private static ServerPlayer inhabitant(GameTestHelper h,GameType mode) {
var p=(ServerPlayer)h.makeMockServerPlayer(mode);
p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);return p;
@@ -0,0 +1,174 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.infected.*;
import fr.koka.sanctuary.inventory.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.animal.cow.Cow;
import net.minecraft.world.entity.monster.zombie.Zombie;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.*;
import net.minecraft.world.phys.Vec3;
/** Native cone, death splash and temporary diversion; familiar permission and miniature paths. */
public final class Boomer284GameTests {
private static final class StillZombie extends Zombie {
StillZombie(net.minecraft.world.level.Level level){super(EntityTypes.ZOMBIE,level);}
@Override protected void registerGoals() { }
}
private static BlockPos arena(GameTestHelper h,int slot) {
var at=h.absolutePos(new BlockPos(0,174+slot*11,0));
for(int x=-8;x<=8;x++)for(int z=-6;z<=15;z++) {
h.getLevel().setBlockAndUpdate(at.offset(x,-1,z),Blocks.STONE.defaultBlockState());
h.getLevel().setBlockAndUpdate(at.offset(x,7,z),Blocks.STONE.defaultBlockState());
}
return at;
}
private static BoomerEntity boomer(GameTestHelper h,BlockPos at) {
var source=Infected.BOOMER.create(h.getLevel(),EntitySpawnReason.COMMAND);source.setNoAi(true);source.setOnGround(true);
source.setPos(Vec3.atBottomCenterOf(at));h.getLevel().addFreshEntity(source);return source;
}
private static Cow cow(GameTestHelper h,Vec3 at) {
var target=EntityTypes.COW.create(h.getLevel(),EntitySpawnReason.COMMAND);target.setNoAi(true);target.setPos(at);h.getLevel().addFreshEntity(target);return target;
}
private static Zombie enemy(GameTestHelper h,Vec3 at) {
var target=EntityTypes.ZOMBIE.create(h.getLevel(),EntitySpawnReason.COMMAND);target.setNoAi(true);target.setPos(at);h.getLevel().addFreshEntity(target);return target;
}
@GameTest public void eggProfileSpawnAndAdultRule(GameTestHelper h) {
var egg=new ItemStack(Infected.BOOMER_EGG);var mob=Infected.BOOMER.create(h.getLevel(),EntitySpawnReason.COMMAND);mob.setBaby(true);
h.assertTrue(SpawnEggItem.getType(egg)==Infected.BOOMER&&SpawnEggItem.byId(Infected.BOOMER).orElseThrow().value()==Infected.BOOMER_EGG,"Native egg identifies the new Boomer in both directions");
h.assertTrue(!mob.isBaby()&&mob.getMaxHealth()==10&&mob.getAttributeValue(Attributes.MOVEMENT_SPEED)==.20,"Boomer stays adult with its slow role attributes");
var profile=FamiliarCatalog.bundled().forEgg(egg);
h.assertTrue(profile.id().equals("sanctuary:boomer")&&profile.health()==14&&profile.signature().effect().equals("bile"),"Bundled familiar supplies its own bile profile");
var attribute=net.minecraft.world.attribute.EnvironmentAttributes.NATURAL_MOB_SPAWNS;
for(var b:h.getLevel().getChunkSource().getGenerator().getBiomeSource().possibleBiomes()) {
var entries=b.value().getAttributes().applyModifier(attribute,attribute.defaultValue()).getMobsToSpawn(MobCategory.MONSTER).unwrap();
if(entries.stream().noneMatch(e->e.value().type()==EntityTypes.ZOMBIE))continue;
var found=entries.stream().filter(e->e.value().type()==Infected.BOOMER).toList();
h.assertTrue(found.size()==1&&found.getFirst().weight()==4&&found.getFirst().value().count().minInclusive()==1&&found.getFirst().value().count().maxInclusive()==1,"Boomer receives one rare singleton spawn entry");
}
h.succeed();
}
@GameTest(maxTicks=60) public void nativeWindupReleasesBileAndPaysCooldown(GameTestHelper h) {
var at=arena(h,1);var source=boomer(h,at);var target=cow(h,Vec3.atBottomCenterOf(at.offset(0,0,4)));source.setTarget(target);source.setNoAi(false);
h.runAfterDelay(10,()->h.assertTrue(source.windingUp()&&!target.hasEffect(MobEffects.GLOWING),"Visible preparation precedes coating"));
h.runAfterDelay(27,()->{
h.assertTrue(target.hasEffect(MobEffects.GLOWING)&&target.hasEffect(MobEffects.WEAKNESS)&&target.getHealth()==10,"The real AI vomits bile without direct acid damage");
h.assertTrue(source.bileCooldown()>0&&!source.windingUp(),"The completed cone enters cooldown");source.discard();target.discard();h.succeed();
});
}
@GameTest(maxTicks=60) public void sidestepEvadesTheLockedCone(GameTestHelper h) {
var at=arena(h,2);var source=boomer(h,at);var target=cow(h,Vec3.atBottomCenterOf(at.offset(0,0,4)));source.setTarget(target);source.setNoAi(false);
h.runAfterDelay(10,()->target.setPos(Vec3.atBottomCenterOf(at.offset(4,0,3))));
h.runAfterDelay(27,()->{h.assertTrue(!target.hasEffect(MobEffects.GLOWING)&&source.bileCooldown()>0,"A side step avoids the original cone direction");source.discard();target.discard();h.succeed();});
}
@GameTest(maxTicks=45) public void interruptedWindupReloadKeepsOnlyCooldown(GameTestHelper h) {
var at=arena(h,3);var source=boomer(h,at);var target=cow(h,Vec3.atBottomCenterOf(at.offset(0,0,4)));source.setTarget(target);source.setNoAi(false);
h.runAfterDelay(10,()->{
var output=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());source.save(output);
var tag=output.buildResult();tag.putBoolean("IsBaby",true);
var loaded=EntityType.loadEntityRecursive(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),tag),h.getLevel(),EntitySpawnReason.LOAD,e->e);
h.assertTrue(loaded instanceof BoomerEntity b&&!b.isBaby()&&!b.windingUp()&&b.bileCooldown()==120,"A saved preparation reloads as an adult with cooldown, without a replayed cone");
for(int x=-3;x<=3;x++)for(int y=0;y<4;y++)h.getLevel().setBlockAndUpdate(at.offset(x,y,2),Blocks.STONE.defaultBlockState());
});
h.runAfterDelay(30,()->{h.assertTrue(!source.windingUp()&&!target.hasEffect(MobEffects.GLOWING),"A wall interrupts preparation before release");source.discard();target.discard();h.succeed();});
}
@GameTest public void lethalNativeDamageSplashesExactlyOnceWithoutBreakingTerrain(GameTestHelper h) {
var at=arena(h,4);var source=boomer(h,at);var target=cow(h,Vec3.atBottomCenterOf(at.offset(0,0,2)));
source.hurtServer(h.getLevel(),source.damageSources().generic(),100);
h.assertTrue(!source.isAlive()&&target.getHealth()==8&&target.hasEffect(MobEffects.GLOWING)&&target.hasEffect(MobEffects.WEAKNESS),"Native lethal damage produces one two-damage bile splash");
target.setInvulnerableTime(0);source.die(source.damageSources().generic());
h.assertTrue(target.getHealth()==8,"Repeated native death notification cannot splash twice");
h.assertTrue(h.getLevel().getBlockState(at.below()).is(Blocks.STONE)&&h.getLevel().getBlockState(at).isAir(),"Death never explodes terrain or lights fire");
source.discard();target.discard();h.succeed();
}
@GameTest public void administrativeRemovalDoesNotBurst(GameTestHelper h) {
var at=arena(h,5);var source=boomer(h,at);var target=cow(h,Vec3.atBottomCenterOf(at.offset(0,0,2)));source.discard();
h.assertTrue(target.getHealth()==10&&!target.hasEffect(MobEffects.GLOWING),"Discarding or unloading a living Boomer is not a lethal explosion");target.discard();h.succeed();
}
@GameTest public void wallsHeightBackAndInfectedFamilyBlockCoating(GameTestHelper h) {
var at=arena(h,6);var source=boomer(h,at);var front=cow(h,Vec3.atBottomCenterOf(at.offset(0,0,3)));
var back=cow(h,Vec3.atBottomCenterOf(at.offset(0,0,-3)));var hidden=cow(h,Vec3.atBottomCenterOf(at.offset(2,0,3)));
var upstairs=cow(h,Vec3.atBottomCenterOf(at.offset(0,2,2)));var family=Infected.CHARGER.create(h.getLevel(),EntitySpawnReason.COMMAND);
family.setNoAi(true);family.setPos(Vec3.atBottomCenterOf(at.offset(0,0,2)));h.getLevel().addFreshEntity(family);
for(int z=1;z<=4;z++)for(int y=0;y<4;y++)h.getLevel().setBlockAndUpdate(at.offset(1,y,z),Blocks.STONE.defaultBlockState());
h.assertTrue(BoomerBile.wild(source,new Vec3(0,0,1),false)==1&&front.hasEffect(MobEffects.GLOWING),"Only the visible creature inside the forward cone is coated");
h.assertTrue(!back.hasEffect(MobEffects.GLOWING)&&!hidden.hasEffect(MobEffects.GLOWING)&&!upstairs.hasEffect(MobEffects.GLOWING)&&!family.hasEffect(MobEffects.GLOWING),"Bile cannot cross walls/floors, reach behind the cone or coat infected relatives");
for(var entity:java.util.List.of(source,front,back,hidden,upstairs,family))entity.discard();h.succeed();
}
@GameTest public void attractionIsBoundedToSixAlreadyPresentZombies(GameTestHelper h) {
var at=arena(h,7);var source=boomer(h,at);var victim=cow(h,Vec3.atBottomCenterOf(at.offset(0,0,4)));var zombies=new java.util.ArrayList<Zombie>();
for(int i=0;i<8;i++) { var zombie=new StillZombie(h.getLevel());zombie.setPos(Vec3.atBottomCenterOf(at.offset(i%3-1,0,7+i/3)));h.getLevel().addFreshEntity(zombie);zombies.add(zombie); }
int before=h.getLevel().getEntitiesOfClass(Zombie.class,source.getBoundingBox().inflate(13)).size();BoomerBile.wild(source,new Vec3(0,0,1),false);
h.assertTrue(zombies.stream().filter(z->z.getTarget()==victim).count()==6,"Bile recruits at most six existing zombies");
h.assertTrue(h.getLevel().getEntitiesOfClass(Zombie.class,source.getBoundingBox().inflate(13)).size()==before,"The horde effect never spawns extra entities");
zombies.forEach(Entity::discard);source.discard();victim.discard();h.succeed();
}
@GameTest(maxTicks=160) public void recruitedZombieReallyPursuesAndThenRestoresPreviousTarget(GameTestHelper h) {
var at=arena(h,8);var source=boomer(h,at);var victim=cow(h,Vec3.atBottomCenterOf(at.offset(0,0,4)));victim.getAttribute(Attributes.MAX_HEALTH).setBaseValue(100);victim.setHealth(100);
var previous=cow(h,Vec3.atBottomCenterOf(at.offset(0,0,14)));var zombie=EntityTypes.ZOMBIE.create(h.getLevel(),EntitySpawnReason.COMMAND);
zombie.setPos(Vec3.atBottomCenterOf(at.offset(0,0,10)));zombie.setOnGround(true);h.getLevel().addFreshEntity(zombie);zombie.setTarget(previous);
BoomerBile.wild(source,new Vec3(0,0,1),false);
h.assertTrue(zombie.getTarget()==victim,"Coating temporarily diverts the native zombie");
h.runAfterDelay(70,()->h.assertTrue(zombie.getZ()<at.getZ()+9&&victim.getHealth()<100,"The real zombie navigates toward and attacks the marked creature"));
h.runAfterDelay(140,()->{
h.assertTrue(zombie.getTarget()==previous&&!victim.hasEffect(MobEffects.GLOWING),"Diversion expires and restores the original valid target");
source.discard();victim.discard();previous.discard();zombie.discard();h.succeed();
});
}
@GameTest public void familiarGDealsBileDamageAndProtectsOwnerAndPassiveAnimals(GameTestHelper h) {
var p=pet(h,9);var pet=FamiliarService.active(p);var front=enemy(h,pet.position().add(0,0,2));var back=enemy(h,pet.position().add(0,0,-2));var animal=cow(h,pet.position().add(1,0,0));
float health=p.getHealth();FamiliarBattle.engage(p,front,false);
h.assertTrue(FamiliarBattle.activate(p).equals("activated"),"Shared manual entry point accepts the Boomer's signature");
var cast=FamiliarBattle.state(p).cast;for(int i=0;i<20;i++)FamiliarTechniques.advance(pet);
h.assertTrue(front.getHealth()<20&&back.getHealth()<20&&front.hasEffect(MobEffects.WEAKNESS)&&front.hasEffect(MobEffects.GLOWING)&&cast.hit.size()==2,"G splashes enemies around the familiar using shared damage and effects");
h.assertTrue(pet.isAlive()&&pet.getHealth()==14&&p.getHealth()==health&&animal.getHealth()==10&&!animal.hasEffect(MobEffects.GLOWING),"The familiar never sacrifices itself or coats its owner/passive animals");
h.assertTrue(FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now()&&CompanionService.record(p).readyAt()==FamiliarBattle.bond(p).readyAt(),"Released G commits the durable shared cooldown");
front.discard();back.discard();animal.discard();cleanup(p);h.succeed();
}
@GameTest public void familiarConeHasNoBackHitAndNeverRedirectsHostiles(GameTestHelper h) {
var p=pet(h,10);var pet=FamiliarService.active(p);var front=enemy(h,pet.position().add(0,0,2));var back=enemy(h,pet.position().add(0,0,-2));
var cast=new FamiliarTechniques.Cast(pet,FamiliarBattle.profile(p).initiative(),true,front,front.position());FamiliarActions.execute(cast);
h.assertTrue(front.getHealth()<20&&front.hasEffect(MobEffects.WEAKNESS)&&back.getHealth()==20&&!back.hasEffect(MobEffects.GLOWING),"Automatic bile affects only the forward cone");
h.assertTrue(front.getTarget()==null&&back.getTarget()==null,"Familiar bile does not take control of monsters against third parties");front.discard();back.discard();cleanup(p);h.succeed();
}
@GameTest public void everyMiniatureSizeRetainsAdultGeometryAndBond(GameTestHelper h) {
var p=pet(h,11);var id=FamiliarBattle.bond(p).id();
for(int value:new int[]{120,500,1000,1500,3000,6000}) {
var egg=FamiliarBattle.egg(p).copy();var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",value);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.tickCount+=10;FamiliarService.tick(p);var pet=FamiliarService.active(p);
h.assertTrue(pet!=null&&!pet.babyAppearance()&&FamiliarSize.value(pet.egg())==value&&Math.abs(pet.visualHeight()-.85*value/1000)<.001&&FamiliarBattle.bond(p).id().equals(id),"Every supported size stays an adult miniature with the same identity: "+value);
}
cleanup(p);h.succeed();
}
@GameTest public void remoteProjectionDoesNotLoadChunks(GameTestHelper h) {
var remote=h.absolutePos(new BlockPos(10000,200,10000));var source=Infected.BOOMER.create(h.getLevel(),EntitySpawnReason.COMMAND);source.setPos(Vec3.atCenterOf(remote));
h.assertTrue(!h.getLevel().hasChunkAt(remote)&&BoomerBile.wild(source,new Vec3(0,0,1),false)==0&&!h.getLevel().hasChunkAt(remote),"A remote projection neither affects unloaded mobs nor requests their chunks");h.succeed();
}
@GameTest public void savingOnTheReleaseTickAlreadyPreservesCooldown(GameTestHelper h) {
var at=arena(h,0);var source=boomer(h,at);var target=cow(h,Vec3.atBottomCenterOf(at.offset(0,0,4)));source.setTarget(target);source.setNoAi(false);
for(int i=0;i<40&&!target.hasEffect(MobEffects.GLOWING);i++)h.getLevel().tickNonPassenger(source);
h.assertTrue(target.hasEffect(MobEffects.GLOWING)&&!source.windingUp()&&source.bileCooldown()==120,"Cooldown is committed on release, before the goal's next stop callback");
var output=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());source.save(output);
var loaded=EntityType.loadEntityRecursive(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),output.buildResult()),h.getLevel(),EntitySpawnReason.LOAD,e->e);
h.assertTrue(loaded instanceof BoomerEntity b&&b.bileCooldown()==120&&!b.windingUp(),"An immediate save/reload cannot reset the released vomit's cooldown");
source.discard();target.discard();h.succeed();
}
private static ServerPlayer pet(GameTestHelper h,int slot) {
var p=Companion019GameTests.player(h,CompanionType.ZOMBIE);p.setPos(Vec3.atBottomCenterOf(arena(h,slot)));var egg=new ItemStack(Infected.BOOMER_EGG);
var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",1000);net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.tickCount=200;FamiliarService.tick(p);var pet=FamiliarService.active(p);pet.setPos(p.position());pet.setOnGround(true);pet.setNoAi(true);return p;
}
private static void cleanup(ServerPlayer p) { AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);CompanionService.clear(p); }
}
@@ -0,0 +1,188 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.infected.*;
import fr.koka.sanctuary.inventory.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.EntityTypeTags;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.*;
import net.minecraft.world.phys.Vec3;
/** Real native movement, contact, wall collision and the familiar's straight-charge path. */
public final class Charger282GameTests {
private static final class StillCow extends net.minecraft.world.entity.animal.cow.Cow {
StillCow(net.minecraft.world.level.Level level){super(EntityTypes.COW,level);}
@Override protected void registerGoals() { }
}
private record Wild(ChargerEntity mob,net.minecraft.world.entity.animal.cow.Cow target,BlockPos origin) {
void close(){mob.discard();target.discard();}
}
private static BlockPos arena(GameTestHelper h,int slot) {
var at=h.absolutePos(new BlockPos(0,70+slot*8,0));
// Keep the four-block sidestep on the arena floor instead of testing fall damage.
for(int x=-5;x<=5;x++)for(int z=-2;z<=34;z++) {
h.getLevel().setBlockAndUpdate(at.offset(x,-1,z),Blocks.STONE.defaultBlockState());
h.getLevel().setBlockAndUpdate(at.offset(x,4,z),Blocks.STONE.defaultBlockState());
}
return at;
}
private static Wild wild(GameTestHelper h,int slot) {
var at=arena(h,slot);var mob=Infected.CHARGER.create(h.getLevel(),EntitySpawnReason.COMMAND);
mob.setPos(Vec3.atBottomCenterOf(at));mob.setOnGround(true);
var target=new StillCow(h.getLevel());
target.getAttribute(Attributes.MAX_HEALTH).setBaseValue(100);target.setHealth(100);
target.setPos(Vec3.atBottomCenterOf(at.offset(0,0,7)));
h.getLevel().addFreshEntity(target);h.getLevel().addFreshEntity(mob);mob.setTarget(target);
return new Wild(mob,target,at);
}
@GameTest public void eggProfileTagsAndAdultRuleAreRegistered(GameTestHelper h) {
var egg=new ItemStack(Infected.CHARGER_EGG);var mob=Infected.CHARGER.create(h.getLevel(),EntitySpawnReason.MOB_SUMMONED);
mob.setBaby(true);
h.assertTrue(SpawnEggItem.getType(egg)==Infected.CHARGER&&SpawnEggItem.byId(Infected.CHARGER).orElseThrow().value()==Infected.CHARGER_EGG,"Native charger egg mapping is bidirectional");
h.assertTrue(!mob.isBaby()&&mob.getMaxHealth()==10&&mob.getAttributeValue(Attributes.MOVEMENT_SPEED)==.27,"Charger is adult-only without the baby speed bonus");
h.assertTrue(Infected.CHARGER.builtInRegistryHolder().is(EntityTypeTags.UNDEAD)&&Infected.CHARGER.builtInRegistryHolder().is(EntityTypeTags.BURN_IN_DAYLIGHT),"Charger remains an undead daylight-sensitive zombie");
h.assertTrue(FamiliarCatalog.bundled().forEgg(egg).id().equals("sanctuary:charger"),"Charger has a dedicated bundled profile");
var attribute=net.minecraft.world.attribute.EnvironmentAttributes.NATURAL_MOB_SPAWNS;
for(var b:h.getLevel().getChunkSource().getGenerator().getBiomeSource().possibleBiomes()) {
var entries=b.value().getAttributes().applyModifier(attribute,attribute.defaultValue()).getMobsToSpawn(MobCategory.MONSTER).unwrap();
if(entries.stream().noneMatch(e->e.value().type()==EntityTypes.ZOMBIE))continue;
var found=entries.stream().filter(e->e.value().type()==Infected.CHARGER).toList();
h.assertTrue(found.size()==1&&found.getFirst().weight()==4&&found.getFirst().value().count().minInclusive()==1&&found.getFirst().value().count().maxInclusive()==1,"Zombie habitats receive one rare singleton charger entry");
}
h.succeed();
}
@GameTest(maxTicks=150) public void windupImpactRecoveryAndCooldownUseRealPhysics(GameTestHelper h) {
var w=wild(h,1);var start=w.mob.position();
h.runAfterDelay(10,()->h.assertTrue(w.mob.phase()==ChargerEntity.Phase.WINDUP&&w.mob.position().subtract(start).horizontalDistance()<.1,"The charger holds its position while visibly preparing"));
h.runAfterDelay(45,()->{
h.assertTrue(w.target.getHealth()==96&&w.target.getZ()>w.origin.getZ()+7.5,"One real charge impact deals four damage and pushes the target: health="+w.target.getHealth()+" z="+w.target.getZ()+" origin="+w.origin+" phase="+w.mob.phase());
h.assertTrue(w.mob.phase()==ChargerEntity.Phase.RECOVERING,"Impact enters recovery");
h.assertTrue(!w.mob.doHurtTarget(h.getLevel(),w.target),"Native melee cannot double-hit during recovery");
});
h.runAfterDelay(110,()->{h.assertTrue(w.mob.phase()==ChargerEntity.Phase.IDLE&&w.mob.chargeCooldown()>0,"Recovery ends before another charge is permitted");w.close();h.succeed();});
}
@GameTest(maxTicks=120) public void movingAsideDodgesInsteadOfBeingHomedOn(GameTestHelper h) {
var w=wild(h,2);var started=new java.util.concurrent.atomic.AtomicBoolean();double x=w.mob.getX();
h.onEachTick(()->{if(w.mob.phase()==ChargerEntity.Phase.CHARGING&&started.compareAndSet(false,true)){
w.target.setPos(w.target.position().add(4,0,0));
h.runAfterDelay(30,()->{
h.assertTrue(Math.abs(w.mob.getX()-x)<.01&&w.mob.getZ()>w.origin.getZ()+4,"A released charge keeps its original straight direction");
h.assertTrue(w.target.getHealth()==100&&w.mob.phase()==ChargerEntity.Phase.RECOVERING,"A sidestep avoids contact and a miss still requires recovery: health="+w.target.getHealth()+" target="+w.target.position()+" phase="+w.mob.phase());
w.close();h.succeed();
});
}});
}
@GameTest(maxTicks=120) public void newWallStopsTheBodyWithoutDamageThroughIt(GameTestHelper h) {
var w=wild(h,3);var started=new java.util.concurrent.atomic.AtomicBoolean();
h.onEachTick(()->{if(w.mob.phase()==ChargerEntity.Phase.CHARGING&&started.compareAndSet(false,true)){
for(int x=-3;x<=3;x++)for(int y=0;y<4;y++)h.getLevel().setBlockAndUpdate(w.origin.offset(x,y,4),Blocks.STONE.defaultBlockState());
h.runAfterDelay(25,()->{
h.assertTrue(w.mob.getZ()<=w.origin.getZ()+3.71&&w.target.getHealth()==100,"Native collision stops the actual body and swept contact cannot cross the wall");
h.assertTrue(w.mob.phase()==ChargerEntity.Phase.RECOVERING&&h.getLevel().getBlockState(w.origin.offset(0,1,4)).is(Blocks.STONE),"Wall collision causes recovery without breaking blocks");
w.close();h.succeed();
});
}});
}
@GameTest(maxTicks=100) public void reloadingAnInterruptedChargePreservesOnlyCooldown(GameTestHelper h) {
var w=wild(h,4);w.mob.setHealth(7);
h.runAfterDelay(10,()->{
w.mob.setDeltaMovement(1.18,0,0);
var output=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());w.mob.save(output);
var saved=output.buildResult();saved.putBoolean("IsBaby",true);
var loaded=EntityType.loadEntityRecursive(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),saved),h.getLevel(),EntitySpawnReason.LOAD,e->e);
h.assertTrue(loaded instanceof ChargerEntity c&&c.getUUID().equals(w.mob.getUUID())&&c.getHealth()==7&&!c.isBaby()
&&c.phase()==ChargerEntity.Phase.IDLE&&c.chargeCooldown()==100&&c.chargeDirection().equals(Vec3.ZERO)&&c.getDeltaMovement().horizontalDistance()==0,"Reload retains UUID and health but cannot resume or reset a partly prepared rush");
w.close();h.succeed();
});
}
@GameTest public void familiarActuallyRushesAndCommitsSharedCooldown(GameTestHelper h) {
var p=pet(h,6);var pet=FamiliarService.active(p);var enemy=enemy(p);var profile=FamiliarBattle.profile(p);
h.assertTrue(!pet.babyAppearance()&&profile.id().equals("sanctuary:charger")&&pet.getMaxHealth()==16,"Equipped charger is a miniature adult using its own profile");
FamiliarBattle.engage(p,enemy,false);h.assertTrue(FamiliarBattle.activate(p).equals("activated"),"The normal manual technique entry point starts the rush");
var cast=FamiliarBattle.state(p).cast;run(p,80);
h.assertTrue(enemy.getHealth()<20&&enemy.getHealth()>=16&&cast.hit.size()==1,"The real moved body hits once through shared damage and armor rules");
h.assertTrue(enemy.getDeltaMovement().z>0&&FamiliarBattle.state(p).cast==null,"Impact pushes the enemy and recovery completes");
h.assertTrue(FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now()&&CompanionService.record(p).readyAt()==FamiliarBattle.bond(p).readyAt(),"The durable shared cooldown is committed at release");
enemy.discard();cleanup(p);h.succeed();
}
@GameTest public void familiarMissKeepsStraightTrajectoryAndPaysCooldown(GameTestHelper h) {
var p=pet(h,7);var pet=FamiliarService.active(p);var enemy=enemy(p);double x=pet.getX();
var cast=new FamiliarTechniques.Cast(pet,FamiliarBattle.profile(p).signature(),false,enemy,enemy.position());FamiliarBattle.state(p).cast=cast;
run(p,22);enemy.setPos(enemy.position().add(4,0,0));run(p,70);
h.assertTrue(Math.abs(pet.getX()-x)<.001&&pet.getZ()>p.getZ()+4&&enemy.getHealth()==20,"The familiar does not home in on a sidestepping target");
h.assertTrue(FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now()&&FamiliarBattle.state(p).cast==null,"A missed released charge still consumes cooldown and recovery");
enemy.discard();cleanup(p);h.succeed();
}
@GameTest public void familiarStopsBeforeWallsAndCannotChargeItsOwner(GameTestHelper h) {
var p=pet(h,8);var pet=FamiliarService.active(p);var enemy=enemy(p);double wall=pet.getZ()+2;
var cast=new FamiliarTechniques.Cast(pet,FamiliarBattle.profile(p).signature(),false,enemy,enemy.position());FamiliarBattle.state(p).cast=cast;run(p,22);
var pos=BlockPos.containing(pet.getX(),pet.getY(),wall);
for(int x=-2;x<=2;x++)for(int y=0;y<3;y++)p.level().setBlockAndUpdate(pos.offset(x,y,0),Blocks.STONE.defaultBlockState());
run(p,70);h.assertTrue(enemy.getHealth()==20&&pet.getZ()<wall&&FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now(),"An obstacle stops the familiar without a through-wall hit or a cooldown refund");
float health=p.getHealth();var denied=new FamiliarTechniques.Cast(pet,FamiliarBattle.profile(p).signature(),false,p,p.position());FamiliarBattle.state(p).cast=denied;run(p,30);
h.assertTrue(p.getHealth()==health&&denied.hit.isEmpty(),"Even a server-created cast cannot bypass the owner protection rules");
enemy.discard();cleanup(p);h.succeed();
}
@GameTest public void ownerTeleportCannotBecomeASweptRemoteHit(GameTestHelper h) {
var p=pet(h,9);var pet=FamiliarService.active(p);var enemy=enemy(p);
var cast=new FamiliarTechniques.Cast(pet,FamiliarBattle.profile(p).signature(),false,enemy,enemy.position());FamiliarBattle.state(p).cast=cast;
run(p,22);pet.teleportTo(pet.getX(),pet.getY(),pet.getZ()+10);run(p,40);
h.assertTrue(enemy.getHealth()==20&&cast.hit.isEmpty()&&FamiliarBattle.state(p).cast==null,"A discontinuous relocation across the target cancels contact rather than hitting from a distance");
h.assertTrue(FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now(),"An interrupted released charge retains its cooldown");
enemy.discard();cleanup(p);h.succeed();
}
@GameTest public void mountedWindupBlocksSteeringAndThenRestoresIt(GameTestHelper h) {
var p=pet(h,10);var egg=FamiliarBattle.egg(p).copy();var size=new net.minecraft.nbt.CompoundTag();
size.putInt("schema",1);size.putInt("per_mille",2500);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
var floor=p.blockPosition();
for(int x=-3;x<=3;x++)for(int z=-2;z<=34;z++) {
p.level().setBlockAndUpdate(floor.offset(x,4,z),Blocks.AIR.defaultBlockState());
p.level().setBlockAndUpdate(floor.offset(x,8,z),Blocks.STONE.defaultBlockState());
}
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.tickCount+=10;FamiliarService.tick(p);var pet=FamiliarService.active(p);pet.setPos(Vec3.atBottomCenterOf(floor));pet.setOnGround(true);
h.assertTrue(pet.mount(p),"The existing colossal familiar can be mounted");
var enemy=enemy(p);FamiliarBattle.state(p).cast=new FamiliarTechniques.Cast(pet,FamiliarBattle.profile(p).signature(),false,enemy,enemy.position());
p.setYRot(90);p.setLastClientInput(new net.minecraft.world.entity.player.Input(true,false,false,true,true,false,false));
pet.setDeltaMovement(Vec3.ZERO);var start=pet.position();pet.travel(Vec3.ZERO);
h.assertTrue(pet.position().subtract(start).horizontalDistance()<.001&&pet.getDeltaMovement().horizontalDistance()<.001,"A rider cannot steer or jump out of the committed charge windup");
FamiliarBattle.cancel(p);pet.setOnGround(true);pet.travel(Vec3.ZERO);
h.assertTrue(pet.getDeltaMovement().horizontalDistance()>0,"Normal rider movement resumes when the cast ends");
p.stopRiding();enemy.discard();cleanup(p);h.succeed();
}
@GameTest public void unloadedCorridorIsRejectedWithoutLoadingIt(GameTestHelper h) {
var at=h.absolutePos(new BlockPos(10000,90,10000));
h.assertTrue(!h.getLevel().hasChunkAt(at),"The remote corridor starts unloaded");
h.assertTrue(!ChargeContact.loaded(h.getLevel(),new net.minecraft.world.phys.AABB(at).inflate(.3)),"A full body corridor must already be loaded");
h.assertTrue(!h.getLevel().hasChunkAt(at),"The charge probe does not request or generate the remote chunk");h.succeed();
}
private static ServerPlayer pet(GameTestHelper h,int slot) {
var p=Companion019GameTests.player(h,CompanionType.ZOMBIE);var at=arena(h,slot);
p.setPos(Vec3.atBottomCenterOf(at));var egg=new ItemStack(Infected.CHARGER_EGG);
var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",1000);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.tickCount=200;FamiliarService.tick(p);
var pet=FamiliarService.active(p);pet.setPos(p.position());pet.setOnGround(true);return p;
}
private static net.minecraft.world.entity.monster.zombie.Zombie enemy(ServerPlayer p) {
var enemy=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);enemy.setNoAi(true);enemy.setPos(p.position().add(0,0,5));p.level().addFreshEntity(enemy);return enemy;
}
private static void run(ServerPlayer p,int ticks) {
var pet=FamiliarService.active(p);
for(int i=0;i<ticks&&FamiliarBattle.state(p).cast!=null;i++) {
pet.setOnGround(true);FamiliarTechniques.advance(pet);pet.move(MoverType.SELF,pet.getDeltaMovement().multiply(1,0,1));pet.setDeltaMovement(Vec3.ZERO);
}
}
private static void cleanup(ServerPlayer p) { AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);CompanionService.clear(p); }
}
@@ -24,7 +24,9 @@ public final class Companion019GameTests {
@GameTest public void catalogMatchesEveryNativeEgg(GameTestHelper h){
var found=new HashSet<CompanionType>();
for(var item:BuiltInRegistries.ITEM)if(item instanceof SpawnEggItem){
var type=CompanionType.find(BuiltInRegistries.ENTITY_TYPE.getKey(SpawnEggItem.getType(new ItemStack(item))).toString());
var id=BuiltInRegistries.ENTITY_TYPE.getKey(SpawnEggItem.getType(new ItemStack(item)));
if(!id.getNamespace().equals("minecraft"))continue;
var type=CompanionType.find(id.toString());
h.assertTrue(type!=null,"Each native egg has a configured power definition");found.add(type);
}
h.assertTrue(found.size()==88&&found.size()==CompanionType.values().length,"Exactly 88 native familiar species");h.succeed();
@@ -0,0 +1,154 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.infected.*;
import fr.koka.sanctuary.inventory.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.animal.cow.Cow;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.*;
import net.minecraft.world.phys.Vec3;
/** Actual native airborne physics and swept body hits, including the familiar's G path. */
public final class Hunter286GameTests {
private static final java.util.concurrent.atomic.AtomicReference<BlockPos> DENIED=new java.util.concurrent.atomic.AtomicReference<>();
static { CompanionWorld.ALLOW.register((player,pos,operation)->!pos.equals(DENIED.get())); }
private record Wild(HunterEntity mob,Cow victim,BlockPos at) { void close(){mob.discard();victim.discard();} }
private static BlockPos arena(GameTestHelper h,int slot) {
var at=h.absolutePos(new BlockPos(0,74+slot*10,0));
for(int x=-5;x<=5;x++)for(int z=-3;z<=18;z++) {
h.getLevel().setBlockAndUpdate(at.offset(x,-1,z),Blocks.STONE.defaultBlockState());
h.getLevel().setBlockAndUpdate(at.offset(x,7,z),Blocks.STONE.defaultBlockState());
}
return at;
}
private static Wild wild(GameTestHelper h,int slot) {
var at=arena(h,slot);var mob=Infected.HUNTER.create(h.getLevel(),EntitySpawnReason.COMMAND);mob.setPos(Vec3.atBottomCenterOf(at));mob.setOnGround(true);
var target=EntityTypes.COW.create(h.getLevel(),EntitySpawnReason.COMMAND);target.setNoAi(true);target.getAttribute(Attributes.MAX_HEALTH).setBaseValue(100);target.setHealth(100);target.setPos(Vec3.atBottomCenterOf(at.offset(0,0,8)));
h.getLevel().addFreshEntity(target);h.getLevel().addFreshEntity(mob);mob.setTarget(target);return new Wild(mob,target,at);
}
private static void tick(GameTestHelper h,Entity entity) { h.getLevel().tickNonPassenger(entity); }
private static void launch(GameTestHelper h,Wild w) { for(int i=0;i<32&&w.mob.phase()!=HunterEntity.Phase.LEAPING;i++)tick(h,w.mob);h.assertTrue(w.mob.phase()==HunterEntity.Phase.LEAPING,"Native Hunter goal reaches takeoff"); }
@GameTest public void eggProfileSpawnAndAdultRule(GameTestHelper h) {
var egg=new ItemStack(Infected.HUNTER_EGG);var mob=Infected.HUNTER.create(h.getLevel(),EntitySpawnReason.COMMAND);mob.setBaby(true);
h.assertTrue(SpawnEggItem.getType(egg)==Infected.HUNTER&&SpawnEggItem.byId(Infected.HUNTER).orElseThrow().value()==Infected.HUNTER_EGG,"Native egg resolves Hunter in both directions");
h.assertTrue(!mob.isBaby()&&mob.getMaxHealth()==10&&mob.getAttributeValue(Attributes.MOVEMENT_SPEED)==.30,"Hunter rejects babies and has its own fast attributes");
var profile=FamiliarCatalog.bundled().forEgg(egg);h.assertTrue(profile.id().equals("sanctuary:hunter")&&profile.health()==12&&FamiliarPounce.applies(profile.signature()),"Dedicated Hunter profile supplies the real pounce");
var attribute=net.minecraft.world.attribute.EnvironmentAttributes.NATURAL_MOB_SPAWNS;
for(var b:h.getLevel().getChunkSource().getGenerator().getBiomeSource().possibleBiomes()) {
var entries=b.value().getAttributes().applyModifier(attribute,attribute.defaultValue()).getMobsToSpawn(MobCategory.MONSTER).unwrap();
if(entries.stream().noneMatch(e->e.value().type()==EntityTypes.ZOMBIE))continue;
var found=entries.stream().filter(e->e.value().type()==Infected.HUNTER).toList();
h.assertTrue(found.size()==1&&found.getFirst().weight()==4&&found.getFirst().value().count().minInclusive()==1&&found.getFirst().value().count().maxInclusive()==1,"Zombie habitats receive one rare singleton Hunter entry");
}
h.succeed();
}
@GameTest public void nativeWindupAirborneContactAndRecoveryDealOneHit(GameTestHelper h) {
var w=wild(h,1);var start=w.mob.position();for(int i=0;i<8;i++)tick(h,w.mob);
h.assertTrue(w.mob.phase()==HunterEntity.Phase.WINDUP&&w.mob.position().subtract(start).length()<.01&&w.mob.getPose()==Pose.CROUCHING,"Hunter crouches and holds while preparing");
launch(h,w);double top=w.mob.getY();for(int i=0;i<30&&w.mob.phase()==HunterEntity.Phase.LEAPING;i++){top=Math.max(top,w.mob.getY());w.victim.setInvulnerableTime(0);tick(h,w.mob);}
h.assertTrue(top>start.y+1&&w.victim.getHealth()==96&&w.mob.phase()==HunterEntity.Phase.RECOVERING,"The native airborne body reaches contact and deals four damage: hp="+w.victim.getHealth()+" top="+top+" z="+w.mob.getZ()+" target="+w.victim.position());
for(int i=0;i<10;i++){w.victim.setInvulnerableTime(0);tick(h,w.mob);}
h.assertTrue(w.victim.getHealth()==96&&!w.mob.doHurtTarget(h.getLevel(),w.victim)&&w.mob.pounceCooldown()>0,"Recovery prevents both repeated contact damage and native melee double-hits");w.close();h.succeed();
}
@GameTest public void sidestepAvoidsTheAirborneBody(GameTestHelper h) {
var w=wild(h,2);launch(h,w);double x=w.mob.getX();w.victim.setPos(w.victim.position().add(4,0,0));
for(int i=0;i<35&&w.mob.phase()==HunterEntity.Phase.LEAPING;i++)tick(h,w.mob);
h.assertTrue(w.victim.getHealth()==100&&Math.abs(w.mob.getX()-x)<.01&&w.mob.getZ()>w.at.getZ()+5,"A released pounce keeps its original trajectory and misses a side step");
h.assertTrue(w.mob.phase()==HunterEntity.Phase.RECOVERING&&w.mob.pounceCooldown()>0,"A miss still lands in recovery and pays cooldown");w.close();h.succeed();
}
@GameTest public void wallStopsRealBodyWithoutDamageThroughIt(GameTestHelper h) {
var w=wild(h,3);launch(h,w);for(int x=-4;x<=4;x++)for(int y=0;y<6;y++)h.getLevel().setBlockAndUpdate(w.at.offset(x,y,3),Blocks.STONE.defaultBlockState());
for(int i=0;i<25&&w.mob.phase()==HunterEntity.Phase.LEAPING;i++)tick(h,w.mob);
h.assertTrue(w.victim.getHealth()==100&&w.mob.getZ()<=w.at.getZ()+2.701&&w.mob.phase()==HunterEntity.Phase.RECOVERING,"Native collision ends the pounce before the wall instead of hitting through it");
h.assertTrue(h.getLevel().getBlockState(w.at.offset(0,1,3)).is(Blocks.STONE),"Hunter never breaks the wall");w.close();h.succeed();
}
@GameTest public void lowCeilingStopsTheJumpAndGravityResumes(GameTestHelper h) {
var w=wild(h,4);for(int x=-2;x<=2;x++)for(int z=-1;z<=5;z++)h.getLevel().setBlockAndUpdate(w.at.offset(x,3,z),Blocks.STONE.defaultBlockState());launch(h,w);
for(int i=0;i<8&&w.mob.phase()==HunterEntity.Phase.LEAPING;i++)tick(h,w.mob);
h.assertTrue(w.mob.phase()==HunterEntity.Phase.RECOVERING&&w.victim.getHealth()==100&&w.mob.getY()<=w.at.getY()+1.051,"A ceiling collides with the actual head/body");
for(int i=0;i<15;i++)tick(h,w.mob);h.assertTrue(w.mob.onGround()&&!w.mob.isNoGravity(),"Hunter falls back to the floor without a hovering recovery");w.close();h.succeed();
}
@GameTest public void reloadDropsPreparationAndFlightButRetainsCooldown(GameTestHelper h) {
var w=wild(h,5);w.mob.setHealth(7);for(int i=0;i<8;i++)tick(h,w.mob);assertReload(h,w.mob);
launch(h,w);assertReload(h,w.mob);w.close();h.succeed();
}
private static void assertReload(GameTestHelper h,HunterEntity original) {
var out=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());original.save(out);var tag=out.buildResult();tag.putBoolean("IsBaby",true);
var loaded=EntityType.loadEntityRecursive(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),tag),h.getLevel(),EntitySpawnReason.LOAD,e->e);
h.assertTrue(loaded instanceof HunterEntity hunter&&!hunter.isBaby()&&hunter.getUUID().equals(original.getUUID())&&hunter.getHealth()==7&&hunter.phase()==HunterEntity.Phase.IDLE&&hunter.pounceCooldown()==120&&hunter.getDeltaMovement().equals(Vec3.ZERO)&&hunter.getPose()==Pose.STANDING,"Reload preserves identity/health/cooldown without replaying the jump or baby pose");
}
@GameTest public void teleportAcrossTargetCannotBecomeASweptPounceHit(GameTestHelper h) {
var w=wild(h,6);launch(h,w);w.mob.teleportTo(w.mob.getX(),w.mob.getY(),w.mob.getZ()+12);tick(h,w.mob);
h.assertTrue(w.victim.getHealth()==100&&w.mob.phase()==HunterEntity.Phase.RECOVERING&&w.mob.pounceCooldown()>0,"A discontinuous relocation cancels contact and retains spent cooldown");w.close();h.succeed();
}
@GameTest public void unloadedFlightCorridorIsRefusedWithoutGeneration(GameTestHelper h) {
var w=wild(h,7);launch(h,w);var remote=w.mob.blockPosition().offset(10000,0,0);h.assertTrue(!h.getLevel().hasChunkAt(remote),"Remote corridor starts unloaded");
w.mob.setDeltaMovement(10000,0,0);tick(h,w.mob);
h.assertTrue(w.mob.phase()==HunterEntity.Phase.RECOVERING&&!h.getLevel().hasChunkAt(remote)&&w.victim.getHealth()==100,"Full flight corridor guard rejects an unloaded jump without generating terrain");w.close();h.succeed();
}
@GameTest public void familiarGActuallyJumpsAndHitsOnlyOnPhysicalContact(GameTestHelper h) {
var p=pet(h,8);var pet=FamiliarService.active(p);var enemy=enemy(h,pet.position().add(0,0,7));FamiliarBattle.engage(p,enemy,false);
var result=FamiliarBattle.activate(p);h.assertTrue(result.equals("activated"),"Native G activation starts Hunter preparation: "+result+" active="+FamiliarBattle.active(p)+" lookup="+p.level().getEntity(enemy.getUUID())+" mayHit="+FamiliarBattle.mayHit(pet,enemy));var cast=FamiliarBattle.state(p).cast;double start=pet.getY(),top=start;
for(int i=0;i<12;i++)tick(h,pet);h.assertTrue(enemy.getHealth()==20&&pet.getY()<start+.01,"Preparation cannot apply remote damage");
for(int i=0;i<70&&FamiliarBattle.state(p).cast!=null;i++){top=Math.max(top,pet.getY());enemy.setInvulnerableTime(0);tick(h,pet);}
h.assertTrue(top>start+1&&enemy.getHealth()<20&&enemy.getHealth()>=16&&cast.hit.size()==1,"The actual miniature body jumps and applies one shared hit: top="+top+" hp="+enemy.getHealth()+" pos="+pet.position()+" enemy="+enemy.position());
h.assertTrue(FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now()&&CompanionService.record(p).readyAt()==FamiliarBattle.bond(p).readyAt()&&FamiliarBattle.state(p).cast==null,"Released G commits shared durable cooldown and finishes recovery");enemy.discard();cleanup(p);h.succeed();
}
@GameTest public void familiarCanBeDodgedAndStillPaysCooldown(GameTestHelper h) {
var p=pet(h,9);var pet=FamiliarService.active(p);var enemy=enemy(h,pet.position().add(0,0,7));FamiliarBattle.engage(p,enemy,false);FamiliarBattle.activate(p);
for(int i=0;i<30&&pet.getY()<p.getY()+.2;i++)tick(h,pet);double x=pet.getX();enemy.setPos(enemy.position().add(4,0,0));run(h,p,70);
h.assertTrue(enemy.getHealth()==20&&Math.abs(pet.getX()-x)<.01&&FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now()&&FamiliarBattle.state(p).cast==null,"The familiar's released ballistic trajectory never homes in on a sidestep");enemy.discard();cleanup(p);h.succeed();
}
@GameTest public void familiarWallAndCancelCannotProduceRemoteDamage(GameTestHelper h) {
var p=pet(h,10);var pet=FamiliarService.active(p);var enemy=enemy(h,pet.position().add(0,0,7));FamiliarBattle.engage(p,enemy,false);FamiliarBattle.activate(p);
for(int i=0;i<30&&pet.getY()<p.getY()+.2;i++)tick(h,pet);var wall=p.blockPosition().offset(0,0,3);
for(int x=-3;x<=3;x++)for(int y=0;y<6;y++)p.level().setBlockAndUpdate(wall.offset(x,y,0),Blocks.STONE.defaultBlockState());run(h,p,60);
h.assertTrue(enemy.getHealth()==20&&pet.getZ()<wall.getZ()&&FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now(),"A wall stops the moved familiar without a remote hit or cooldown refund");
FamiliarBattle.cancel(p);h.assertTrue(pet.getPose()==Pose.STANDING&&FamiliarBattle.state(p).cast==null,"Normal cancellation clears preparation and recovery pose");enemy.discard();cleanup(p);h.succeed();
}
@GameTest public void familiarTeleportDuringFlightCannotHitAcrossTheTarget(GameTestHelper h) {
var p=pet(h,11);var pet=FamiliarService.active(p);var enemy=enemy(h,pet.position().add(0,0,7));FamiliarBattle.engage(p,enemy,false);FamiliarBattle.activate(p);
for(int i=0;i<30&&pet.getY()<p.getY()+.2;i++)tick(h,pet);pet.teleportTo(pet.getX(),pet.getY(),pet.getZ()+10);run(h,p,40);
h.assertTrue(enemy.getHealth()==20&&FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now(),"Owner recall or teleport across an enemy cannot become body-sweep damage");enemy.discard();cleanup(p);h.succeed();
}
@GameTest public void ownerAndPassiveAnimalsCannotBePounceVictims(GameTestHelper h) {
var p=pet(h,12);var pet=FamiliarService.active(p);var cow=EntityTypes.COW.create(p.level(),EntitySpawnReason.COMMAND);cow.setNoAi(true);cow.setPos(pet.position().add(0,0,5));p.level().addFreshEntity(cow);
var cast=new FamiliarTechniques.Cast(pet,FamiliarBattle.profile(p).signature(),false,cow,cow.position());FamiliarBattle.state(p).cast=cast;run(h,p,40);
h.assertTrue(cow.getHealth()==10&&cast.hit.isEmpty(),"Even a server-created cast cannot bypass passive-animal protection");
float hp=p.getHealth();cast=new FamiliarTechniques.Cast(pet,FamiliarBattle.profile(p).signature(),false,p,p.position());FamiliarBattle.state(p).cast=cast;run(h,p,20);
h.assertTrue(p.getHealth()==hp&&cast.hit.isEmpty(),"The Hunter cannot pounce on its owner");cow.discard();cleanup(p);h.succeed();
}
@GameTest public void protectedTakeoffIsRefusedBeforeCooldown(GameTestHelper h) {
var p=pet(h,13);var pet=FamiliarService.active(p);var enemy=enemy(h,pet.position().add(0,0,7));FamiliarBattle.engage(p,enemy,false);FamiliarBattle.activate(p);var start=pet.position();
try { DENIED.set(BlockPos.containing(start.add(HunterJump.impulse(start,enemy.position()))));run(h,p,40);h.assertTrue(pet.position().subtract(start).horizontalDistance()<.01&&enemy.getHealth()==20&&FamiliarBattle.bond(p).readyAt()==0,"A forbidden first flight cell prevents takeoff before cooldown and damage"); }
finally { DENIED.set(null); }
enemy.discard();cleanup(p);h.succeed();
}
@GameTest public void everyMiniatureSizeRetainsAdultGeometryAndIdentity(GameTestHelper h) {
var p=pet(h,14);var id=FamiliarBattle.bond(p).id();
for(int value:new int[]{120,500,1000,1500,3000,6000}) {
var egg=FamiliarBattle.egg(p).copy();var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",value);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.tickCount+=10;FamiliarService.tick(p);
var pet=FamiliarService.active(p);h.assertTrue(pet!=null&&!pet.babyAppearance()&&Math.abs(pet.visualHeight()-.85*value/1000)<.001&&FamiliarBattle.bond(p).id().equals(id),"Every supported scale remains an adult miniature with the same bond: "+value);
}
cleanup(p);h.succeed();
}
private static ServerPlayer pet(GameTestHelper h,int slot) {
var p=Companion019GameTests.player(h,CompanionType.ZOMBIE);p.setPos(Vec3.atBottomCenterOf(arena(h,slot)));var egg=new ItemStack(Infected.HUNTER_EGG);var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",1000);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.tickCount=200;FamiliarService.tick(p);
var pet=FamiliarService.active(p);pet.setPos(p.position());pet.setOnGround(true);return p;
}
private static net.minecraft.world.entity.monster.zombie.Zombie enemy(GameTestHelper h,Vec3 at) { var target=EntityTypes.ZOMBIE.create(h.getLevel(),EntitySpawnReason.COMMAND);target.setNoAi(true);target.setPos(at);h.getLevel().addFreshEntity(target);return target; }
private static void run(GameTestHelper h,ServerPlayer p,int ticks) { for(int i=0;i<ticks&&FamiliarBattle.state(p).cast!=null;i++)tick(h,FamiliarService.active(p)); }
private static void cleanup(ServerPlayer p) { AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);CompanionService.clear(p); }
}
@@ -0,0 +1,171 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.infected.*;
import fr.koka.sanctuary.inventory.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.tags.EntityTypeTags;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.item.*;
import net.minecraft.world.item.context.UseOnContext;
import net.minecraft.world.level.biome.Biomes;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.*;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.Vec3;
/** Native egg, biome, entity persistence and familiar combat integration. */
public final class Infected280GameTests {
@GameTest public void infectedHasDistinctStatsAndNativeMelee(GameTestHelper h) {
var mob=Infected.TYPE.create(h.getLevel(),EntitySpawnReason.COMMAND);
var zombie=EntityTypes.ZOMBIE.create(h.getLevel(),EntitySpawnReason.COMMAND);
h.assertTrue(mob.getMaxHealth()==10&&mob.getHealth()==10,"Infected starts with ten health");
h.assertTrue(mob.getAttributeBaseValue(Attributes.MOVEMENT_SPEED)>zombie.getAttributeBaseValue(Attributes.MOVEMENT_SPEED),"Infected is faster than a vanilla zombie");
var victim=EntityTypes.COW.create(h.getLevel(),EntitySpawnReason.COMMAND);
float health=victim.getHealth();
h.assertTrue(mob.doHurtTarget(h.getLevel(),victim)&&victim.getHealth()<health,"Inherited zombie melee causes actual damage");
float adult=mob.getBbHeight();mob.setBaby(true);
h.assertTrue(!mob.isBaby()&&mob.getBbHeight()==adult&&mob.getAttributeValue(Attributes.MOVEMENT_SPEED)==.25,"Infected rejects baby dimensions and the native baby speed bonus");
zombie.setBaby(true);h.assertTrue(zombie.isBaby()&&zombie.getBbHeight()<adult,"Vanilla zombie babies remain available");
h.assertTrue(mob.getType().builtInRegistryHolder().is(EntityTypeTags.UNDEAD)&&mob.getType().builtInRegistryHolder().is(EntityTypeTags.BURN_IN_DAYLIGHT),"Undead and daylight tags resolve on the registered type");
h.succeed();
}
@GameTest public void actualEggUseSpawnsOnlyInfected(GameTestHelper h) {
var p=Companion019GameTests.player(h,CompanionType.ZOMBIE);
var at=p.blockPosition().offset(3,-1,0);
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Infected.SPAWN_EGG,2));
var egg=p.getMainHandItem();
h.assertTrue(SpawnEggItem.getType(egg)==Infected.TYPE&&SpawnEggItem.byId(Infected.TYPE).orElseThrow().value()==Infected.SPAWN_EGG,"Native bidirectional egg mapping resolves the custom type");
Infected.SPAWN_EGG.useOn(new UseOnContext(p,InteractionHand.MAIN_HAND,
new BlockHitResult(Vec3.atCenterOf(at).add(0,.5,0),Direction.UP,at,false)));
var found=h.getLevel().getEntitiesOfClass(InfectedEntity.class,new net.minecraft.world.phys.AABB(at).inflate(2));
h.assertTrue(found.size()==1&&egg.getCount()==1,"A real survival egg use spawns one infected and consumes one egg");
found.forEach(Entity::discard);cleanup(p);h.succeed();
}
@GameTest public void nativeSaveRetainsInfectedIdentityAndHealth(GameTestHelper h) {
var mob=Infected.TYPE.create(h.getLevel(),EntitySpawnReason.COMMAND);mob.setHealth(7);mob.setBaby(true);
var output=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());
h.assertTrue(mob.save(output),"The infected is a persistent entity");
var saved=output.buildResult();saved.putBoolean("IsBaby",true);
var input=TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),saved);
var loaded=EntityType.loadEntityRecursive(input,h.getLevel(),EntitySpawnReason.LOAD,e->e);
h.assertTrue(loaded instanceof InfectedEntity infected&&infected.getType()==Infected.TYPE
&&infected.getHealth()==7&&!infected.isBaby()&&infected.getUUID().equals(mob.getUUID()),"Native reload rejects an old baby flag while retaining type, UUID and health");
h.succeed();
}
@GameTest public void naturalSpawnTablesRespectExistingZombieHabitats(GameTestHelper h) {
var biomes=h.getLevel().registryAccess().lookupOrThrow(Registries.BIOME);
// Fabric's 26.3 flat_all_dimensions fixture uses a desert Overworld.
var habitats=h.getLevel().getChunkSource().getGenerator().getBiomeSource().possibleBiomes().stream()
.filter(b->spawns(b.value()).stream().anyMatch(e->e.value().type()==EntityTypes.ZOMBIE)).toList();
h.assertTrue(!habitats.isEmpty(),"The native fixture includes a real zombie habitat");
for(var biome:habitats) {
var entries=spawns(biome.value());
var infected=entries.stream().filter(e->e.value().type()==Infected.TYPE).toList();
h.assertTrue(infected.size()==1&&infected.getFirst().weight()==15,"Zombie habitat gets exactly one low-weight infected entry: "+biome+" "+infected);
}
for(var key:java.util.List.of(Biomes.MUSHROOM_FIELDS,Biomes.NETHER_WASTES,Biomes.THE_END))
h.assertTrue(spawns(biomes.getOrThrow(key).value()).stream()
.noneMatch(e->e.value().type()==Infected.TYPE),"No infected in mushroom fields, Nether or End");
h.succeed();
}
@GameTest public void immersionKeepsTheInfectedSpecies(GameTestHelper h) {
var at=h.absolutePos(new BlockPos(2,2,2));
for(int x=-1;x<=2;x++)for(int y=0;y<=2;y++)for(int z=-1;z<=1;z++)
h.getLevel().setBlockAndUpdate(at.offset(x,y,z),Blocks.WATER.defaultBlockState());
var infected=Infected.TYPE.create(h.getLevel(),EntitySpawnReason.COMMAND);
var zombie=EntityTypes.ZOMBIE.create(h.getLevel(),EntitySpawnReason.COMMAND);
infected.setPos(Vec3.atBottomCenterOf(at));zombie.setPos(Vec3.atBottomCenterOf(at.east()));
infected.setInWaterTime(601);zombie.setInWaterTime(601);
h.getLevel().addFreshEntity(infected);h.getLevel().addFreshEntity(zombie);
h.runAfterDelay(5,()->{
h.assertTrue(zombie.isUnderWaterConverting(),"The water fixture starts vanilla zombie conversion");
h.assertTrue(infected.isAlive()&&!infected.isUnderWaterConverting()&&infected.getType()==Infected.TYPE,"The infected retains its species under the same immersion");
infected.discard();zombie.discard();h.succeed();
});
}
@GameTest public void equippedInfectedUsesOwnProfileAndPersistentBond(GameTestHelper h) {
var p=equipped(h);var pet=FamiliarService.active(p);var profile=FamiliarBattle.profile(p);var id=FamiliarBattle.bond(p).id();
h.assertTrue(pet!=null&&pet.owner()==p&&!pet.babyAppearance(),"Equipping the new egg produces an adult miniature familiar");
h.assertTrue(profile.id().equals("sanctuary:infected")&&pet.getMaxHealth()==10&&pet.getHealth()==10,"Dedicated profile supplies actual familiar health");
h.assertTrue(FamiliarCatalog.bundled().forEgg(FamiliarBattle.egg(p)).equals(profile),"Client bundled and server loaded profiles agree");
h.assertTrue(pet.burnsInDaylight(),"The infected familiar shares the undead sunlight rule");
FamiliarBattle.command(p,"work");h.assertTrue(!FamiliarBattle.state(p).workMode,"Unsupported work mode is refused by the server");
FamiliarBattle.update(p,FamiliarBattle.bond(p).health(6,0));
var egg=FamiliarBattle.egg(p).copy();cleanup(p);
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.tickCount+=10;FamiliarService.tick(p);
h.assertTrue(FamiliarService.active(p)!=null&&FamiliarBattle.bond(p).id().equals(id)&&FamiliarService.active(p).getHealth()==6,"Re-equipping retains this individual's identity and health");
cleanup(p);h.succeed();
}
@GameTest public void infectedFamiliarBiteCausesRealDamage(GameTestHelper h) {
var p=equipped(h);var pet=FamiliarService.active(p);
var enemy=EntityTypes.ZOMBIE.create(h.getLevel(),EntitySpawnReason.COMMAND);enemy.setNoAi(true);
enemy.setPos(pet.position().add(0,0,1));h.getLevel().addFreshEntity(enemy);
float health=enemy.getHealth();
var bite=FamiliarBattle.profile(p).initiative();
var cast=new FamiliarTechniques.Cast(pet,bite,true,enemy,enemy.position());
h.assertTrue(FamiliarActions.validate(cast).isEmpty(),"The infected can reach a nearby hostile");
FamiliarActions.execute(cast);
h.assertTrue(enemy.getHealth()<health&&FamiliarBattle.fighting(p),"The distinct profile deals native damage and enters combat");
enemy.discard();cleanup(p);h.succeed();
}
@GameTest public void eggSwapStopsThePreviousGoalBeforeTheNewBondExists(GameTestHelper h) {
var p=equipped(h);var old=FamiliarService.active(p);var id=FamiliarBattle.bond(p).id();
old.setAggressive(true);
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,new ItemStack(Items.ZOMBIE_SPAWN_EGG));
h.assertTrue(FamiliarBattle.profile(p)!=null&&FamiliarBattle.bond(p)==null,"A fresh replacement egg has a profile before it has an individual");
FamiliarBrain.tick(old);
h.assertTrue(!old.isAggressive(),"The previous running goal safely stops during that native tick window");
FamiliarService.tick(p);
h.assertTrue(old.isRemoved()&&FamiliarService.active(p)!=null&&!FamiliarBattle.bond(p).id().equals(id)
&&FamiliarBattle.profile(p).id().equals("sanctuary:zombie"),"Normal reconciliation creates the replacement without reusing the old individual");
cleanup(p);h.succeed();
}
@GameTest public void adultMiniaturePreservesVariableSizeAndIndividual(GameTestHelper h) {
var p=equipped(h);var id=FamiliarBattle.bond(p).id();var model=Infected.TYPE.create(h.getLevel(),EntitySpawnReason.MOB_SUMMONED);
model.setBaby(true);
for(int value:new int[]{120,500,1000,1500,3000,6000}) {
var egg=FamiliarBattle.egg(p).copy();var size=new net.minecraft.nbt.CompoundTag();
size.putInt("schema",1);size.putInt("per_mille",value);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.tickCount+=10;FamiliarService.tick(p);
var pet=FamiliarService.active(p);
h.assertTrue(pet!=null&&!pet.babyAppearance()&&FamiliarBattle.bond(p).id().equals(id),"Every miniature size retains adult proportions and the same individual");
h.assertTrue(FamiliarSize.value(FamiliarBattle.egg(p))==value,"The stored size trait is never rerolled or rewritten");
double height=model.getBbHeight()*FamiliarSize.renderScale(model,egg);
h.assertTrue(Math.abs(pet.visualHeight()-height)<.0001&&Math.abs(height-.85*value/1000)<.0001,"Voice, visible size and mount height all use the adult miniature scale");
}
cleanup(p);h.succeed();
}
private static java.util.List<net.minecraft.util.random.Weighted<net.minecraft.world.level.biome.MobSpawnSettings.SpawnerData>> spawns(net.minecraft.world.level.biome.Biome biome) {
var attribute=net.minecraft.world.attribute.EnvironmentAttributes.NATURAL_MOB_SPAWNS;
return biome.getAttributes().applyModifier(attribute,attribute.defaultValue()).getMobsToSpawn(MobCategory.MONSTER).unwrap();
}
private static net.minecraft.server.level.ServerPlayer equipped(GameTestHelper h) {
var p=Companion019GameTests.player(h,CompanionType.ZOMBIE);
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,new ItemStack(Infected.SPAWN_EGG));
p.tickCount=200;FamiliarService.tick(p);return p;
}
private static void cleanup(net.minecraft.server.level.ServerPlayer p) {
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);
FamiliarService.tick(p);FamiliarBattle.release(p);CompanionService.clear(p);
}
}
@@ -0,0 +1,189 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.infected.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.zipline.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.item.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.*;
import net.minecraft.world.phys.Vec3;
/** Real projectile intersections, whole-body zipline motion and interruption rules. */
public final class Smoker285GameTests {
private static final java.util.concurrent.atomic.AtomicReference<BlockPos> DENIED=new java.util.concurrent.atomic.AtomicReference<>();
static { CompanionWorld.ALLOW.register((player,pos,operation)->!pos.equals(DENIED.get())); }
private static BlockPos arena(GameTestHelper h,int slot) {
// Stay inside the framework's tracked chunks so native projectile queries see players immediately.
var at=h.absolutePos(new BlockPos(0,70+slot*9,0));
for(int x=-4;x<=4;x++)for(int z=-3;z<=18;z++) {
h.getLevel().setBlockAndUpdate(at.offset(x,-1,z),Blocks.STONE.defaultBlockState());
h.getLevel().setBlockAndUpdate(at.offset(x,7,z),Blocks.STONE.defaultBlockState());
}
return at;
}
private static SmokerEntity smoker(GameTestHelper h,BlockPos at) {
var mob=Infected.SMOKER.create(h.getLevel(),EntitySpawnReason.COMMAND);mob.setNoAi(true);mob.setPos(Vec3.atBottomCenterOf(at));mob.setOnGround(true);h.getLevel().addFreshEntity(mob);return mob;
}
private static ServerPlayer rider(GameTestHelper h,BlockPos at) {
var p=Progression003GameTests.player(h);p.setUUID(java.util.UUID.randomUUID());p.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());
p.setPos(Vec3.atBottomCenterOf(at));p.setOnGround(true);p.setInvulnerableTime(0);h.getLevel().addNewPlayer(p);return p;
}
private static void tick(GameTestHelper h,Entity entity) { h.getLevel().tickNonPassenger(entity); }
private static void catchTarget(GameTestHelper h,SmokerLassoEntity rope) { for(int i=0;i<28&&!rope.isRemoved()&&!rope.isReeling();i++)tick(h,rope); }
private static void ride(GameTestHelper h,SmokerLassoEntity rope) { for(int i=0;i<65&&!rope.isRemoved();i++)tick(h,rope); }
@GameTest public void eggProfileSpawnAndAdultRule(GameTestHelper h) {
var egg=new ItemStack(Infected.SMOKER_EGG);var mob=Infected.SMOKER.create(h.getLevel(),EntitySpawnReason.COMMAND);mob.setBaby(true);
h.assertTrue(SpawnEggItem.getType(egg)==Infected.SMOKER&&SpawnEggItem.byId(Infected.SMOKER).orElseThrow().value()==Infected.SMOKER_EGG,"Native egg maps to Smoker in both directions");
h.assertTrue(!mob.isBaby()&&mob.getMaxHealth()==10&&mob.getAttributeValue(Attributes.MOVEMENT_SPEED)==.23,"Smoker always has adult proportions and its own attributes");
var profile=FamiliarCatalog.bundled().forEgg(egg);h.assertTrue(profile.id().equals("sanctuary:smoker")&&profile.health()==12&&profile.signature().effect().equals("lasso"),"Dedicated familiar profile is bundled");
var attribute=net.minecraft.world.attribute.EnvironmentAttributes.NATURAL_MOB_SPAWNS;
for(var b:h.getLevel().getChunkSource().getGenerator().getBiomeSource().possibleBiomes()) {
var entries=b.value().getAttributes().applyModifier(attribute,attribute.defaultValue()).getMobsToSpawn(MobCategory.MONSTER).unwrap();
if(entries.stream().noneMatch(e->e.value().type()==EntityTypes.ZOMBIE))continue;
var found=entries.stream().filter(e->e.value().type()==Infected.SMOKER).toList();
h.assertTrue(found.size()==1&&found.getFirst().weight()==4&&found.getFirst().value().count().minInclusive()==1&&found.getFirst().value().count().maxInclusive()==1,"Zombie habitats receive a rare singleton Smoker");
}
h.succeed();
}
@GameTest public void actualPlayerMustBeHitBeforeZiplineMotionBegins(GameTestHelper h) {
var at=arena(h,0);var source=smoker(h,at);var p=rider(h,at.offset(0,0,10));var initial=p.position();var rope=SmokerLassoEntity.launch(source,p,null);
tick(h,rope);h.assertTrue(!rope.isReeling()&&p.position().equals(initial),"Throwing the lasso alone cannot move the player");catchTarget(h,rope);
h.assertTrue(rope.isReeling()&&rope.caughtEntity()==p&&SmokerLassoEntity.pulled(p)&&p.position().equals(initial),"A real native projectile intersection attaches the player before travel");
while(!rope.isRemoved()) { var before=p.position();tick(h,rope);h.assertTrue(p.position().distanceTo(before)<=ZiplineMotion.SPEED+.0001,"Each step uses the existing 0.58-block zipline speed"); }
h.assertTrue(Math.abs(p.distanceTo(source)-1.8)<.001&&!SmokerLassoEntity.pulled(p)&&SmokerLassoEntity.active(source)==null&&!p.isNoGravity(),"Arrival releases the cable near the Smoker with ordinary gravity");
source.discard();h.getLevel().removePlayerImmediately(p,Entity.RemovalReason.DISCARDED);h.succeed();
}
@GameTest public void nativeAiPreparesLaunchesAndCommitsCooldown(GameTestHelper h) {
var at=arena(h,1);var source=smoker(h,at);var p=rider(h,at.offset(0,0,10));source.setNoAi(false);source.setTarget(p);
for(int i=0;i<10;i++)tick(h,source);
h.assertTrue(source.windingUp()&&SmokerLassoEntity.active(source)==null,"Native goal visibly prepares before launching");
for(int i=0;i<20&&SmokerLassoEntity.active(source)==null;i++)tick(h,source);
var rope=SmokerLassoEntity.active(source);h.assertTrue(rope!=null&&source.lassoCooldown()==160,"Release commits cooldown immediately");
catchTarget(h,rope);var before=p.position();ride(h,rope);
h.assertTrue(p.getZ()<before.z-5&&source.lassoCooldown()>0,"The native attack really reels its victim without an instant grab");source.discard();h.getLevel().removePlayerImmediately(p,Entity.RemovalReason.DISCARDED);h.succeed();
}
@GameTest public void sidesteppingDuringFlightReallyDodgesTheLasso(GameTestHelper h) {
var at=arena(h,2);var source=smoker(h,at);var p=rider(h,at.offset(0,0,10));source.setNoAi(false);source.setTarget(p);
for(int i=0;i<30&&SmokerLassoEntity.active(source)==null;i++)tick(h,source);var rope=SmokerLassoEntity.active(source);
tick(h,rope);tick(h,rope);p.setPos(p.position().add(3,0,0));var dodged=p.position();ride(h,rope);
h.assertTrue(rope.isRemoved()&&!SmokerLassoEntity.pulled(p)&&p.position().equals(dodged)&&source.lassoCooldown()>0,"The released lasso never homes in on a sidestep, and a miss still costs cooldown");source.discard();h.getLevel().removePlayerImmediately(p,Entity.RemovalReason.DISCARDED);h.succeed();
}
@GameTest public void wallInsertedDuringFlightStopsTheProjectile(GameTestHelper h) {
var at=arena(h,3);var source=smoker(h,at);var p=rider(h,at.offset(0,0,10));var initial=p.position();var rope=SmokerLassoEntity.launch(source,p,null);tick(h,rope);
for(int x=-3;x<=3;x++)for(int y=0;y<4;y++)h.getLevel().setBlockAndUpdate(at.offset(x,y,5),Blocks.STONE.defaultBlockState());ride(h,rope);
h.assertTrue(rope.isRemoved()&&!SmokerLassoEntity.pulled(p)&&p.position().equals(initial)&&h.getLevel().getBlockState(at.offset(0,1,5)).is(Blocks.STONE),"A native block collision prevents capture and preserves the wall");source.discard();h.getLevel().removePlayerImmediately(p,Entity.RemovalReason.DISCARDED);h.succeed();
}
@GameTest public void wholePlayerBodyHitsLowObstacleEvenWhenEyesCanSeeOverIt(GameTestHelper h) {
var at=arena(h,4);var source=smoker(h,at);var p=rider(h,at.offset(0,0,10));var rope=SmokerLassoEntity.launch(source,p,null);catchTarget(h,rope);
h.assertTrue(rope.isReeling(),"The physical lasso first attaches");h.getLevel().setBlockAndUpdate(at.offset(0,0,5),Blocks.STONE.defaultBlockState());
h.assertTrue(source.hasLineOfSight(p),"The eye-level line still passes over the low obstacle");ride(h,rope);
h.assertTrue(p.getZ()>=at.getZ()+6.3&&!SmokerLassoEntity.pulled(p)&&p.getDeltaMovement().y<=-.18&&!p.isNoGravity(),"The complete body stops before the block and the cable releases into a normal fall");source.discard();h.getLevel().removePlayerImmediately(p,Entity.RemovalReason.DISCARDED);h.succeed();
}
@GameTest public void hurtingTheSmokerReleasesItsVictim(GameTestHelper h) {
var at=arena(h,5);var source=smoker(h,at);source.setHealth(5);var p=rider(h,at.offset(0,0,10));var rope=SmokerLassoEntity.launch(source,p,null);catchTarget(h,rope);tick(h,rope);var before=p.position();
source.setHealth(10);source.hurtServer(h.getLevel(),source.damageSources().generic(),1);tick(h,rope);
h.assertTrue(rope.isRemoved()&&!SmokerLassoEntity.pulled(p)&&p.position().equals(before),"Actual source damage cuts an established cable before another travel step");source.discard();h.getLevel().removePlayerImmediately(p,Entity.RemovalReason.DISCARDED);h.succeed();
}
@GameTest public void cuttingHookAndRemovingSourceCleanBothAttachments(GameTestHelper h) {
var at=arena(h,6);var source=smoker(h,at);var p=rider(h,at.offset(0,0,10));var rope=SmokerLassoEntity.launch(source,p,null);catchTarget(h,rope);
h.assertTrue(rope.hurtServer(h.getLevel(),source.damageSources().playerAttack(p),1)&&!SmokerLassoEntity.pulled(p)&&SmokerLassoEntity.active(source)==null,"The pickable physical hook can be cut");
rope=SmokerLassoEntity.launch(source,p,null);catchTarget(h,rope);source.discard();tick(h,rope);
h.assertTrue(rope.isRemoved()&&!SmokerLassoEntity.pulled(p),"A removed source never leaves a captive or a saved cable");h.getLevel().removePlayerImmediately(p,Entity.RemovalReason.DISCARDED);h.succeed();
}
@GameTest public void onlyOneSourceCableAndOneCapturePerVictim(GameTestHelper h) {
var at=arena(h,7);var source=smoker(h,at);var second=smoker(h,at.offset(2,0,0));var p=rider(h,at.offset(0,0,10));var rope=SmokerLassoEntity.launch(source,p,null);
h.assertTrue(SmokerLassoEntity.launch(source,p,null)==null,"A source cannot stack flying lassos");catchTarget(h,rope);
h.assertTrue(SmokerLassoEntity.launch(second,p,null)==null,"A captured player cannot be reeled by another source simultaneously");rope.discard();source.discard();second.discard();h.getLevel().removePlayerImmediately(p,Entity.RemovalReason.DISCARDED);h.succeed();
}
@GameTest public void teleportsAndCreativeModeBreakEstablishedPulls(GameTestHelper h) {
var at=arena(h,8);var source=smoker(h,at);var p=rider(h,at.offset(0,0,10));var rope=SmokerLassoEntity.launch(source,p,null);catchTarget(h,rope);p.teleportTo(p.getX()+5,p.getY(),p.getZ());tick(h,rope);
h.assertTrue(rope.isRemoved()&&!SmokerLassoEntity.pulled(p),"An external player teleport is not turned into a remote swept pull");
var creative=Progression003GameTests.player(h,GameType.CREATIVE);creative.setPos(Vec3.atBottomCenterOf(at.offset(0,0,10)));
h.assertTrue(SmokerLassoEntity.launch(source,creative,null)==null,"Creative players cannot be captured");
p.setPos(Vec3.atBottomCenterOf(at.offset(0,0,10)));rope=SmokerLassoEntity.launch(source,p,null);catchTarget(h,rope);source.setPos(source.position().add(2,0,0));tick(h,rope);
h.assertTrue(rope.isRemoved()&&!SmokerLassoEntity.pulled(p),"Moving the anchor cuts the cable instead of dragging across a teleport");source.discard();h.getLevel().removePlayerImmediately(p,Entity.RemovalReason.DISCARDED);h.succeed();
}
@GameTest public void hostileCaptureCannotBeReplacedByAnOrdinaryZipline(GameTestHelper h) {
var at=arena(h,9);var source=smoker(h,at);var p=rider(h,at.offset(0,0,10));var rope=SmokerLassoEntity.launch(source,p,null);catchTarget(h,rope);p.setShiftKeyDown(true);
tick(h,rope);h.assertTrue(SmokerLassoEntity.pulled(p),"Sneaking does not cancel a hostile capture after a real hit: removed="+rope.isRemoved()+" reeling="+rope.isReeling()+" position="+p.position()+" at="+at);
p.setItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND,new ItemStack(Ziplines.GRAPPLING_ROD));p.setItemInHand(net.minecraft.world.InteractionHand.OFF_HAND,new ItemStack(Items.LEAD));
h.assertTrue(!PortableZipline.launch(p,net.minecraft.world.InteractionHand.MAIN_HAND,p.getMainHandItem(),1)&&p.getOffhandItem().getCount()==1,"A second movement session is refused without spending a lead");
h.assertTrue(PulleyHeadController.startRide(h.getLevel(),p,at)==net.minecraft.world.InteractionResult.CONSUME&&!PulleyHeadController.hasRide(p),"Fixed pulleys also refuse a competing ride");rope.discard();source.discard();h.getLevel().removePlayerImmediately(p,Entity.RemovalReason.DISCARDED);h.succeed();
}
@GameTest public void cooldownPersistsButNoRopeOrBabyIsRestored(GameTestHelper h) {
var at=arena(h,10);var source=smoker(h,at);var p=rider(h,at.offset(0,0,10));source.setNoAi(false);source.setTarget(p);for(int i=0;i<8;i++)tick(h,source);
var output=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());source.save(output);var tag=output.buildResult();tag.putBoolean("IsBaby",true);
var loaded=EntityType.loadEntityRecursive(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),tag),h.getLevel(),EntitySpawnReason.LOAD,e->e);
h.assertTrue(loaded instanceof SmokerEntity s&&!s.isBaby()&&!s.windingUp()&&s.lassoCooldown()==160&&SmokerLassoEntity.active(s)==null,"Reload keeps cooldown, rejects baby data and cannot replay preparation or a rope");
source.setNoAi(true);var rope=SmokerLassoEntity.launch(source,p,null);var out=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());
h.assertTrue(!rope.save(out),"A lasso and its trip are never serialized as world hazards");rope.discard();source.discard();h.getLevel().removePlayerImmediately(p,Entity.RemovalReason.DISCARDED);h.succeed();
}
@GameTest public void unloadedCorridorIsRejectedWithoutLoadingIt(GameTestHelper h) {
var at=arena(h,11);var source=smoker(h,at);var p=rider(h,at.offset(0,0,10));var rope=SmokerLassoEntity.launch(source,p,null);var remote=h.absolutePos(new BlockPos(10000,180,10000));
h.assertTrue(!h.getLevel().hasChunkAt(remote),"Remote chunk begins unloaded");rope.setPos(Vec3.atCenterOf(remote));tick(h,rope);
h.assertTrue(rope.isRemoved()&&!h.getLevel().hasChunkAt(remote),"A remote projectile is dropped without requesting its chunk");source.discard();h.getLevel().removePlayerImmediately(p,Entity.RemovalReason.DISCARDED);h.succeed();
}
@GameTest public void familiarGNeedsAHitThenPullsTheActualEnemy(GameTestHelper h) {
var p=pet(h,12);var pet=FamiliarService.active(p);var enemy=enemy(h,pet.position().add(0,0,9));FamiliarBattle.engage(p,enemy,false);
var activation=FamiliarBattle.activate(p);h.assertTrue(activation.equals("activated"),"Native manual activation starts the familiar's G preparation: "+activation+" active="+FamiliarBattle.active(p)+" pet="+pet.position()+" owner="+p.position()+" enemy="+enemy.position()+" sight="+pet.hasLineOfSight(enemy));var cast=FamiliarBattle.state(p).cast;for(int i=0;i<22;i++)FamiliarTechniques.advance(pet);
var rope=SmokerLassoEntity.active(pet);h.assertTrue(rope!=null&&enemy.getHealth()==20&&!SmokerLassoEntity.pulled(enemy),"Release alone neither hits nor captures the enemy");
h.assertTrue(FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now()&&CompanionService.record(p).readyAt()==FamiliarBattle.bond(p).readyAt(),"G pays the durable shared cooldown on release");
catchTarget(h,rope);h.assertTrue(rope.isReeling()&&enemy.getHealth()<20&&cast.hit.contains(enemy.getUUID()),"Actual projectile contact passes through shared familiar damage and control rules");
h.assertTrue(FamiliarTechniques.begin(pet,FamiliarBattle.profile(p).initiative(),true).equals("preparing"),"Automatic fire cannot replace an active lasso");ride(h,rope);
h.assertTrue(enemy.distanceTo(pet)<2&&pet.isAlive()&&!pet.babyAppearance(),"The enemy travels physically to the adult miniature familiar");enemy.discard();cleanup(p);h.succeed();
}
@GameTest public void familiarMissKeepsCooldownAndFollowCancelsTheCable(GameTestHelper h) {
var p=pet(h,13);var pet=FamiliarService.active(p);var enemy=enemy(h,pet.position().add(0,0,9));FamiliarBattle.engage(p,enemy,false);var activation=FamiliarBattle.activate(p);h.assertTrue(activation.equals("activated"),"Miss fixture activates G: "+activation);for(int i=0;i<22;i++)FamiliarTechniques.advance(pet);
var rope=SmokerLassoEntity.active(pet);tick(h,rope);enemy.setPos(enemy.position().add(3,0,0));ride(h,rope);
h.assertTrue(enemy.getHealth()==20&&!SmokerLassoEntity.pulled(enemy)&&FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now(),"A dodged G cannot become a guaranteed grab or refund its cooldown");
var other=enemy(h,pet.position().add(0,0,9));var cast=new FamiliarTechniques.Cast(pet,FamiliarBattle.profile(p).signature(),false,other,other.position());rope=SmokerLassoEntity.launch(pet,other,cast);catchTarget(h,rope);
h.assertTrue(rope.isReeling(),"A separate real hit establishes the follow-cancellation case");FamiliarBattle.cancel(p);
h.assertTrue(rope.isRemoved()&&!SmokerLassoEntity.pulled(other)&&FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now(),"The ordinary cancel/follow path releases the victim while retaining spent cooldown");enemy.discard();other.discard();cleanup(p);h.succeed();
}
@GameTest public void familiarCannotLassoOwnerPassiveAnimalsOrBosses(GameTestHelper h) {
var p=pet(h,14);var pet=FamiliarService.active(p);var enemy=enemy(h,pet.position().add(0,0,9));p.setPos(pet.position().add(0,0,4));h.getLevel().addNewPlayer(p);
var cow=EntityTypes.COW.create(h.getLevel(),EntitySpawnReason.COMMAND);cow.setNoAi(true);cow.setPos(pet.position().add(0,0,6));h.getLevel().addFreshEntity(cow);
var cast=new FamiliarTechniques.Cast(pet,FamiliarBattle.profile(p).signature(),false,enemy,enemy.position());var rope=SmokerLassoEntity.launch(pet,enemy,cast);catchTarget(h,rope);
h.assertTrue(rope.isReeling()&&rope.caughtEntity()==enemy&&!SmokerLassoEntity.pulled(p)&&!SmokerLassoEntity.pulled(cow)&&cow.getHealth()==10,"A physical lasso ignores the owner and passive bystanders without dragging or hurting them: removed="+rope.isRemoved()+" reeling="+rope.isReeling()+" caught="+rope.caughtEntity()+" ownerPulled="+SmokerLassoEntity.pulled(p)+" cow="+cow.getHealth()+" target="+enemy.getHealth()+" hook="+rope.position());rope.discard();
var wither=EntityTypes.WITHER.create(h.getLevel(),EntitySpawnReason.COMMAND);
h.assertTrue(SmokerLassoEntity.launch(pet,wither,cast)==null,"Bosses cannot be captured even through a server-created cast");
enemy.discard();cow.discard();cleanup(p);h.getLevel().removePlayerImmediately(p,Entity.RemovalReason.DISCARDED);h.succeed();
}
@GameTest public void miniatureSizesRetainAdultGeometryAndIdentity(GameTestHelper h) {
var p=pet(h,15);var id=FamiliarBattle.bond(p).id();
for(int value:new int[]{120,500,1000,1500,3000,6000}) {
var egg=FamiliarBattle.egg(p).copy();var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",value);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.tickCount+=10;FamiliarService.tick(p);
var pet=FamiliarService.active(p);h.assertTrue(pet!=null&&!pet.babyAppearance()&&Math.abs(pet.visualHeight()-.85*value/1000)<.001&&FamiliarBattle.bond(p).id().equals(id),"Every supported size keeps the adult miniature and bond: "+value);
}
cleanup(p);h.succeed();
}
@GameTest public void familiarStopsBeforeEnteringAProtectedCell(GameTestHelper h) {
var p=pet(h,16);var pet=FamiliarService.active(p);var enemy=enemy(h,pet.position().add(0,0,9));
var cast=new FamiliarTechniques.Cast(pet,FamiliarBattle.profile(p).signature(),false,enemy,enemy.position());var rope=SmokerLassoEntity.launch(pet,enemy,cast);catchTarget(h,rope);
h.assertTrue(rope.isReeling(),"A permitted physical hit first establishes the cable");var before=enemy.position();
try {
DENIED.set(BlockPos.containing(before.add(0,0,-ZiplineMotion.SPEED)));tick(h,rope);
h.assertTrue(rope.isRemoved()&&!SmokerLassoEntity.pulled(enemy)&&enemy.position().equals(before),"A claim veto stops the next step before the enemy enters its protected cell");
} finally { DENIED.set(null);rope.discard();enemy.discard();cleanup(p); }
h.succeed();
}
private static ServerPlayer pet(GameTestHelper h,int slot) {
var p=Companion019GameTests.player(h,CompanionType.ZOMBIE);p.setPos(Vec3.atBottomCenterOf(arena(h,slot)));var egg=new ItemStack(Infected.SMOKER_EGG);var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",1000);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.tickCount=200;FamiliarService.tick(p);
var pet=FamiliarService.active(p);pet.setNoAi(true);pet.setPos(p.position());pet.setOnGround(true);return p;
}
private static net.minecraft.world.entity.monster.zombie.Zombie enemy(GameTestHelper h,Vec3 at) { var mob=EntityTypes.ZOMBIE.create(h.getLevel(),EntitySpawnReason.COMMAND);mob.setNoAi(true);mob.setPos(at);h.getLevel().addFreshEntity(mob);return mob; }
private static void cleanup(ServerPlayer p) { AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);CompanionService.clear(p); }
}
@@ -0,0 +1,169 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.infected.*;
import fr.koka.sanctuary.inventory.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.*;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.monster.zombie.Zombie;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.*;
import net.minecraft.world.phys.*;
/** Grounded native potion clouds, actual projectiles and shared familiar damage protections. */
public final class Spitter283GameTests {
private static BlockPos arena(GameTestHelper h,int slot) {
var at=h.absolutePos(new BlockPos(0,80+slot*8,0));
for(int x=-4;x<=4;x++)for(int z=-3;z<=12;z++) {
h.getLevel().setBlockAndUpdate(at.offset(x,-1,z),Blocks.STONE.defaultBlockState());
h.getLevel().setBlockAndUpdate(at.offset(x,4,z),Blocks.STONE.defaultBlockState());
}
return at;
}
private static SpitterEntity source(GameTestHelper h,BlockPos at) {
var source=Infected.SPITTER.create(h.getLevel(),EntitySpawnReason.COMMAND);source.setNoAi(true);
source.setPos(Vec3.atBottomCenterOf(at));h.getLevel().addFreshEntity(source);return source;
}
private static Zombie enemy(GameTestHelper h,Vec3 at) {
var target=EntityTypes.ZOMBIE.create(h.getLevel(),EntitySpawnReason.COMMAND);target.setNoAi(true);
target.setPos(at);h.getLevel().addFreshEntity(target);return target;
}
private static BlockHitResult ground(Vec3 at) { return new BlockHitResult(at,Direction.UP,BlockPos.containing(at).below(),false); }
private static void tick(GameTestHelper h,Entity entity,int count,LivingEntity... victims) {
for(int i=0;i<count&&!entity.isRemoved();i++) {
for(var target:victims)h.getLevel().tickNonPassenger(target);
h.getLevel().tickNonPassenger(entity);
}
}
@GameTest public void eggProfileAndAdultRule(GameTestHelper h) {
var egg=new ItemStack(Infected.SPITTER_EGG);var mob=Infected.SPITTER.create(h.getLevel(),EntitySpawnReason.COMMAND);mob.setBaby(true);
h.assertTrue(SpawnEggItem.getType(egg)==Infected.SPITTER&&SpawnEggItem.byId(Infected.SPITTER).orElseThrow().value()==Infected.SPITTER_EGG,"Spitter uses a dedicated native spawn egg");
h.assertTrue(!mob.isBaby()&&mob.getMaxHealth()==10&&mob.getAttributeValue(Attributes.MOVEMENT_SPEED)==.24,"Spitter always has adult geometry and its own attributes");
var profile=FamiliarCatalog.bundled().forEgg(egg);
h.assertTrue(profile.id().equals("sanctuary:spitter")&&profile.health()==12&&profile.signature().effect().equals("acid_zone"),"Bundled familiar has the ranged acid-zone profile");
var attribute=net.minecraft.world.attribute.EnvironmentAttributes.NATURAL_MOB_SPAWNS;
for(var b:h.getLevel().getChunkSource().getGenerator().getBiomeSource().possibleBiomes()) {
var entries=b.value().getAttributes().applyModifier(attribute,attribute.defaultValue()).getMobsToSpawn(MobCategory.MONSTER).unwrap();
if(entries.stream().noneMatch(e->e.value().type()==EntityTypes.ZOMBIE))continue;
var found=entries.stream().filter(e->e.value().type()==Infected.SPITTER).toList();
h.assertTrue(found.size()==1&&found.getFirst().weight()==4&&found.getFirst().value().count().minInclusive()==1&&found.getFirst().value().count().maxInclusive()==1,"Zombie habitats receive a rare singleton Spitter");
}
h.succeed();
}
@GameTest public void waitingPulseExitAndExpiryHaveNoLingeringStatus(GameTestHelper h) {
var at=arena(h,1);var source=source(h,at);var target=enemy(h,Vec3.atBottomCenterOf(at.offset(0,0,5)));
var zone=AcidZoneEntity.create(source,ground(target.position()),null);
h.assertTrue(zone!=null&&zone instanceof AreaEffectCloud&&target.getHealth()==20,"Impact creates a native potion-cloud zone without direct damage");
tick(h,zone,9,target);h.assertTrue(target.getHealth()==20,"The first half-second allows escape without damage");
tick(h,zone,32,target);h.assertTrue(target.getHealth()==18&&target.getActiveEffects().isEmpty(),"Two one-second pulses hurt without poison or slowness: "+target.getHealth());
target.setPos(target.position().add(4,0,0));tick(h,zone,40,target);
h.assertTrue(target.getHealth()==18&&target.getActiveEffects().isEmpty(),"Leaving immediately stops all subsequent zone damage");
tick(h,zone,40,target);h.assertTrue(zone.isRemoved()&&AcidZoneEntity.active(source)==null,"The finite shrinking zone expires and clears its source reference");
h.assertTrue(h.getLevel().getBlockState(at.offset(0,-1,5)).is(Blocks.STONE),"Acid does not alter the floor");source.discard();target.discard();h.succeed();
}
@GameTest public void wallsFloorsAndInfectedAreProtected(GameTestHelper h) {
var at=arena(h,2);var source=source(h,at);var center=Vec3.atBottomCenterOf(at.offset(0,0,5));
var hit=enemy(h,center.add(-.5,0,0));var hidden=enemy(h,center.add(1.8,0,0));var upstairs=enemy(h,center.add(0,1,1));
var relative=source(h,at.offset(0,0,5));
for(int z=4;z<=6;z++)for(int y=0;y<3;y++)h.getLevel().setBlockAndUpdate(at.offset(1,y,z),Blocks.STONE.defaultBlockState());
h.getLevel().setBlockAndUpdate(at.offset(0,0,6),Blocks.STONE.defaultBlockState());
var zone=AcidZoneEntity.create(source,ground(center),null);tick(h,zone,41,hit,hidden,upstairs);
h.assertTrue(hit.getHealth()==18&&hidden.getHealth()==20&&upstairs.getHealth()==20,"A wall and a higher floor prevent cloud damage to separated entities");
h.assertTrue(source.getHealth()==10&&relative.getHealth()==10,"The source and infected family resist wild acid");
zone.discard();source.discard();relative.discard();hit.discard();hidden.discard();upstairs.discard();h.succeed();
}
@GameTest public void oneCloudPerSourceAndNoSavedHazards(GameTestHelper h) {
var at=arena(h,3);var source=source(h,at);var a=AcidZoneEntity.create(source,ground(Vec3.atBottomCenterOf(at.offset(0,0,4))),null);
var b=AcidZoneEntity.create(source,ground(Vec3.atBottomCenterOf(at.offset(0,0,6))),null);
h.assertTrue(a.isRemoved()&&!b.isRemoved()&&AcidZoneEntity.active(source)==b,"A second impact replaces rather than stacks the old zone");
var output=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());
var shot=new AcidSpitEntity(h.getLevel(),source);
h.assertTrue(!b.save(output)&&!shot.save(output),"Neither transient acid clouds nor projectiles enter world saves");
source.discard();tick(h,b,1);h.assertTrue(b.isRemoved(),"Removing the source removes its hazard on the next tick");h.succeed();
}
@GameTest public void unsupportedAndUnloadedImpactsCreateNothing(GameTestHelper h) {
var at=arena(h,4);var source=source(h,at);var air=Vec3.atBottomCenterOf(at.offset(0,3,6));
h.assertTrue(AcidZoneEntity.create(source,ground(air),null)==null,"An impact too far above ground cannot float a cloud in midair");
var remote=h.absolutePos(new BlockPos(10000,90,10000));
h.assertTrue(!h.getLevel().hasChunkAt(remote)&&AcidZoneEntity.create(source,ground(Vec3.atCenterOf(remote)),null)==null
&&!h.getLevel().hasChunkAt(remote),"An unloaded impact is rejected without loading or generating a chunk");
var shot=new AcidSpitEntity(h.getLevel(),source);shot.setPos(Vec3.atCenterOf(remote));h.getLevel().tickNonPassenger(shot);
h.assertTrue(shot.isRemoved()&&!h.getLevel().hasChunkAt(remote),"Projectile guards also reject an unloaded movement corridor");source.discard();h.succeed();
}
@GameTest(maxTicks=95) public void wildWindupAndNativeProjectileCreateGroundZone(GameTestHelper h) {
var at=arena(h,5);var source=source(h,at);source.setNoAi(false);source.setOnGround(true);
var target=EntityTypes.COW.create(h.getLevel(),EntitySpawnReason.COMMAND);target.setNoAi(true);target.setPos(Vec3.atBottomCenterOf(at.offset(0,0,7)));
h.getLevel().addFreshEntity(target);source.setTarget(target);
h.runAfterDelay(8,()->h.assertTrue(source.windingUp()&&AcidZoneEntity.active(source)==null,"Ranged attack visibly prepares before any hazard exists"));
h.runAfterDelay(45,()->{
var zone=AcidZoneEntity.active(source);
h.assertTrue(zone!=null&&zone.position().distanceTo(target.position())<1&&target.getHealth()<10,"Real native projectile contact deposits a grounded damaging potion-style zone");
h.assertTrue(source.spitCooldown()>0&&target.getActiveEffects().isEmpty(),"Cooldown prevents continuous fire and the victim receives no lingering debuff");
zone.discard();source.discard();target.discard();h.succeed();
});
}
@GameTest(maxTicks=40) public void interruptedWindupReloadsAsCooldownAndCannotShootThroughWall(GameTestHelper h) {
var at=arena(h,6);var source=source(h,at);source.setNoAi(false);source.setOnGround(true);
var target=enemy(h,Vec3.atBottomCenterOf(at.offset(0,0,7)));source.setTarget(target);
h.runAfterDelay(8,()->{
var output=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());source.save(output);
var tag=output.buildResult();tag.putBoolean("IsBaby",true);
var loaded=EntityType.loadEntityRecursive(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),tag),h.getLevel(),EntitySpawnReason.LOAD,e->e);
h.assertTrue(loaded instanceof SpitterEntity s&&!s.isBaby()&&!s.windingUp()&&s.spitCooldown()==100,"Reload keeps adult-only behavior and cooldown without replaying windup");
for(int x=-4;x<=4;x++)for(int y=0;y<4;y++)h.getLevel().setBlockAndUpdate(at.offset(x,y,3),Blocks.STONE.defaultBlockState());
});
h.runAfterDelay(30,()->{
h.assertTrue(!source.windingUp()&&AcidZoneEntity.active(source)==null&&target.getHealth()==20,"Losing line of sight cancels preparation without a through-wall projectile");
source.discard();target.discard();h.succeed();
});
}
@GameTest public void familiarProjectileZoneRepeatsDamageAndProtectsOwnerAndPassiveAnimals(GameTestHelper h) {
var p=pet(h,7);var pet=FamiliarService.active(p);var target=enemy(h,p.position().add(0,0,5));
var cast=new FamiliarTechniques.Cast(pet,FamiliarBattle.profile(p).signature(),false,target,target.position());
FamiliarBattle.state(p).cast=cast;
for(int i=0;i<25;i++)FamiliarTechniques.advance(pet);
h.assertTrue(FamiliarBattle.bond(p).readyAt()>FamiliarBattle.now(),"The normal release commits durable shared cooldown");
var shots=h.getLevel().getEntitiesOfClass(AcidSpitEntity.class,pet.getBoundingBox().inflate(10));
h.assertTrue(shots.size()==1,"The familiar releases one real acid projectile");
h.assertTrue(FamiliarTechniques.begin(pet,FamiliarBattle.profile(p).initiative(),true).equals("preparing"),"Automatic fire waits while the released G projectile is still in flight");
tick(h,shots.getFirst(),20);
var zone=AcidZoneEntity.active(pet);h.assertTrue(zone!=null&&target.getHealth()==20,"Actual familiar impact creates the cloud without instant hit damage");
h.assertTrue(FamiliarTechniques.begin(pet,FamiliarBattle.profile(p).initiative(),true).equals("preparing")&&AcidZoneEntity.active(pet)==zone,"Automatic fire preserves the active signature zone instead of replacing it with a small pool");
p.setPos(target.position());var animals=new java.util.ArrayList<LivingEntity>();
for(int i=0;i<13;i++) {
var animal=EntityTypes.COW.create(h.getLevel(),EntitySpawnReason.COMMAND);animal.setNoAi(true);animal.setPos(target.position());h.getLevel().addFreshEntity(animal);animals.add(animal);
}
var victims=new java.util.ArrayList<>(animals);victims.add(target);
float health=p.getHealth();tick(h,zone,41,victims.toArray(LivingEntity[]::new));
h.assertTrue(target.getHealth()<19&&cast.hit.contains(target.getUUID()),"The same enemy can receive repeated pulses through shared familiar damage rules");
h.assertTrue(p.getHealth()==health&&animals.stream().allMatch(e->e.getHealth()==10)&&target.getActiveEffects().isEmpty(),"Owner and passive crowds remain protected and cannot mask enemies from the bounded pulse");
cleanup(p);tick(h,zone,1);h.assertTrue(zone.isRemoved(),"Unequipping the familiar cancels its remaining zone");target.discard();animals.forEach(Entity::discard);h.succeed();
}
@GameTest public void familiarSizesUseAdultProportionsAndPersistOnEgg(GameTestHelper h) {
var p=pet(h,8);var egg=FamiliarBattle.egg(p).copy();var original=FamiliarBattle.bond(p).id();
for(int value:new int[]{500,1000,1500}) {
var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",value);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg.copy());p.tickCount+=10;FamiliarService.tick(p);var pet=FamiliarService.active(p);
h.assertTrue(!pet.babyAppearance()&&FamiliarSize.value(pet.egg())==value&&Math.abs(pet.visualHeight()-.85F*value/1000)<.001,"The variable miniature scales an adult body: "+value);
h.assertTrue(FamiliarBattle.bond(p).id().equals(original),"Changing size preserves the familiar bond identifier");
}
cleanup(p);h.succeed();
}
private static ServerPlayer pet(GameTestHelper h,int slot) {
var p=Companion019GameTests.player(h,CompanionType.ZOMBIE);var at=arena(h,slot);p.setPos(Vec3.atBottomCenterOf(at));
var egg=new ItemStack(Infected.SPITTER_EGG);var size=new net.minecraft.nbt.CompoundTag();size.putInt("schema",1);size.putInt("per_mille",1000);
net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.tickCount=200;FamiliarService.tick(p);
var pet=FamiliarService.active(p);pet.setPos(p.position());pet.setOnGround(true);pet.setNoAi(true);return p;
}
private static void cleanup(ServerPlayer p) { AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);CompanionService.clear(p); }
}
@@ -8,7 +8,7 @@
"entrypoints": {
"client": ["fr.koka.sanctuary.gametest.Starter039TestSupport"],
"preLaunch": ["fr.koka.sanctuary.gametest.Inventory010MixinAudit"],
"fabric-gametest": ["fr.koka.sanctuary.gametest.Menu275GameTests", "fr.koka.sanctuary.gametest.ClaimMap273GameTests", "fr.koka.sanctuary.gametest.ChunkClaims272GameTests", "fr.koka.sanctuary.gametest.Inhabitant271GameTests","fr.koka.sanctuary.gametest.Property270GameTests","fr.koka.sanctuary.gametest.Providence269GameTests","fr.koka.sanctuary.gametest.Providence268GameTests","fr.koka.sanctuary.gametest.Providence265GameTests","fr.koka.sanctuary.gametest.Insect245GameTests","fr.koka.sanctuary.gametest.Forgerie238GameTests","fr.koka.sanctuary.gametest.Aptitudes236GameTests","fr.koka.sanctuary.gametest.Diagonal231GameTests", "fr.koka.sanctuary.gametest.Diagonal230GameTests", "fr.koka.sanctuary.gametest.Diagonal229GameTests", "fr.koka.sanctuary.gametest.Diagonal228GameTests", "fr.koka.sanctuary.gametest.Diagonal227GameTests", "fr.koka.sanctuary.gametest.Diagonal226GameTests", "fr.koka.sanctuary.gametest.Diagonal225GameTests", "fr.koka.sanctuary.gametest.East224GameTests", "fr.koka.sanctuary.gametest.East223GameTests", "fr.koka.sanctuary.gametest.YellowRock222GameTests", "fr.koka.sanctuary.gametest.West221GameTests", "fr.koka.sanctuary.gametest.West220GameTests", "fr.koka.sanctuary.gametest.West219GameTests", "fr.koka.sanctuary.gametest.West218GameTests", "fr.koka.sanctuary.gametest.West217GameTests", "fr.koka.sanctuary.gametest.Base204GameTests", "fr.koka.sanctuary.gametest.North205GameTests", "fr.koka.sanctuary.gametest.Palace173GameTests", "fr.koka.sanctuary.gametest.Plans072GameTests", "fr.koka.sanctuary.gametest.Starter052GameTests", "fr.koka.sanctuary.gametest.Dispenser051GameTests", "fr.koka.sanctuary.gametest.Head050GameTests", "fr.koka.sanctuary.gametest.Poultry048GameTests", "fr.koka.sanctuary.gametest.StarInspection047GameTests", "fr.koka.sanctuary.gametest.SkyFlight046GameTests", "fr.koka.sanctuary.gametest.GravesFood038GameTests","fr.koka.sanctuary.gametest.Realtime037GameTests","fr.koka.sanctuary.gametest.Corrections036GameTests","fr.koka.sanctuary.gametest.Collections035GameTests","fr.koka.sanctuary.gametest.Notifications034GameTests","fr.koka.sanctuary.gametest.Menus033GameTests","${gametest_entrypoint}", "fr.koka.sanctuary.gametest.Swamp27WorldGameTests", "fr.koka.sanctuary.gametest.Caves28WorldGameTests", "fr.koka.sanctuary.gametest.Terraces29WorldGameTests", "fr.koka.sanctuary.gametest.River30WorldGameTests", "fr.koka.sanctuary.gametest.Transit29WorldGameTests", "fr.koka.sanctuary.gametest.Alpha28NativeStructuresGameTests", "fr.koka.sanctuary.gametest.Village306GameTests", "fr.koka.sanctuary.gametest.Drained307GameTests", "fr.koka.sanctuary.gametest.BlockKnowledgeGameTests", "fr.koka.sanctuary.gametest.CensusGameTestsTerrain", "fr.koka.sanctuary.gametest.CensusStockGameTests", "fr.koka.sanctuary.gametest.MaterialActivityGameTests", "fr.koka.sanctuary.gametest.AncientExpeditions001GameTests", "fr.koka.sanctuary.gametest.Progression003GameTests", "fr.koka.sanctuary.gametest.Atlas005GameTests", "fr.koka.sanctuary.gametest.Demeure006GameTests", "fr.koka.sanctuary.gametest.Mining008GameTests", "fr.koka.sanctuary.gametest.Building009GameTests", "fr.koka.sanctuary.gametest.Inventory010GameTests", "fr.koka.sanctuary.gametest.Cycle011GameTests", "fr.koka.sanctuary.gametest.Inventory012GameTests", "fr.koka.sanctuary.gametest.Inventory014GameTests", "fr.koka.sanctuary.gametest.Tutorial016GameTests", "fr.koka.sanctuary.gametest.Movement017GameTests", "fr.koka.sanctuary.gametest.Accessory018GameTests", "fr.koka.sanctuary.gametest.Companion019GameTests", "fr.koka.sanctuary.gametest.Companion032GameTests", "fr.koka.sanctuary.gametest.Recipes024GameTests", "fr.koka.sanctuary.gametest.Carry027GameTests", "fr.koka.sanctuary.gametest.Familiar029GameTests", "fr.koka.sanctuary.gametest.Dynamic030GameTests", "fr.koka.sanctuary.gametest.GraveInventory039GameTests", "fr.koka.sanctuary.gametest.FoodKnowledge039GameTests", "fr.koka.sanctuary.gametest.OperatorStars040GameTests", "fr.koka.sanctuary.gametest.Starter039GameTests", "fr.koka.sanctuary.gametest.Horde167GameTests", "fr.koka.sanctuary.gametest.Horde168GameTests", "fr.koka.sanctuary.gametest.Horde169GameTests", "fr.koka.sanctuary.gametest.Horde170GameTests", "fr.koka.sanctuary.gametest.Horde171GameTests", "fr.koka.sanctuary.gametest.Horde172GameTests", "fr.koka.sanctuary.gametest.Waystone239GameTests"],
"fabric-gametest": ["fr.koka.sanctuary.gametest.Hunter286GameTests", "fr.koka.sanctuary.gametest.Smoker285GameTests", "fr.koka.sanctuary.gametest.Boomer284GameTests", "fr.koka.sanctuary.gametest.Spitter283GameTests", "fr.koka.sanctuary.gametest.Charger282GameTests", "fr.koka.sanctuary.gametest.Infected280GameTests", "fr.koka.sanctuary.gametest.Menu275GameTests", "fr.koka.sanctuary.gametest.ClaimMap273GameTests", "fr.koka.sanctuary.gametest.ChunkClaims272GameTests", "fr.koka.sanctuary.gametest.Inhabitant271GameTests","fr.koka.sanctuary.gametest.Property270GameTests","fr.koka.sanctuary.gametest.Providence269GameTests","fr.koka.sanctuary.gametest.Providence268GameTests","fr.koka.sanctuary.gametest.Providence265GameTests","fr.koka.sanctuary.gametest.Insect245GameTests","fr.koka.sanctuary.gametest.Forgerie238GameTests","fr.koka.sanctuary.gametest.Aptitudes236GameTests","fr.koka.sanctuary.gametest.Diagonal231GameTests", "fr.koka.sanctuary.gametest.Diagonal230GameTests", "fr.koka.sanctuary.gametest.Diagonal229GameTests", "fr.koka.sanctuary.gametest.Diagonal228GameTests", "fr.koka.sanctuary.gametest.Diagonal227GameTests", "fr.koka.sanctuary.gametest.Diagonal226GameTests", "fr.koka.sanctuary.gametest.Diagonal225GameTests", "fr.koka.sanctuary.gametest.East224GameTests", "fr.koka.sanctuary.gametest.East223GameTests", "fr.koka.sanctuary.gametest.YellowRock222GameTests", "fr.koka.sanctuary.gametest.West221GameTests", "fr.koka.sanctuary.gametest.West220GameTests", "fr.koka.sanctuary.gametest.West219GameTests", "fr.koka.sanctuary.gametest.West218GameTests", "fr.koka.sanctuary.gametest.West217GameTests", "fr.koka.sanctuary.gametest.Base204GameTests", "fr.koka.sanctuary.gametest.North205GameTests", "fr.koka.sanctuary.gametest.Palace173GameTests", "fr.koka.sanctuary.gametest.Plans072GameTests", "fr.koka.sanctuary.gametest.Starter052GameTests", "fr.koka.sanctuary.gametest.Dispenser051GameTests", "fr.koka.sanctuary.gametest.Head050GameTests", "fr.koka.sanctuary.gametest.Poultry048GameTests", "fr.koka.sanctuary.gametest.StarInspection047GameTests", "fr.koka.sanctuary.gametest.SkyFlight046GameTests", "fr.koka.sanctuary.gametest.GravesFood038GameTests","fr.koka.sanctuary.gametest.Realtime037GameTests","fr.koka.sanctuary.gametest.Corrections036GameTests","fr.koka.sanctuary.gametest.Collections035GameTests","fr.koka.sanctuary.gametest.Notifications034GameTests","fr.koka.sanctuary.gametest.Menus033GameTests","${gametest_entrypoint}", "fr.koka.sanctuary.gametest.Swamp27WorldGameTests", "fr.koka.sanctuary.gametest.Caves28WorldGameTests", "fr.koka.sanctuary.gametest.Terraces29WorldGameTests", "fr.koka.sanctuary.gametest.River30WorldGameTests", "fr.koka.sanctuary.gametest.Transit29WorldGameTests", "fr.koka.sanctuary.gametest.Alpha28NativeStructuresGameTests", "fr.koka.sanctuary.gametest.Village306GameTests", "fr.koka.sanctuary.gametest.Drained307GameTests", "fr.koka.sanctuary.gametest.BlockKnowledgeGameTests", "fr.koka.sanctuary.gametest.CensusGameTestsTerrain", "fr.koka.sanctuary.gametest.CensusStockGameTests", "fr.koka.sanctuary.gametest.MaterialActivityGameTests", "fr.koka.sanctuary.gametest.AncientExpeditions001GameTests", "fr.koka.sanctuary.gametest.Progression003GameTests", "fr.koka.sanctuary.gametest.Atlas005GameTests", "fr.koka.sanctuary.gametest.Demeure006GameTests", "fr.koka.sanctuary.gametest.Mining008GameTests", "fr.koka.sanctuary.gametest.Building009GameTests", "fr.koka.sanctuary.gametest.Inventory010GameTests", "fr.koka.sanctuary.gametest.Cycle011GameTests", "fr.koka.sanctuary.gametest.Inventory012GameTests", "fr.koka.sanctuary.gametest.Inventory014GameTests", "fr.koka.sanctuary.gametest.Tutorial016GameTests", "fr.koka.sanctuary.gametest.Movement017GameTests", "fr.koka.sanctuary.gametest.Accessory018GameTests", "fr.koka.sanctuary.gametest.Companion019GameTests", "fr.koka.sanctuary.gametest.Companion032GameTests", "fr.koka.sanctuary.gametest.Recipes024GameTests", "fr.koka.sanctuary.gametest.Carry027GameTests", "fr.koka.sanctuary.gametest.Familiar029GameTests", "fr.koka.sanctuary.gametest.Dynamic030GameTests", "fr.koka.sanctuary.gametest.GraveInventory039GameTests", "fr.koka.sanctuary.gametest.FoodKnowledge039GameTests", "fr.koka.sanctuary.gametest.OperatorStars040GameTests", "fr.koka.sanctuary.gametest.Starter039GameTests", "fr.koka.sanctuary.gametest.Horde167GameTests", "fr.koka.sanctuary.gametest.Horde168GameTests", "fr.koka.sanctuary.gametest.Horde169GameTests", "fr.koka.sanctuary.gametest.Horde170GameTests", "fr.koka.sanctuary.gametest.Horde171GameTests", "fr.koka.sanctuary.gametest.Horde172GameTests", "fr.koka.sanctuary.gametest.Waystone239GameTests"],
"fabric-client-gametest": ["fr.koka.sanctuary.gametest.SanctuaryClientRenderTests"]
},
"mixins": ["sanctuary-gametest.mixins.json"],
@@ -45,6 +45,7 @@ public final class SanctuaryMod implements ModInitializer {
public void onInitialize() {
fr.koka.sanctuary.property.PropertyService.register();
fr.koka.sanctuary.insects.Insects.register();
fr.koka.sanctuary.infected.Infected.register();
fr.koka.sanctuary.waystone.Waystones.register();
fr.koka.sanctuary.zipline.Ziplines.register();
fr.koka.sanctuary.palace.OriginBlock.register();
@@ -9,7 +9,7 @@ public record AtlasMarker(String id,int x,Integer y,int z,String kind,String spr
|| kind.equals("banner")&&y==null || name==null || name.length()>96) throw new IllegalArgumentException("Atlas marker");
}
public boolean inView(AtlasPayloads.Request request) {
long minX=((long)request.centerX()-AtlasPayloads.SIDE/2)*16,minZ=((long)request.centerZ()-AtlasPayloads.SIDE/2)*16;
return x>=minX&&z>=minZ&&x<minX+AtlasPayloads.PIXELS&&z<minZ+AtlasPayloads.PIXELS&&(y==null||y<=request.altitude());
long minX=request.minBlockX(),minZ=request.minBlockZ();
return x>=minX&&z>=minZ&&x<minX+request.blockSpan()&&z<minZ+request.blockSpan()&&(y==null||y<=request.altitude());
}
}
@@ -10,13 +10,21 @@ import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
public final class AtlasPayloads {
public static final int SIDE=64, PIXELS=SIDE*16, BATCH=64;
private AtlasPayloads() { }
public record Request(int token,int centerX,int centerZ,boolean operator,int altitude) implements CustomPacketPayload {
public record Request(int token,int centerX,int centerZ,boolean operator,int altitude,int detail) implements CustomPacketPayload {
public static final Type<Request> TYPE=new Type<>(SanctuaryMod.id("atlas_request"));
public static final StreamCodec<RegistryFriendlyByteBuf,Request> CODEC=CustomPacketPayload.codec(
(p,b) -> {b.writeInt(p.token);b.writeInt(p.centerX);b.writeInt(p.centerZ);b.writeBoolean(p.operator);b.writeVarInt(p.altitude);},
b -> new Request(b.readInt(),b.readInt(),b.readInt(),b.readBoolean(),b.readVarInt()));
(p,b) -> {b.writeInt(p.token);b.writeInt(p.centerX);b.writeInt(p.centerZ);b.writeBoolean(p.operator);b.writeVarInt(p.altitude);b.writeVarInt(p.detail);},
b -> new Request(b.readInt(),b.readInt(),b.readInt(),b.readBoolean(),b.readVarInt(),b.readVarInt()));
public Request(int token,int centerX,int centerZ,boolean operator) {this(token,centerX,centerZ,operator,AtlasAltitude.SURFACE);}
public Request { new AtlasStore.Key(centerX,centerZ);AtlasAltitude.validate(altitude); }
public Request(int token,int centerX,int centerZ,boolean operator,int altitude) {this(token,centerX,centerZ,operator,altitude,0);}
public Request { new AtlasStore.Key(centerX,centerZ);AtlasAltitude.validate(altitude);if(detail<0 || detail>AtlasPyramid.MAX_LEVEL)throw new IllegalArgumentException("Atlas detail level"); }
public int step() {return 1<<detail;}
public int tileCenterX() {return Math.floorDiv(centerX,step());}
public int tileCenterZ() {return Math.floorDiv(centerZ,step());}
public long minBlockX() {return ((long)tileCenterX()-SIDE/2)*16*step();}
public long minBlockZ() {return ((long)tileCenterZ()-SIDE/2)*16*step();}
public long blockSpan() {return (long)PIXELS*step();}
public boolean containsTile(int x,int z) {return x>=tileCenterX()-SIDE/2 && x<tileCenterX()+SIDE/2 && z>=tileCenterZ()-SIDE/2 && z<tileCenterZ()+SIDE/2;}
public Type<Request> type() { return TYPE; }
}
public record Response(int token,boolean reset,boolean done,boolean canOperate,boolean operator,
@@ -32,10 +32,10 @@ public record AtlasPlayersPayload(int token,List<Player> players) implements Cus
public Type<AtlasPlayersPayload> type(){return TYPE;}
public static AtlasPlayersPayload snapshot(ServerPlayer viewer,AtlasPayloads.Request request) {
if(!AtlasService.canRead(viewer,request.operator()))return new AtlasPlayersPayload(request.token(),List.of());
double minX=((long)request.centerX()-AtlasPayloads.SIDE/2)*16,minZ=((long)request.centerZ()-AtlasPayloads.SIDE/2)*16;
double minX=request.minBlockX(),minZ=request.minBlockZ();
var players=viewer.level().getServer().getPlayerList().getPlayers().stream()
.filter(p->p.level()==viewer.level() && p.isAlive() && (!p.isSpectator() || p==viewer))
.filter(p->p.getX()>=minX && p.getZ()>=minZ && p.getX()<minX+AtlasPayloads.PIXELS && p.getZ()<minZ+AtlasPayloads.PIXELS)
.filter(p->p.getX()>=minX && p.getZ()>=minZ && p.getX()<minX+request.blockSpan() && p.getZ()<minZ+request.blockSpan())
.sorted(Comparator.comparingDouble(p->p.distanceToSqr(viewer))).limit(LIMIT)
.map(p->new Player(p.getUUID(),p.getGameProfile().name(),p.getX(),p.getZ())).toList();
return new AtlasPlayersPayload(request.token(),players);
@@ -0,0 +1,61 @@
package fr.koka.sanctuary.atlas;
import java.util.*;
/** Disposable mip levels of one authorized survey. Never persisted or filled from unknown terrain. */
public final class AtlasPyramid {
public static final int MAX_LEVEL=16;
private final List<Map<AtlasStore.Key,byte[]>> levels=new ArrayList<>();
private final List<Set<AtlasStore.Key>> dirty=new ArrayList<>();
public AtlasPyramid(Map<AtlasStore.Key,byte[]> source) {
levels.add(source);dirty.add(new HashSet<>());
}
public void changed(AtlasStore.Key key) {
for(int level=1;level<levels.size();level++) {
key=parent(key);dirty.get(level).add(key);
}
}
Map<AtlasStore.Key,byte[]> view(int level) {
if(level<0 || level>MAX_LEVEL)throw new IllegalArgumentException("Atlas detail level");
for(int i=1;i<=level;i++) {
if(i==levels.size()) {
levels.add(new HashMap<>());dirty.add(new HashSet<>());
for(var key:levels.get(i-1).keySet())dirty.get(i).add(parent(key));
}
var target=levels.get(i);
for(var key:dirty.get(i))target.put(key,reduce(levels.get(i-1),key));
dirty.get(i).clear();
}
return Collections.unmodifiableMap(levels.get(level));
}
private static AtlasStore.Key parent(AtlasStore.Key key) {
return new AtlasStore.Key(Math.floorDiv(key.x(),2),Math.floorDiv(key.z(),2));
}
private static byte[] reduce(Map<AtlasStore.Key,byte[]> source,AtlasStore.Key key) {
byte[] out=new byte[256];
for(int dz=0;dz<2;dz++)for(int dx=0;dx<2;dx++) {
int cx=key.x()*2+dx,cz=key.z()*2+dz;
if(Math.abs((long)cx)>1875000 || Math.abs((long)cz)>1875000)continue;
var child=source.get(new AtlasStore.Key(cx,cz));
if(child==null)continue;
for(int z=0;z<8;z++)for(int x=0;x<8;x++) {
int p=z*32+x*2;
out[(dz*8+z)*16+dx*8+x]=representative(child[p],child[p+1],child[p+16],child[p+17]);
}
}
return out;
}
/** Prefer known terrain over surveyed void, then the most common packed map colour. */
private static byte representative(byte a,byte b,byte c,byte d) {
byte best=a;int score=score(a,a,b,c,d),next=score(b,a,b,c,d);
if(next>score){best=b;score=next;}next=score(c,a,b,c,d);
if(next>score){best=c;score=next;}next=score(d,a,b,c,d);
if(next>score)best=d;
return best;
}
private static int score(byte value,byte a,byte b,byte c,byte d) {
if(value==0)return -1;
return (Byte.toUnsignedInt(value)>=4?4:0)+(value==a?1:0)+(value==b?1:0)+(value==c?1:0)+(value==d?1:0);
}
}
@@ -34,6 +34,8 @@ public final class AtlasService {
int lastSnapshot;
final ArrayDeque<AtlasStore.Key> revisits=new ArrayDeque<>();
final Map<Integer,AtlasStore> layers=new LinkedHashMap<>();
final Map<AtlasStore.Key,byte[]> inspection=new HashMap<>();
AtlasPyramid inspectionPyramid=new AtlasPyramid(inspection);
}
private record Job(AtlasPayloads.Request request,ArrayDeque<AtlasStore.Key> remaining) { }
private AtlasService() { }
@@ -87,7 +89,7 @@ public final class AtlasService {
try {for (var store:state.layers.values()) store.save();}
catch (Exception error) {state.failed=true;unsubscribe(state);SanctuaryMod.LOGGER.error("Could not save atlas (file preserved)",error);}
}
private static void unsubscribe(State state) {state.job=null;state.viewport=null;state.updates.clear();}
private static void unsubscribe(State state) {state.job=null;state.viewport=null;state.updates.clear();state.inspection.clear();state.inspectionPyramid=new AtlasPyramid(state.inspection);}
private static boolean canObserve(ServerPlayer player) {
var session=ProgressionService.session(player.level().getServer());
return session!=null && session.healthy && ProgressEffects.get(player)!=null
@@ -134,9 +136,10 @@ public final class AtlasService {
for (var entry:samples.entrySet()) {
if (!state.layers.get(entry.getKey()).observe(entry.getValue())) continue;
var viewport=state.viewport;
if (viewport!=null && !viewport.operator() && viewport.altitude()==entry.getKey()
&& inside(pos.x(),pos.z(),viewport.centerX()-AtlasPayloads.SIDE/2,viewport.centerZ()-AtlasPayloads.SIDE/2))
state.updates.add(new AtlasStore.Key(pos.x(),pos.z()));
if (viewport!=null && !viewport.operator() && viewport.altitude()==entry.getKey()) {
int x=Math.floorDiv(pos.x(),viewport.step()),z=Math.floorDiv(pos.z(),viewport.step());
if(viewport.containsTile(x,z))state.updates.add(new AtlasStore.Key(x,z));
}
}
}
private static void request(ServerPlayer player,AtlasPayloads.Request request) {
@@ -144,6 +147,7 @@ public final class AtlasService {
if (session==null || !session.healthy) {reply(player,request,true,true,"unavailable",List.of());return;}
var state=state(player);
if (request.token()==0) {unsubscribe(state);fr.koka.sanctuary.property.ChunkPropertyService.closeOverlay(player);return;}
if(request.detail()>0)fr.koka.sanctuary.property.ChunkPropertyService.closeOverlay(player);
int tick=player.level().getServer().getTickCount();
unsubscribe(state);
long now=System.nanoTime();
@@ -155,24 +159,21 @@ public final class AtlasService {
}
var survey=state.layers.get(request.altitude());
var keys=new ArrayList<AtlasStore.Key>();
int minX=request.centerX()-AtlasPayloads.SIDE/2,minZ=request.centerZ()-AtlasPayloads.SIDE/2;
if (request.operator()) {
for (var chunk:LoadedChunks.full(player.level())) {
var p=chunk.getPos();
if (inside(p.x(),p.z(),minX,minZ)) keys.add(new AtlasStore.Key(p.x(),p.z()));
if (request.containsTile(Math.floorDiv(p.x(),request.step()),Math.floorDiv(p.z(),request.step()))) keys.add(new AtlasStore.Key(p.x(),p.z()));
}
} else {
for (int z=minZ;z<minZ+AtlasPayloads.SIDE;z++) for (int x=minX;x<minX+AtlasPayloads.SIDE;x++) {
if (Math.abs((long)x)<=1875000 && Math.abs((long)z)<=1875000 && survey.contains(x,z)) keys.add(new AtlasStore.Key(x,z));
}
for(var key:survey.view(request.detail()).keySet())if(request.containsTile(key.x(),key.z()))keys.add(key);
}
keys.sort(Comparator.comparingLong(k -> Math.abs((long)k.x()-request.centerX())+Math.abs((long)k.z()-request.centerZ())));
int cx=request.operator()?request.centerX():request.tileCenterX(),cz=request.operator()?request.centerZ():request.tileCenterZ();
keys.sort(Comparator.comparingLong(k -> Math.abs((long)k.x()-cx)+Math.abs((long)k.z()-cz)));
state.viewport=request;
reply(player,request,true,keys.isEmpty(),survey.full() ? "full" : "ready",List.of());
state.lastSnapshot=tick;
if (!keys.isEmpty()) state.job=new Job(request,new ArrayDeque<>(keys));
}
private static boolean inside(int x,int z,int minX,int minZ) {return x>=minX && x<minX+AtlasPayloads.SIDE && z>=minZ && z<minZ+AtlasPayloads.SIDE;}
private static void tick(MinecraftServer server) {
var session=ProgressionService.session(server);
if (session==null || !session.healthy) return;
@@ -239,16 +240,25 @@ public final class AtlasService {
while (budget>0 && updates.hasNext()) {
var key=updates.next();updates.remove();budget--;
sent.add(key);
var tile=state.layers.get(request.altitude()).get(key.x(),key.z());
var tile=state.layers.get(request.altitude()).get(key.x(),key.z(),request.detail());
if (tile!=null) tiles.add(tile);
}
while (budget-->0 && job!=null && !job.remaining.isEmpty()) {
var key=job.remaining.removeFirst();
if (!sent.add(key)) {budget++;continue;}
var tile=request.operator() ? AtlasSampler.sample(player.level(),key.x(),key.z(),request.altitude())
: state.layers.get(request.altitude()).get(key.x(),key.z());
: state.layers.get(request.altitude()).get(key.x(),key.z(),request.detail());
if (tile!=null) tiles.add(tile);
}
if(request.operator() && request.detail()>0 && !tiles.isEmpty()) {
var changed=new LinkedHashSet<AtlasStore.Key>();
for(var tile:tiles) {
var key=new AtlasStore.Key(tile.x(),tile.z());state.inspection.put(key,tile.colors());state.inspectionPyramid.changed(key);
changed.add(new AtlasStore.Key(Math.floorDiv(key.x(),request.step()),Math.floorDiv(key.z(),request.step())));
}
var view=state.inspectionPyramid.view(request.detail());tiles.clear();
for(var key:changed)tiles.add(new AtlasStore.Tile(key.x(),key.z(),view.get(key)));
}
boolean done=job==null || job.remaining.isEmpty();
if (job!=null || !tiles.isEmpty()) reply(player,request,false,done,state.layers.get(request.altitude()).full() ? "full" : "ready",tiles);
if (done) state.job=null;
@@ -259,8 +269,8 @@ public final class AtlasService {
if (ServerPlayNetworking.canSend(player,AtlasPayloads.Response.TYPE)) ServerPlayNetworking.send(player,
new AtlasPayloads.Response(request.token(),reset,done,canOperate(player),request.operator(),status,tiles));
if (reset && (status.equals("ready") || status.equals("full"))) snapshot(player,request);
if (!tiles.isEmpty()) fr.koka.sanctuary.property.ChunkPropertyService.mapTiles(player,request.token(),tiles);
if (!tiles.isEmpty() && ServerPlayNetworking.canSend(player,AtlasDemeurePayload.TYPE)) ServerPlayNetworking.send(player,
if (request.detail()==0 && !tiles.isEmpty()) fr.koka.sanctuary.property.ChunkPropertyService.mapTiles(player,request.token(),tiles);
if (request.detail()==0 && !tiles.isEmpty() && ServerPlayNetworking.canSend(player,AtlasDemeurePayload.TYPE)) ServerPlayNetworking.send(player,
new AtlasDemeurePayload(request.token(),AtlasDemeurePayload.forTiles(player,request.operator(),tiles)));
}
private static void snapshot(ServerPlayer player,AtlasPayloads.Request request) {
@@ -10,8 +10,8 @@ import java.util.*;
/** One inhabitant's Overworld observations. Never populated from another player's atlas. */
public final class AtlasStore {
public static final int MAX_TILES=16384;
private static final int MAX_BYTES=12*1024*1024;
public static final int MAX_TILES=131072;
private static final int MAX_BYTES=64*1024*1024;
public record Key(int x,int z) {
public Key { if (Math.abs((long)x)>1875000 || Math.abs((long)z)>1875000) throw new IllegalArgumentException("Atlas coordinates"); }
}
@@ -23,6 +23,7 @@ public final class AtlasStore {
private final UUID owner;
private final long seed;
private final Map<Key,byte[]> tiles=new LinkedHashMap<>();
private final AtlasPyramid pyramid=new AtlasPyramid(tiles);
private byte[] previous;
private boolean dirty;
@@ -53,6 +54,8 @@ public final class AtlasStore {
public boolean dirty() { return dirty; }
public boolean contains(int x,int z) { return tiles.containsKey(new Key(x,z)); }
public Tile get(int x,int z) { var data=tiles.get(new Key(x,z));return data==null ? null : new Tile(x,z,data); }
Map<Key,byte[]> view(int detail) {return pyramid.view(detail);}
public Tile get(int x,int z,int detail) {var data=view(detail).get(new Key(x,z));return data==null?null:new Tile(x,z,data);}
public boolean observe(Tile tile) {
var key=new Key(tile.x(),tile.z());
if (!tiles.containsKey(key) && full()) return false;
@@ -63,7 +66,7 @@ public final class AtlasStore {
known|=colors[i]!=0;
}
if (!known || Arrays.equals(old,colors)) return false;
tiles.put(key,colors);dirty=true;return true;
tiles.put(key,colors);pyramid.changed(key);dirty=true;return true;
}
public void save() throws IOException {
if (!dirty) return;
@@ -28,7 +28,9 @@ public final class CompanionTooltips {
}
public static void register(){
ItemTooltipCallback.EVENT.register((stack,context,flags,lines)->{
var type=type(stack);if(type==null||!ProgressionClient.active())return;
var type=type(stack);
var profile=fr.koka.sanctuary.familiar.FamiliarCatalog.bundled().forEgg(stack);
if((type==null&&profile==null)||!ProgressionClient.active())return;
boolean details=(CompanionClient.studied||ProgressionClient.operator);
var extra=new java.util.ArrayList<Component>();
extra.add(rarity(stack).label().copy().withStyle(color(stack)));
@@ -47,7 +49,6 @@ public final class CompanionTooltips {
extra.add(Component.translatable("sanctuary.familiar.nautilus.mount.hint").withStyle(ChatFormatting.AQUA));
if(fr.koka.sanctuary.cosmetics.FamiliarMountTraits.walksOnLava(stack)&&fr.koka.sanctuary.cosmetics.FamiliarSize.colossal(stack))
extra.add(Component.translatable("sanctuary.familiar.strider.mount.hint").withStyle(ChatFormatting.GOLD));
var profile=fr.koka.sanctuary.familiar.FamiliarCatalog.bundled().forEgg(stack);
if(profile!=null){
if(FamiliarClient.profile!=null&&FamiliarClient.profile.id().equals(profile.id()))profile=FamiliarClient.profile;
var initiative=profile.initiative();
@@ -0,0 +1,8 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.lighting.DynamicLightField;
/** Render-thread capture handed to a terrain worker with its copied block data. */
public interface DynamicLightContext extends DynamicLightRegion {
void sanctuary$captureLights(DynamicLightField.Snapshot snapshot);
}
@@ -56,6 +56,7 @@ public final class FamiliarRenderer extends EntityRenderer<FamiliarEntity,Famili
mob.xOld=pet.xOld;mob.yOld=pet.yOld;mob.zOld=pet.zOld;
mob.xo=pet.xo;mob.yo=pet.yo;mob.zo=pet.zo;
mob.yRotO=pet.yRotO;mob.xRotO=pet.xRotO;
if(mob instanceof fr.koka.sanctuary.infected.HunterEntity)mob.setPose(pet.getPose());
mob.setOnGround(pet.onGround());mob.setDeltaMovement(pet.getDeltaMovement());
mob.yBodyRot=pet.yBodyRot;mob.yBodyRotO=pet.yBodyRotO;mob.yHeadRot=pet.yHeadRot;mob.yHeadRotO=pet.yHeadRotO;
mob.tickCount=pet.tickCount;
@@ -64,6 +65,10 @@ public final class FamiliarRenderer extends EntityRenderer<FamiliarEntity,Famili
mob.setAttached(fr.koka.sanctuary.cosmetics.AccessoryService.APPEARANCE,pet.getAttachedOrElse(fr.koka.sanctuary.cosmetics.AccessoryService.APPEARANCE,fr.koka.sanctuary.cosmetics.AccessoryService.Appearance.EMPTY));
var renderer=entityRenderDispatcher.getRenderer(mob);
var appearance=renderer.createRenderState(mob,partialTick);
if(appearance instanceof InfectedRenderer.State zombie) {
var identity=fr.koka.sanctuary.familiar.FamiliarIdentity.get(pet.egg());
zombie.skin=ZombieSkins.index(identity==null?pet.getUUID():identity);
}
// Render the real companion's name separately, at its miniature scale.
appearance.nameTag=null;
// Giant native models have an eye probe above the actual miniature. Use the real
@@ -44,7 +44,7 @@ public final class FamiliarScreen extends BlocodexFrame {
boolean workMode=FamiliarClient.view!=null&&FamiliarClient.view.work();
var selected=bond.variant()?profile.variant():profile.signature();
var type=CompanionTooltips.type(egg);
Component workName=CompanionClient.label(type.id()+".active_name");
Component workName=type==null?FamiliarBattle.label("no_work"):CompanionClient.label(type.id()+".active_name");
layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("selected_power",workMode?workName:FamiliarClient.name(selected)),font).setMaxWidth(width));
layout.addChild(new StringWidget(FamiliarBattle.label("technique"),font));
var technique=layout.addChild(LinearLayout.horizontal().spacing(4));
@@ -52,14 +52,16 @@ public final class FamiliarScreen extends BlocodexFrame {
var signature=technique.addChild(Button.builder(FamiliarClient.name(profile.signature()),b->FamiliarClient.action("signature")).width(half).build());signature.active=!combat&&(workMode||bond.variant());
var variant=technique.addChild(Button.builder(FamiliarClient.name(profile.variant()),b->FamiliarClient.action("variant")).width(half).build());
variant.active=!combat&&(workMode||!bond.variant())&&bond.learned()&&CompanionClient.record!=null&&CompanionClient.record.unlocked();
var work=layout.addChild(Button.builder(FamiliarBattle.label(workMode?"work_selected":"work"),b->FamiliarClient.action("work")).width(width).build());
boolean unlocked=ProgressionClient.operator||(CompanionClient.record!=null&&CompanionClient.record.unlocked());
work.active=!combat&&!workMode&&unlocked;
work.setTooltip(Tooltip.create(FamiliarBattle.label(unlocked?"work_help":"work_locked",CompanionClient.controls())));
if(!unlocked)layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("work_locked"),font).setMaxWidth(width));
if(type!=null){
var work=layout.addChild(Button.builder(FamiliarBattle.label(workMode?"work_selected":"work"),b->FamiliarClient.action("work")).width(width).build());
work.active=!combat&&!workMode&&unlocked;
work.setTooltip(Tooltip.create(FamiliarBattle.label(unlocked?"work_help":"work_locked",CompanionClient.controls())));
if(!unlocked)layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("work_locked"),font).setMaxWidth(width));
}
if(workMode){
layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("work_help",CompanionClient.controls()),font).setMaxWidth(width));
if(CompanionClient.studied)layout.addChild(new MultiLineTextWidget(CompanionClient.label(type.id()+".active"),font).setMaxWidth(width));
if(type!=null&&CompanionClient.studied)layout.addChild(new MultiLineTextWidget(CompanionClient.label(type.id()+".active"),font).setMaxWidth(width));
}else{
layout.addChild(new MultiLineTextWidget(FamiliarClient.help(selected),font).setMaxWidth(width));
layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("technique_stats",selected.windup()/20F,selected.cooldown()/20F),font).setMaxWidth(width));
@@ -0,0 +1,32 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.infected.*;
import net.minecraft.client.model.monster.zombie.*;
import net.minecraft.client.model.geom.ModelLayers;
import net.minecraft.client.renderer.entity.*;
import net.minecraft.client.renderer.entity.state.ZombieRenderState;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.monster.zombie.Zombie;
/** Shared random clothes, native zombie arms and the Hunter's crouched ground posture. */
public final class InfectedRenderer extends AbstractZombieRenderer<Zombie,InfectedRenderer.State,ZombieModel<InfectedRenderer.State>> {
public static final Identifier TEXTURE=ZombieSkins.texture(0,false);
public static final class State extends ZombieRenderState {
public int skin;
}
public InfectedRenderer(EntityRendererProvider.Context context) {
super(context,new ZombieModel<State>(context.bakeLayer(ModelLayers.ZOMBIE)),
new BabyZombieModel<State>(context.bakeLayer(ModelLayers.ZOMBIE_BABY)),
ArmorModelSet.bake(ModelLayers.ZOMBIE_ARMOR,context.getModelSet(),ZombieModel<State>::new),
ArmorModelSet.bake(ModelLayers.ZOMBIE_BABY_ARMOR,context.getModelSet(),BabyZombieModel<State>::new));
}
@Override public State createRenderState() { return new State(); }
@Override public Identifier getTextureLocation(State state) { return ZombieSkins.texture(state.skin,state.isBaby); }
@Override public void extractRenderState(Zombie entity,State state,float partialTick) {
super.extractRenderState(entity,state,partialTick);
state.skin=ZombieSkins.index(entity.getUUID());
// A stationary spawn need not receive a movement packet carrying the grounded bit.
if(entity instanceof HunterEntity)state.isCrouching=!entity.isInWater()&&!entity.isPassenger()
&&(entity.onGround()||!entity.level().noBlockCollision(entity,entity.getBoundingBox().deflate(.01).move(0,-.06,0)));
}
}
@@ -29,6 +29,7 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
private double centerX,centerZ,zoom=2;
private int token,originX,originZ,focusX,focusY,focusSize,refresh,tiles;
private int altitude=AtlasAltitude.SURFACE;
private int detail;
private Button altitudeUp,altitudeDown;
private boolean operator,canOperate,dragging,dirty,complete,navigable;
private boolean grid=false,footprints=false;
@@ -95,6 +96,7 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
centerX=initialTarget.x()+.5;centerZ=initialTarget.z()+.5;request();
} else recenter();
}
else refresh=6;
if(propertyOpen)rebuildPropertyMenu();
}
private void clear() {
@@ -128,7 +130,11 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
if (minecraft.player==null || minecraft.level==null) return;
centerX=Math.clamp(centerX,-29999488,29999488);centerZ=Math.clamp(centerZ,-29999488,29999488);
int chunkX=Math.floorDiv((int)Math.floor(centerX),16),chunkZ=Math.floorDiv((int)Math.floor(centerZ),16);
int newX=(chunkX-AtlasPayloads.SIDE/2)*16,newZ=(chunkZ-AtlasPayloads.SIDE/2)*16;
int nextDetail=detailForZoom();
if(nextDetail!=detail){detail=nextDetail;clear();}
int step=1<<detail;
int newX=(Math.floorDiv(chunkX,step)-AtlasPayloads.SIDE/2)*16*step;
int newZ=(Math.floorDiv(chunkZ,step)-AtlasPayloads.SIDE/2)*16*step;
// Reproject retained tiles instead of blanking the window on every drag release.
if (newX!=originX || newZ!=originZ) {
originX=newX;originZ=newZ;
@@ -140,7 +146,7 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
pendingFootprintBatch=java.util.List.of();
token=AtlasClient.nextToken();claimOverlay.begin(token);complete=false;refresh=1600;
dimension=minecraft.level.dimension().identifier().toString();
AtlasClient.send(new AtlasPayloads.Request(token,chunkX,chunkZ,operator,altitude));
AtlasClient.send(new AtlasPayloads.Request(token,chunkX,chunkZ,operator,altitude,detail));
}
public void accept(AtlasPayloads.Response response) {
if (texture==null || response.token()!=token || minecraft.level==null
@@ -163,9 +169,9 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
if (!response.tiles().isEmpty()) pendingFootprintBatch=response.tiles().stream()
.map(tile -> new fr.koka.sanctuary.atlas.AtlasStore.Key(tile.x(),tile.z())).toList();
for (var tile:response.tiles()) {
int x=tile.x()*16-originX,z=tile.z()*16-originZ;
int x=tile.x()*16-Math.floorDiv(originX,1<<detail),z=tile.z()*16-Math.floorDiv(originZ,1<<detail);
if (x<0 || z<0 || x+16>SIZE || z+16>SIZE) continue;
claimOverlay.observe(tile.x(),tile.z());
if(detail==0)claimOverlay.observe(tile.x(),tile.z());
var key=new fr.koka.sanctuary.atlas.AtlasStore.Key(tile.x(),tile.z());
byte[] colors=tile.colors(),before=surveyed.put(key,colors);
if (!java.util.Arrays.equals(before,colors)) {
@@ -181,7 +187,7 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
if (complete && !wasComplete) refresh=operator ? 100 : 1200;
}
private void paintTile(fr.koka.sanctuary.atlas.AtlasStore.Key key,byte[] colors) {
int x=key.x()*16-originX,z=key.z()*16-originZ;
int x=key.x()*16-Math.floorDiv(originX,1<<detail),z=key.z()*16-Math.floorDiv(originZ,1<<detail);
if (x<0 || z<0 || x+16>SIZE || z+16>SIZE) return;
for (int dz=0;dz<16;dz++) for (int dx=0;dx<16;dx++) {
int packed=Byte.toUnsignedInt(colors[dz*16+dx]);
@@ -206,9 +212,15 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
}
private double mapCenterY() {return focusY+focusSize/2.;}
private double clampZoom(double value) {
// At the last level the whole world must fit along the shorter window axis too.
double minimum=Math.min(width,height)/(SIZE*(double)(1<<fr.koka.sanctuary.atlas.AtlasPyramid.MAX_LEVEL));
return Math.clamp(value,minimum,8);
}
private int detailForZoom() {
double extent=Math.max(width,2*Math.max(mapCenterY(),height-mapCenterY()));
// Leave room for the viewport origin's chunk alignment (up to 16 blocks).
return Math.clamp(value,Math.min(8,Math.max(.75,extent/992.)),8);
int level=0;
while(level<fr.koka.sanctuary.atlas.AtlasPyramid.MAX_LEVEL && zoom*(1<<level)<extent/992.)level++;
return level;
}
private boolean readable() {return navigable;}
private void changeZoom(double next,double x,double y) {
@@ -226,7 +238,7 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
}
// Disabled buttons also own their hit area; clicking one must never start a map gesture.
if (overControl(event.x(),event.y())) return super.mouseClicked(event,doubleClick);
if(readable() && event.button()==InputConstants.MOUSE_BUTTON_RIGHT && inside(event.x(),event.y())){
if(detail==0 && detailForZoom()==0 && readable() && event.button()==InputConstants.MOUSE_BUTTON_RIGHT && inside(event.x(),event.y())){
int wx=(int)Math.floor(centerX+(event.x()-width/2.)/zoom),wz=(int)Math.floor(centerZ+(event.y()-mapCenterY())/zoom);
if(surveyed.containsKey(new fr.koka.sanctuary.atlas.AtlasStore.Key(wx>>4,wz>>4)))openPropertyContext(wx>>4,wz>>4,(int)event.x(),(int)event.y());
return true;
@@ -272,7 +284,7 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
graphics.enableScissor(0,0,width,height);
if (texture!=null && readable()) {
int x=screenX(originX),y=screenZ(originZ);
graphics.blit(RenderPipelines.GUI_TEXTURED,TEXTURE,x,y,0,0,(int)(SIZE*zoom),(int)(SIZE*zoom),SIZE,SIZE,SIZE,SIZE);
graphics.blit(RenderPipelines.GUI_TEXTURED,TEXTURE,x,y,0,0,(int)Math.round(SIZE*(1<<detail)*zoom),(int)Math.round(SIZE*(1<<detail)*zoom),SIZE,SIZE,SIZE,SIZE);
renderFootprints(graphics);
if (grid) renderGrid(graphics);
renderMarkers(graphics);
@@ -453,8 +465,9 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
boolean pointer=inside(mouseX,mouseY) && !overControl(mouseX,mouseY) && readable();
int wx=(int)Math.floor(centerX+(pointer ? (mouseX-width/2.)/zoom : 0));
int wz=(int)Math.floor(centerZ+(pointer ? (mouseY-mapCenterY())/zoom : 0));
String coords="X "+wx+" Z "+wz+" · "+String.format(java.util.Locale.ROOT,"%.1f×",zoom);
var state=label(operator ? "operator_status" : "inhabitant_status");
String scale=zoom>=1?String.format(java.util.Locale.ROOT,"%.1f×",zoom):label("blocks_per_pixel",String.format(java.util.Locale.ROOT,"%,.0f",1/zoom)).getString();
String coords="X "+wx+" Z "+wz+" · "+scale;
var state=label(detail>0 ? operator ? "operator_overview_status" : "overview_status" : operator ? "operator_status" : "inhabitant_status");
int textWidth=Math.min(width-24,Math.max(font.width(coords),font.width(state)));
int y=height-58;
graphics.fill(5,y-4,17+textWidth,y+24,0xA0101720);
@@ -484,6 +497,7 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
private int screenX(double world) {return (int)Math.round(width/2.+(world-centerX)*zoom);}
private int screenZ(double world) {return (int)Math.round(mapCenterY()+(world-centerZ)*zoom);}
private void renderGrid(GuiGraphicsExtractor g) {
if(16*zoom<8)return;
int minX=Math.floorDiv((int)Math.floor(centerX-width/2./zoom),16);
int maxX=Math.floorDiv((int)Math.ceil(centerX+width/2./zoom),16)+1;
int minZ=Math.floorDiv((int)Math.floor(centerZ-mapCenterY()/zoom),16);
@@ -492,12 +506,12 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
for (int z=minZ;z<=maxZ;z++) {int sy=screenZ(z*16.);g.fill(0,sy,width,sy+1,0x407A91A0);}
}
private void renderFootprints(GuiGraphicsExtractor g) {
if (footprintTexture==null) return;
if (footprintTexture==null || detail>0) return;
for (var key:footprintUpdates) paintFootprint(key);
footprintUpdates.clear();
if (footprintDirty) {footprintTexture.upload();footprintDirty=false;}
if (footprints || ChunkPropertyClient.propertiesVisible()) g.blit(RenderPipelines.GUI_TEXTURED,FOOTPRINT_TEXTURE,screenX(originX),screenZ(originZ),0,0,
(int)(SIZE*zoom),(int)(SIZE*zoom),SIZE,SIZE,SIZE,SIZE);
(int)Math.round(SIZE*(1<<detail)*zoom),(int)Math.round(SIZE*(1<<detail)*zoom),SIZE,SIZE,SIZE,SIZE);
}
private void paintFootprint(fr.koka.sanctuary.atlas.AtlasStore.Key key) {
int left=key.x()*16-originX,top=key.z()*16-originZ;
@@ -512,7 +526,7 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
}
}
private void renderFootprintHint(GuiGraphicsExtractor g,int mouseX,int mouseY) {
if (!footprints || !readable() || dragging || !inside(mouseX,mouseY) || overControl(mouseX,mouseY)) return;
if (detail>0 || !footprints || !readable() || dragging || !inside(mouseX,mouseY) || overControl(mouseX,mouseY)) return;
int wx=(int)Math.floor(centerX+(mouseX-width/2.)/zoom),wz=(int)Math.floor(centerZ+(mouseY-mapCenterY())/zoom);
var key=new fr.koka.sanctuary.atlas.AtlasStore.Key(Math.floorDiv(wx,16),Math.floorDiv(wz,16));
byte[] colors=surveyed.get(key);
@@ -531,7 +545,7 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
}
// Native client checks inspect the actual uploaded image's source pixels, including alpha.
public int renderedPixelAt(int worldX,int worldZ) {
int x=worldX-originX,z=worldZ-originZ;
int x=Math.floorDiv(worldX-originX,1<<detail),z=Math.floorDiv(worldZ-originZ,1<<detail);
return texture==null || x<0 || z<0 || x>=SIZE || z>=SIZE ? 0 : texture.getPixels().getPixel(x,z);
}
public int knownPixels() {return surveyed.values().stream().mapToInt(colors -> {int n=0;for (byte color:colors) if (color!=0) n++;return n;}).sum();}
@@ -554,6 +568,7 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
public double centerX() {return centerX;}
public double centerZ() {return centerZ;}
public double zoom() {return zoom;}
public int detail() {return detail;}
public int altitude() {return altitude;}
public java.util.List<fr.koka.sanctuary.atlas.AtlasMarker> markers() {return markers;}
public String status() {return status;}
@@ -18,6 +18,26 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
public static void registerClient() {
InsectRenderer.register();
net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry.register(
net.minecraft.world.entity.EntityTypes.ZOMBIE,InfectedRenderer::new);
net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry.register(
fr.koka.sanctuary.infected.Infected.TYPE, InfectedRenderer::new);
net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry.register(
fr.koka.sanctuary.infected.Infected.CHARGER,InfectedRenderer::new);
net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry.register(
fr.koka.sanctuary.infected.Infected.SPITTER,InfectedRenderer::new);
net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry.register(
fr.koka.sanctuary.infected.Infected.ACID_SPIT,ctx->new net.minecraft.client.renderer.entity.ThrownItemRenderer<>(ctx,.5F,true));
net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry.register(
fr.koka.sanctuary.infected.Infected.ACID_ZONE,net.minecraft.client.renderer.entity.NoopRenderer::new);
net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry.register(
fr.koka.sanctuary.infected.Infected.BOOMER,InfectedRenderer::new);
net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry.register(
fr.koka.sanctuary.infected.Infected.SMOKER,InfectedRenderer::new);
net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry.register(
fr.koka.sanctuary.infected.Infected.SMOKER_LASSO,SmokerLassoRenderer::new);
net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry.register(
fr.koka.sanctuary.infected.Infected.HUNTER,InfectedRenderer::new);
InsectSoundClient.register();
WaystoneScreen.register();
PropertyScreen.register();
@@ -0,0 +1,17 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.client.zipline.GrapplingLeashRenderUtil;
import fr.koka.sanctuary.infected.SmokerLassoEntity;
import net.minecraft.client.renderer.entity.*;
import net.minecraft.client.renderer.entity.state.ThrownItemRenderState;
/** Reuses the zipline's taut, lit native leash segments for the physical lasso. */
public final class SmokerLassoRenderer extends ThrownItemRenderer<SmokerLassoEntity> {
public SmokerLassoRenderer(EntityRendererProvider.Context context) { super(context,.65F,false); }
@Override public void extractRenderState(SmokerLassoEntity hook,ThrownItemRenderState state,float partial) {
super.extractRenderState(hook,state,partial);var owner=hook.getOwner();
var hold=owner instanceof fr.koka.sanctuary.cosmetics.FamiliarEntity pet?owner.getPosition(partial).add(0,pet.visualHeight()*.85,0):owner==null?null:owner.getRopeHoldPosition(partial);
state.leashStates=owner==null?null:GrapplingLeashRenderUtil.buildSegmentedLeash(hook.level(),hook.getPosition(partial),hook.getPosition(partial),hold);
}
@Override protected boolean affectedByCulling(SmokerLassoEntity hook) { return false; }
}
@@ -0,0 +1,16 @@
package fr.koka.sanctuary.client;
import java.util.UUID;
import fr.koka.sanctuary.SanctuaryMod;
import net.minecraft.resources.Identifier;
/** A shared wardrobe, selected once by the already persistent native/familiar identity. */
public final class ZombieSkins {
public static final int COUNT=9;
public static int index(UUID identity) { return Math.floorMod(identity.hashCode(),COUNT); }
public static Identifier texture(int index,boolean baby) {
if(index<0||index>=COUNT)throw new IllegalArgumentException("Zombie skin index");
return SanctuaryMod.id("textures/entity/zombies/zombie"+(index+1)+(baby?"_baby":"")+".png");
}
private ZombieSkins() { }
}
@@ -116,6 +116,10 @@ public final class FamiliarEntity extends PathfinderMob {
mountedClimbing=false;
var player=pilot();
if(player==null){super.travel(input);return;}
var cast=fr.koka.sanctuary.familiar.FamiliarBattle.state(player).cast;
if(cast!=null&&cast.pet==this&&(fr.koka.sanctuary.familiar.FamiliarRush.applies(cast.technique)||fr.koka.sanctuary.familiar.FamiliarPounce.applies(cast.technique))){
super.travel(Vec3.ZERO);return;
}
var keys=player.getLastClientInput();
setYRot(player.getYRot());setXRot(player.getXRot()*.5F);yBodyRot=getYRot();yHeadRot=getYRot();
double forward=(keys.forward()?1:0)-(keys.backward()?1:0),side=(keys.left()?1:0)-(keys.right()?1:0);
@@ -150,7 +154,9 @@ public final class FamiliarEntity extends PathfinderMob {
double z=mountRoom(player,player.position().add(x,y,delta.z))?delta.z:0;
delta=new Vec3(x,y,z);setDeltaMovement(delta);
}
var beforeCenter=getBoundingBox().getCenter();var beforePosition=position();
super.move(type,delta);
if(type==MoverType.SELF&&!level().isClientSide())fr.koka.sanctuary.familiar.FamiliarPounce.moved(this,beforeCenter,beforePosition,delta);
}
@Override protected void registerGoals() {
goalSelector.addGoal(-3,new Goal(){
@@ -209,7 +215,11 @@ public final class FamiliarEntity extends PathfinderMob {
fr.koka.sanctuary.familiar.FamiliarTechniques.advance(this);return;
}
}
if(CarryService.carried(this)||MobHeadActions.propelling(this)){getNavigation().stop();return;}
if(CarryService.carried(this)||MobHeadActions.propelling(this)){
var cast=fr.koka.sanctuary.familiar.FamiliarBattle.state(player).cast;
if(cast!=null&&fr.koka.sanctuary.familiar.FamiliarPounce.applies(cast.technique)){fr.koka.sanctuary.familiar.FamiliarPounce.cancel(cast);fr.koka.sanctuary.familiar.FamiliarBattle.state(player).cast=null;}
getNavigation().stop();return;
}
boolean obstructed=!level.noBlockCollision(this,getBoundingBox().deflate(0.02));
if(distanceToSqr(player)>(fr.koka.sanctuary.familiar.FamiliarBattle.fighting(player)?24*24:144) || getY()<level.getMinY() || obstructed) {
if(tickCount%10==0)teleportNearOwner();
@@ -22,6 +22,11 @@ import static fr.koka.sanctuary.familiar.FamiliarProfile.Action;
public final class FamiliarActions {
private FamiliarActions(){}
public static String validate(FamiliarTechniques.Cast c){
if(FamiliarPounce.applies(c.technique)&&(!c.pet.onGround()||c.pet.isInWater()||c.pet.isVehicle()||c.pet.isPassenger()))return "blocked";
if(c.technique.effect().equals("lasso")) {
if(fr.koka.sanctuary.infected.SmokerLassoEntity.active(c.pet)!=null)return "preparing";
if(c.pet.isPassenger()||c.pet.isVehicle()||c.target()!=null&&CompanionWorld.boss(c.target()))return "blocked";
}
var t=c.technique;var p=c.owner;var target=c.target();var pet=c.pet;
if(!p.isAlive()||pet.isRemoved()||pet.level()!=p.level())return "unavailable";
if(target!=null&&(target.distanceToSqr(pet)>Math.pow(t.range()+2,2)||!pet.hasLineOfSight(target)))return "blocked";
@@ -46,6 +51,8 @@ public final class FamiliarActions {
};
}
public static void execute(FamiliarTechniques.Cast c){
if(c.technique.effect().equals("lasso")){fr.koka.sanctuary.infected.SmokerLassoEntity.launch(c.pet,c.target(),c);return;}
if(c.technique.effect().equals("bile")){fr.koka.sanctuary.infected.BoomerBile.familiar(c);return;}
var t=c.technique;var p=c.owner;var pet=c.pet;var target=c.target();
switch(t.action()){
case STRIKE,CHARGE,INTERCEPT->{
@@ -168,7 +168,7 @@ public final class FamiliarBattle {
if(p.tickCount%20==0)send(p,"");
}
public static void release(ServerPlayer p){var w=world(p.level().getServer());var state=STATES.remove(p);if(w!=null&&state!=null&&state.individual!=null)w.active.remove(state.individual,p.getUUID());if(state!=null)state.cast=null;}
public static void cancel(ServerPlayer p){var s=state(p);s.cast=null;s.guard=null;s.target=null;s.explicitTarget=false;s.threat=null;s.evadeUntil=0;s.unreachable=null;s.retryAfter=0;FamiliarTechniques.cancel(p);}
public static void cancel(ServerPlayer p){var pet=FamiliarService.active(p);var rope=pet==null?null:fr.koka.sanctuary.infected.SmokerLassoEntity.active(pet);if(rope!=null)rope.discard();var s=state(p);FamiliarRush.cancel(s.cast);FamiliarPounce.cancel(s.cast);s.cast=null;s.guard=null;s.target=null;s.explicitTarget=false;s.threat=null;s.evadeUntil=0;s.unreachable=null;s.retryAfter=0;FamiliarTechniques.cancel(p);}
public static void combat(ServerPlayer p){
var until=now()+COMBAT_MILLIS;state(p).playerCombatUntil=until;p.setAttached(COMBAT_LOCK,until);var b=bond(p);if(b!=null)update(p,b.combat(until));
}
@@ -248,10 +248,11 @@ public final class FamiliarBattle {
if(attacker instanceof FamiliarEntity familiar)attacker=familiar.owner();
if(attacker instanceof LivingEntity living&&!hostile&&!enemy(p,living))return false;
if(!FamiliarDuels.fighting(p)&&attacker instanceof LivingEntity living)automatic(p,living,FamiliarPersonality.Trigger.PET_HURT);
state(p).cast=null;state(p).lastHurt=p.tickCount;combat(p);
FamiliarPounce.cancel(state(p).cast);state(p).cast=null;state(p).lastHurt=p.tickCount;combat(p);
float scale=FamiliarLevels.health(bond(p));
float damage=FamiliarTechniques.incoming(pet,source,amount)/scale,health=Math.max(0,pet.getHealth()-damage);
if(damage<=0)return false;
var lasso=fr.koka.sanctuary.infected.SmokerLassoEntity.active(pet);if(lasso!=null)lasso.discard();
if(FamiliarTechniques.current()!=null)FamiliarDuels.hit(FamiliarTechniques.current(),Math.min(pet.getHealth(),damage)*scale);
update(p,bond(p).health(health,health<=0?now()+KO_MILLIS:0));
pet.setHealth(health>0?health:.01F);pet.animateHurt(source.getSourcePosition()==null?0:p.getYRot());p.level().broadcastDamageEvent(pet,source);
@@ -303,6 +304,7 @@ public final class FamiliarBattle {
}
case "signature","variant","work"->{
if(fighting(p)){send(p,"in_combat");return;}
if(action.equals("work")&&CompanionService.equipped(p)==null){send(p,"no_work");return;}
if(action.equals("work")&&!CompanionService.record(p).unlocked()&&!ProgressionService.canOperate(p)){send(p,"work_locked");return;}
if(action.equals("variant")&&(!b.learned()||!CompanionService.record(p).unlocked())){send(p,"variant_locked");return;}
s.workMode=action.equals("work");update(p,b.variant(action.equals("variant")));
@@ -18,7 +18,7 @@ public final class FamiliarBrain {
private FamiliarBrain(){}
public static boolean controls(FamiliarEntity pet){
var p=pet.owner();if(p==null||!FamiliarBattle.active(p)||CarryService.carried(pet))return false;
var s=FamiliarBattle.state(p);return s.cast!=null||s.target!=null||!s.workMode&&guarding(p)||s.evadeUntil>p.tickCount||FamiliarFields.busy(p);
var s=FamiliarBattle.state(p);return fr.koka.sanctuary.infected.SmokerLassoEntity.active(pet)!=null||s.cast!=null||s.target!=null||!s.workMode&&guarding(p)||s.evadeUntil>p.tickCount||FamiliarFields.busy(p);
}
private static final double HUNT_RADIUS=12, GUARD_RADIUS=6, GUARD_LEASH=10;
public static boolean guarding(ServerPlayer p){
@@ -77,7 +77,16 @@ public final class FamiliarBrain {
if(s.order==FamiliarBattle.Order.ENGAGE)s.order=FamiliarBattle.Order.FOLLOW;
}
public static void tick(FamiliarEntity pet){
var p=pet.owner();if(p==null)return;var s=FamiliarBattle.state(p);var profile=FamiliarBattle.profile(p);if(profile==null)return;
var p=pet.owner();
// A running goal can tick once after the equipped egg changes, before the
// end-of-tick service retires its old visual and prepares the new bond.
if(p==null||!FamiliarBattle.active(p)||!net.minecraft.world.item.ItemStack.isSameItemSameComponents(pet.egg(),FamiliarBattle.egg(p))){
pet.getNavigation().stop();pet.setAggressive(false);return;
}
var s=FamiliarBattle.state(p);var profile=FamiliarBattle.profile(p);if(profile==null)return;
if(fr.koka.sanctuary.infected.SmokerLassoEntity.active(pet)!=null) {
pet.getNavigation().stop();pet.setSpeed(0);pet.setXxa(0);pet.setZza(0);pet.setDeltaMovement(0,pet.getDeltaMovement().y,0);return;
}
var personality=FamiliarPersonality.of(FamiliarBattle.bond(p));
var initiative=personality.initiative(profile,s.explicitTarget||s.order==FamiliarBattle.Order.PROTECT);
var target=FamiliarBattle.target(p);
@@ -56,9 +56,15 @@ public final class FamiliarCatalog {
private static FamiliarCatalog bundled;
public static FamiliarCatalog bundled(){
if(bundled!=null)return bundled;
try(var input=FamiliarCatalog.class.getResourceAsStream("/data/sanctuary/sanctuary_familiars/vanilla.json")){
if(input==null)throw new IOException("Missing bundled familiar catalogue");
bundled=new FamiliarCatalog(parse(new String(input.readNBytes(1048577),StandardCharsets.UTF_8)));return bundled;
try{
var profiles=new ArrayList<FamiliarProfile>();
for(String file:List.of("vanilla.json","infected.json","charger.json","spitter.json","boomer.json","smoker.json","hunter.json")){
try(var input=FamiliarCatalog.class.getResourceAsStream("/data/sanctuary/sanctuary_familiars/"+file)){
if(input==null)throw new IOException("Missing bundled familiar catalogue: "+file);
profiles.addAll(parse(new String(input.readNBytes(1048577),StandardCharsets.UTF_8)));
}
}
bundled=new FamiliarCatalog(profiles);return bundled;
}catch(IOException e){throw new IllegalStateException("Cannot read bundled familiar catalogue",e);}
}
}
@@ -11,7 +11,7 @@ public enum FamiliarPersonality {
public enum Trigger { NEARBY_HOSTILE, GUARD_INTRUDER, OWNER_ATTACK, OWNER_HURT, PET_HURT }
private static final Set<String> FIERCE=Set.of("vindicator","pillager","evoker","ravager","piglin_brute",
"zombie","husk","drowned","skeleton","stray","bogged","parched","wither_skeleton",
"zombie","husk","drowned","charger","spitter","boomer","smoker","hunter","skeleton","stray","bogged","parched","wither_skeleton",
"creeper","blaze","breeze","ghast","wither","ender_dragon","warden","zoglin");
private static final Set<String> GENTLE=Set.of("villager","wandering_trader","allay","cow","mooshroom",
"sheep","pig","chicken","rabbit","turtle","tadpole","horse","donkey","mule","happy_ghast");
@@ -0,0 +1,58 @@
package fr.koka.sanctuary.familiar;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.cosmetics.FamiliarEntity;
import fr.koka.sanctuary.infected.*;
import net.minecraft.core.BlockPos;
import net.minecraft.world.entity.Pose;
import net.minecraft.world.phys.Vec3;
/** The Hunter familiar uses a real ballistic body, never an instantaneous ranged hit. */
public final class FamiliarPounce {
private Vec3 lastPosition;
private int flightTicks,recovery=-1;
public static boolean applies(FamiliarProfile.Technique t) { return t.action()==FamiliarProfile.Action.CHARGE&&t.effect().equals("pounce"); }
public static void advance(FamiliarTechniques.Cast cast) { if(cast.pounce==null)cast.pounce=new FamiliarPounce();cast.pounce.tick(cast); }
public static void cancel(FamiliarTechniques.Cast cast) {
if(cast==null||!applies(cast.technique))return;
HunterJump.hold(cast.pet);cast.pet.setPose(Pose.STANDING);cast.pet.setAggressive(false);
}
private static void finish(FamiliarTechniques.Cast c) { cancel(c);if(FamiliarBattle.state(c.owner).cast==c)FamiliarBattle.state(c.owner).cast=null; }
private void recover(FamiliarTechniques.Cast c) { recovery=24;HunterJump.hold(c.pet);c.pet.setPose(Pose.STANDING); }
private void tick(FamiliarTechniques.Cast c) {
var pet=c.pet;var target=c.target();
if(pet.isVehicle()||pet.isPassenger()||pet.level()!=c.owner.level()) { finish(c);return; }
if(c.age<c.technique.windup()) { HunterJump.hold(pet);pet.setPose(Pose.CROUCHING);return; }
if(recovery>=0) { HunterJump.hold(pet);if(--recovery<=0)finish(c);return; }
if(lastPosition==null) {
if(target==null||!FamiliarBattle.mayHit(pet,target)||!pet.hasLineOfSight(target)||!pet.onGround()||pet.isInWater()
||pet.position().subtract(target.position()).horizontalDistance()>c.technique.range()) { finish(c);FamiliarBattle.send(c.owner,"blocked");return; }
var impulse=HunterJump.impulse(pet.position(),target.position());
if(!HunterJump.clear(pet,impulse)||!CompanionWorld.permitted(c.owner,BlockPos.containing(pet.position().add(impulse)),"move")) { finish(c);FamiliarBattle.send(c.owner,"blocked");return; }
var supplies=FamiliarSupplies.reserve(c.owner,c.technique);
if(supplies==null) { finish(c);FamiliarBattle.send(c.owner,"need_supply");return; }
supplies.commit().run();c.supply=supplies.item();FamiliarDuels.technique(c);
if(!c.automatic) {
long until=FamiliarBattle.now()+c.technique.cooldown()*50L;
FamiliarBattle.update(c.owner,FamiliarBattle.bond(c.owner).cooldown(until));CompanionService.save(c.owner,CompanionService.record(c.owner).cooldown(until));FamiliarBattle.send(c.owner,"activated");
}
lastPosition=pet.position();pet.setPose(Pose.STANDING);HunterJump.stopSteering(pet);
pet.setDeltaMovement(impulse);pet.setOnGround(false);pet.syncVelocity=true;
return;
}
HunterJump.stopSteering(pet);
if(++flightTicks>28||pet.position().distanceToSqr(lastPosition)>4||!HunterJump.clearNext(pet)
||!CompanionWorld.permitted(c.owner,BlockPos.containing(pet.position().add(pet.getDeltaMovement())),"move"))recover(c);
}
public static void moved(FamiliarEntity pet,Vec3 beforeCenter,Vec3 beforePosition,Vec3 movement) {
var owner=pet.owner();if(owner==null)return;var c=FamiliarBattle.state(owner).cast;
if(c==null||!applies(c.technique)||c.pounce==null)return;var jump=c.pounce;
if(jump.lastPosition==null||jump.recovery>=0)return;
if(beforePosition.distanceToSqr(jump.lastPosition)>4) { jump.recover(c);return; }
jump.lastPosition=pet.position();var target=c.target();
if(target!=null&&FamiliarDuels.castAllowed(c)&&ChargeContact.touches(pet,target,beforeCenter,pet.getBoundingBox().getCenter())&&pet.hasLineOfSight(target)) {
FamiliarTechniques.hit(c,target,c.technique.power());jump.recover(c);
} else if(pet.horizontalCollision||pet.verticalCollision&&movement.y>0||pet.onGround()&&jump.flightTicks>1)jump.recover(c);
}
private FamiliarPounce() { }
}
@@ -21,7 +21,10 @@ public final class FamiliarProjectiles {
private record Shot(FamiliarTechniques.Cast cast,Vec3 origin){}
private static final Map<Projectile,Shot> SHOTS=new WeakHashMap<>();
private FamiliarProjectiles(){}
public static boolean managed(Projectile projectile){return SHOTS.containsKey(projectile);}
public static boolean managed(Projectile projectile){return !projectile.level().isClientSide()&&SHOTS.containsKey(projectile);}
public static boolean acidInFlight(fr.koka.sanctuary.cosmetics.FamiliarEntity pet){
return SHOTS.values().stream().anyMatch(shot->shot.cast.pet==pet&&shot.cast.technique.effect().equals("acid_zone"));
}
public static void launch(FamiliarTechniques.Cast c){
for(int i=0;i<c.technique.count();i++){
var target=c.target();if(target==null)return;
@@ -33,7 +36,8 @@ public final class FamiliarProjectiles {
case WIND->new BreezeWindCharge(EntityTypes.BREEZE_WIND_CHARGE,c.owner.level());
case SKULL->new WitherSkull(c.owner.level(),c.pet,direction);
case POTION->new ThrownSplashPotion(c.owner.level(),c.pet,c.supply.copyWithCount(1));
case SPIT->c.technique.effect().equals("levitation")?new ShulkerBullet(c.owner.level(),c.pet,target,Direction.Axis.Y):new LlamaSpit(EntityTypes.LLAMA_SPIT,c.owner.level());
case SPIT->c.technique.effect().equals("acid_zone")?new fr.koka.sanctuary.infected.AcidSpitEntity(c.owner.level(),c.pet)
:c.technique.effect().equals("levitation")?new ShulkerBullet(c.owner.level(),c.pet,target,Direction.Axis.Y):new LlamaSpit(EntityTypes.LLAMA_SPIT,c.owner.level());
default->throw new IllegalArgumentException("Technique is not a projectile");
};
shot.setOwner(c.pet);shot.setPos(c.pet.getEyePosition().add(direction.scale(.3)));
@@ -52,15 +56,25 @@ public final class FamiliarProjectiles {
}
}
public static boolean canHit(Projectile shot,Entity target){
if(shot.level().isClientSide())return true;
var value=SHOTS.get(shot);if(value==null)return true;
if(!FamiliarDuels.castAllowed(value.cast))return false;
if(shot.getOwner() instanceof ServerPlayer reflected)return target instanceof LivingEntity living&&FamiliarBattle.enemy(reflected,living);
return target instanceof LivingEntity living&&FamiliarBattle.enemy(value.cast.owner,living);
}
public static boolean impact(Projectile shot,HitResult hit){
// Integrated client entities share their numeric ids with the server. Prediction must
// never remove the server's cast from this map before its authoritative collision.
if(shot.level().isClientSide())return false;
var value=SHOTS.remove(shot);if(value==null)return false;
var c=value.cast;var t=c.technique;
if(!FamiliarDuels.castAllowed(c)){shot.discard();return true;}
if(t.effect().equals("acid_zone")){
if(shot.getOwner()==c.pet&&!c.pet.isRemoved()&&FamiliarBattle.active(c.owner))
fr.koka.sanctuary.infected.AcidZoneEntity.create(c.pet,hit,c);
c.owner.level().sendParticles(ParticleTypes.ITEM_SLIME,hit.getLocation().x,hit.getLocation().y,hit.getLocation().z,12,.2,.15,.2,.03);
shot.discard();return true;
}
if(hit instanceof EntityHitResult entity&&entity.getEntity() instanceof LivingEntity target){
if(shot.getOwner() instanceof ServerPlayer reflected&&reflected!=c.owner){
if(FamiliarBattle.enemy(reflected,target)&&CompanionWorld.permitted(reflected,target.blockPosition(),"attack"))target.hurtServer(reflected.level(),reflected.damageSources().playerAttack(reflected),Math.min(6,t.power()));
@@ -0,0 +1,74 @@
package fr.koka.sanctuary.familiar;
import fr.koka.sanctuary.companion.CompanionService;
import fr.koka.sanctuary.companion.CompanionWorld;
import fr.koka.sanctuary.infected.ChargeContact;
import net.minecraft.world.phys.Vec3;
/** Straight CHARGE variant: release commits the cooldown, even if the target dodges. */
public final class FamiliarRush {
private Vec3 direction,lastCenter,lastPosition;
private double traveled;
private int steps,recovery=-1;
public static boolean applies(FamiliarProfile.Technique t) {
return t.action()==FamiliarProfile.Action.CHARGE&&t.effect().equals("straight");
}
public static void advance(FamiliarTechniques.Cast cast) {
if(cast.rush==null)cast.rush=new FamiliarRush();
cast.rush.tick(cast);
}
public static void cancel(FamiliarTechniques.Cast cast) {
if(cast!=null&&applies(cast.technique)){hold(cast);cast.pet.setAggressive(false);}
}
private static void hold(FamiliarTechniques.Cast c) {
var pet=c.pet;pet.getNavigation().stop();pet.getMoveControl().setWantedPosition(pet.getX(),pet.getY(),pet.getZ(),0);
pet.setSpeed(0);pet.setXxa(0);pet.setZza(0);pet.setDeltaMovement(0,pet.getDeltaMovement().y,0);pet.syncVelocity=true;
}
private static void finish(FamiliarTechniques.Cast c) {
hold(c);c.pet.setAggressive(false);
var state=FamiliarBattle.state(c.owner);if(state.cast==c)state.cast=null;
}
private void recover(FamiliarTechniques.Cast c) { recovery=24;hold(c); }
private void tick(FamiliarTechniques.Cast c) {
var pet=c.pet;var p=c.owner;var target=c.target();var t=c.technique;
if(c.age<t.windup()) { hold(c);return; }
if(recovery>=0) { hold(c);if(--recovery<=0)finish(c);return; }
if(direction==null) {
if(target==null||!FamiliarBattle.mayHit(pet,target)||!pet.hasLineOfSight(target)||!pet.onGround()||pet.isInWater()) {
finish(c);FamiliarBattle.send(p,"blocked");return;
}
direction=target.position().subtract(pet.position()).multiply(1,0,1).normalize();
if(direction.lengthSqr()<.5) { finish(c);return; }
var supplies=FamiliarSupplies.reserve(p,t);
if(supplies==null) { finish(c);FamiliarBattle.send(p,"need_supply");return; }
supplies.commit().run();c.supply=supplies.item();FamiliarDuels.technique(c);
if(!c.automatic) {
long until=FamiliarBattle.now()+t.cooldown()*50L;
FamiliarBattle.update(p,FamiliarBattle.bond(p).cooldown(until));
CompanionService.save(p,CompanionService.record(p).cooldown(until));FamiliarBattle.send(p,"activated");
}
lastCenter=pet.getBoundingBox().getCenter();lastPosition=pet.position();
}
var displacement=pet.position().subtract(lastPosition);
// Owner recall/teleport is not a body sweep and must never become a remote hit.
if(displacement.horizontalDistance()>1.5||Math.abs(displacement.y)>1.5) { recover(c);return; }
traveled+=displacement.horizontalDistance();lastPosition=pet.position();
Vec3 center=pet.getBoundingBox().getCenter();
if(target!=null&&ChargeContact.touches(pet,target,lastCenter,center)&&pet.hasLineOfSight(target)) {
if(FamiliarTechniques.hit(c,target,t.power()))FamiliarTechniques.push(c,target,direction.scale(.9).add(0,.25,0));
recover(c);return;
}
lastCenter=center;
if(pet.horizontalCollision||traveled>=t.range()||steps++>=24) { recover(c);return; }
var motion=direction.scale(Math.min(.65,t.range()-traveled));var next=pet.position().add(motion);
var corridor=pet.getBoundingBox().expandTowards(motion);
if(!ChargeContact.loaded(p.level(),corridor)||!p.level().getWorldBorder().isWithinBounds(corridor)
||!FamiliarTechniques.safe(p,pet,next,false)||!CompanionWorld.loaded(p.level(),center,center.add(motion))
||!p.level().noBlockCollision(pet,corridor.deflate(.001))) {
recover(c);return;
}
pet.getNavigation().stop();pet.getLookControl().setLookAt(pet.getX()+direction.x*4,pet.getEyeY(),pet.getZ()+direction.z*4,30,30);
pet.setDeltaMovement(motion.x,pet.getDeltaMovement().y,motion.z);pet.syncVelocity=true;
}
private FamiliarRush() { }
}
@@ -22,6 +22,8 @@ public final class FamiliarTechniques {
public final FamiliarEntity pet;public final ServerPlayer owner;public final FamiliarProfile.Technique technique;
public final boolean automatic;public final UUID target,duel;public final Vec3 point,direction,origin;
public int age;public final Set<UUID> hit=new HashSet<>();public ItemStack supply=ItemStack.EMPTY;
FamiliarRush rush;
FamiliarPounce pounce;
public Cast(FamiliarEntity pet,FamiliarProfile.Technique technique,boolean automatic,LivingEntity target,Vec3 point){
this.pet=pet;owner=pet.owner();duel=FamiliarDuels.combatId(owner);this.technique=technique;this.automatic=automatic;this.target=target==null?null:target.getUUID();
this.point=point;origin=pet.position();direction=(target==null?point:target.getBoundingBox().getCenter()).subtract(pet.getEyePosition()).normalize();
@@ -52,6 +54,9 @@ public final class FamiliarTechniques {
var p=pet.owner();if(p==null||!FamiliarBattle.active(p)||!pet.isAlive()||pet.isRemoved()||CarryService.carried(pet))return "unavailable";
var state=FamiliarBattle.state(p);if(state.cast!=null)return "preparing";
if(FamiliarFields.busy(p))return "preparing";
// Let the released acid zone run its course before an automatic shot can replace it.
if(automatic&&technique.effect().equals("acid_zone")&&(FamiliarProjectiles.acidInFlight(pet)
||fr.koka.sanctuary.infected.AcidZoneEntity.active(pet)!=null))return "preparing";
LivingEntity target=automatic?FamiliarBattle.target(p):aim(p,Math.max(technique.range(),4),e->FRIENDLY.contains(technique.action())?ally(p,e):FamiliarBattle.enemy(p,e));
if(FRIENDLY.contains(technique.action())&&(target==null||!ally(p,target)))target=FamiliarDuels.fighting(p)?pet:p;
if(!automatic&&target==null&&ENEMY.contains(technique.action()))target=FamiliarBattle.target(p);
@@ -75,10 +80,12 @@ public final class FamiliarTechniques {
public static void advance(FamiliarEntity pet){
var p=pet.owner();var state=FamiliarBattle.state(p);var cast=state.cast;if(cast==null)return;
var target=cast.target();var t=cast.technique;
if(!FamiliarBattle.active(p)||!FamiliarDuels.castAllowed(cast)||cast.pet!=pet||pet.isRemoved()||cast.target!=null&&target==null){state.cast=null;return;}
if(!FamiliarBattle.active(p)||!FamiliarDuels.castAllowed(cast)||cast.pet!=pet||pet.isRemoved()||cast.target!=null&&target==null){FamiliarRush.cancel(cast);FamiliarPounce.cancel(cast);state.cast=null;return;}
cast.age++;
if(cast.age%4==0){var at=pet.getEyePosition();p.level().sendParticles(ParticleTypes.CRIT,at.x,at.y,at.z,2,.12,.12,.12,0);}
if(target!=null){pet.getLookControl().setLookAt(target,30,30);}
if(FamiliarPounce.applies(t)){FamiliarPounce.advance(cast);return;}
if(FamiliarRush.applies(t)){FamiliarRush.advance(cast);return;}
if(t.action()==Action.CHARGE||t.action()==Action.INTERCEPT){
if(target==null||!FamiliarBattle.mayHit(pet,target)){state.cast=null;return;}
if(cast.age>=t.windup()&&pet.distanceToSqr(target)>3){
@@ -125,6 +132,7 @@ public final class FamiliarTechniques {
}
public static void effect(Cast cast,LivingEntity target,String effect,int duration){
if(!FamiliarDuels.castAllowed(cast))return;
if(effect.equals("bile")){fr.koka.sanctuary.infected.BoomerBile.coat(cast,target);return;}
if(effect.equals("fire")){target.igniteForSeconds(2);return;}
var kind=switch(effect){case "poison"->MobEffects.POISON;case "weakness"->MobEffects.WEAKNESS;case "slowness"->MobEffects.SLOWNESS;case "wither"->MobEffects.WITHER;case "levitation"->MobEffects.LEVITATION;default->null;};
if(kind==null)return;
@@ -0,0 +1,16 @@
package fr.koka.sanctuary.infected;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.monster.zombie.Zombie;
import net.minecraft.world.level.Level;
/** Shared rule for every infected role: adult proportions, including familiar proxies. */
public abstract class AbstractInfectedEntity extends Zombie {
protected AbstractInfectedEntity(EntityType<? extends AbstractInfectedEntity> type, Level level) {
super(type, level);
}
// Covers natural groups, eggs, loaded entity data and render/sound proxies.
// FamiliarSize supplies the miniature's size independently of native age.
@Override public final void setBaby(boolean baby) { super.setBaby(false); }
}
@@ -0,0 +1,37 @@
package fr.koka.sanctuary.infected;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.projectile.throwableitemprojectile.ThrowableItemProjectile;
import net.minecraft.world.item.*;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.*;
/** The glob itself does no damage: only a grounded lingering zone can hurt. */
public final class AcidSpitEntity extends ThrowableItemProjectile {
private Vec3 origin;
public AcidSpitEntity(EntityType<? extends AcidSpitEntity> type,Level level) { super(type,level); }
public AcidSpitEntity(Level level,LivingEntity source) { super(Infected.ACID_SPIT,source,level,new ItemStack(Items.SLIME_BALL)); }
@Override protected Item getDefaultItem() { return Items.SLIME_BALL; }
@Override protected double getDefaultGravity() { return .035; }
@Override public void tick() {
if(level() instanceof ServerLevel) {
if(origin==null)origin=position();
var owner=getOwner();
var corridor=getBoundingBox().expandTowards(getDeltaMovement());
if(!(owner instanceof LivingEntity living)||!living.isAlive()||owner.isRemoved()||tickCount>=120
||position().distanceToSqr(origin)>1024||!ChargeContact.loaded(level(),corridor)
||!level().getWorldBorder().isWithinBounds(corridor)) { discard();return; }
}
super.tick();
if(level().isClientSide())level().addParticle(ParticleTypes.ITEM_SLIME,getX(),getY(),getZ(),0,0,0);
}
@Override protected void onHit(HitResult hit) {
if(level() instanceof ServerLevel level) {
if(getOwner() instanceof SpitterEntity source)AcidZoneEntity.create(source,hit,null);
level.sendParticles(ParticleTypes.ITEM_SLIME,getX(),getY(),getZ(),12,.2,.15,.2,.03);
discard();
}
}
}
@@ -0,0 +1,83 @@
package fr.koka.sanctuary.infected;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.familiar.*;
import java.util.Comparator;
import net.fabricmc.fabric.api.attachment.v1.*;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.particles.*;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.alchemy.PotionContents;
import net.minecraft.world.level.ClipContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.*;
/** A native potion-cloud visual with bounded, server-authoritative ground damage. */
public final class AcidZoneEntity extends AreaEffectCloud {
private static final AttachmentType<AcidZoneEntity> ACTIVE=AttachmentRegistry.create(SanctuaryMod.id("active_acid_zone"));
private LivingEntity source;
private FamiliarTechniques.Cast familiar;
public AcidZoneEntity(EntityType<? extends AcidZoneEntity> type,Level level) { super(type,level); }
public static void register() { java.util.Objects.requireNonNull(ACTIVE); }
public static AcidZoneEntity active(LivingEntity source) { return source.getAttached(ACTIVE); }
public static AcidZoneEntity create(LivingEntity source,HitResult impact,FamiliarTechniques.Cast cast) {
if(!(source.level() instanceof ServerLevel level)||!source.isAlive()||source.isRemoved())return null;
Vec3 anchor=impact instanceof EntityHitResult e?e.getEntity().position():impact.getLocation();
if(impact instanceof BlockHitResult block) {
var d=block.getDirection();anchor=anchor.add(d.getStepX()*.05,d.getStepY()*.05,d.getStepZ()*.05);
}
Vec3 floor=floor(level,anchor);if(floor==null)return null;
var zone=Infected.ACID_ZONE.create(level,EntitySpawnReason.MOB_SUMMONED);
zone.source=source;zone.familiar=cast;zone.setOwner(source);zone.setPos(floor);
float radius=cast==null?2.5F:(float)Math.clamp(cast.technique.radius(),.5,3);
int duration=cast==null?100:Math.clamp(cast.technique.duration(),20,100);
zone.setRadius(radius);zone.setWaitTime(10);zone.setDuration(duration);zone.setRadiusOnUse(0);zone.setDurationOnUse(0);
zone.setRadiusPerTick(-(radius-.5F)/duration);zone.setPotionContents(PotionContents.EMPTY);
zone.setCustomParticle(ColorParticleOption.create(ParticleTypes.ENTITY_EFFECT,0xFF91D12A));
if(!ChargeContact.loaded(level,zone.getBoundingBox())||!level.getWorldBorder().isWithinBounds(zone.getBoundingBox()))return null;
if(!level.addFreshEntity(zone))return null;
var previous=active(source);if(previous!=null)previous.discard();source.setAttached(ACTIVE,zone);
return zone;
}
private static Vec3 floor(ServerLevel level,Vec3 at) {
for(int depth=0;depth<=3;depth++) {
var pos=BlockPos.containing(at).below(depth);if(!level.hasChunkAt(pos))return null;
var shape=level.getBlockState(pos).getCollisionShape(level,pos);
if(shape.isEmpty())continue;
double y=pos.getY()+shape.max(Direction.Axis.Y);
if(y<=at.y+.25&&level.getFluidState(pos.above()).isEmpty())return new Vec3(at.x,y+.05,at.z);
}
return null;
}
@Override public void tick() {
if(level() instanceof ServerLevel) {
if(source==null||!source.isAlive()||source.isRemoved()||source.level()!=level()
||!ChargeContact.loaded(level(),getBoundingBox())
||familiar!=null&&(!FamiliarBattle.active(familiar.owner)||!FamiliarDuels.castAllowed(familiar))) { discard();return; }
}
super.tick();
if(!(level() instanceof ServerLevel level)||isRemoved()||isWaiting()||tickCount%20!=0)return;
var victims=level.getEntitiesOfClass(LivingEntity.class,getBoundingBox().inflate(0,.6,0),this::inside).stream()
.sorted(Comparator.comparingDouble(e->e.position().distanceToSqr(position()))).limit(12).toList();
for(var target:victims) {
if(familiar!=null)FamiliarTechniques.hit(familiar,target,Math.min(1.5F,familiar.technique.power()));
else if(!(target instanceof AbstractInfectedEntity)&&source.canAttack(target))
target.hurtServer(level,damageSources().indirectMagic(this,source),1);
}
}
private boolean inside(LivingEntity target) {
if(target==source||!target.isAlive()||target.isSpectator()||target instanceof Player p&&p.isCreative())return false;
if(familiar==null?target instanceof AbstractInfectedEntity||!source.canAttack(target):!FamiliarBattle.enemy(familiar.owner,target))return false;
var offset=target.position().subtract(position());
if(offset.horizontalDistanceSqr()>getRadius()*getRadius()||Math.abs(offset.y)>.6)return false;
return level().clip(new ClipContext(position().add(0,.25,0),target.position().add(0,.25,0),
ClipContext.Block.COLLIDER,ClipContext.Fluid.NONE,source)).getType()==HitResult.Type.MISS;
}
@Override public void remove(RemovalReason reason) {
if(source!=null&&active(source)==this)source.removeAttached(ACTIVE);
super.remove(reason);
}
}
@@ -0,0 +1,100 @@
package fr.koka.sanctuary.infected;
import fr.koka.sanctuary.familiar.*;
import java.util.*;
import net.fabricmc.fabric.api.event.lifecycle.v1.*;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.*;
import net.minecraft.world.effect.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.monster.zombie.Zombie;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.ClipContext;
import net.minecraft.world.phys.*;
/** Short bile projections and finite diversions of existing, loaded zombies. */
public final class BoomerBile {
private record Lure(UUID victim,UUID previous,long until) { }
private static final Map<Zombie,Lure> LURES=new HashMap<>();
private BoomerBile() { }
public static void register() {
ServerTickEvents.END_SERVER_TICK.register(server->{
for(var entry:List.copyOf(LURES.entrySet())) {
var mob=entry.getKey();if(mob.level().getServer()!=server)continue;var lure=entry.getValue();
var victim=((ServerLevel)mob.level()).getEntity(lure.victim) instanceof LivingEntity living?living:null;
if(mob.isRemoved()||!mob.isAlive()||mob.isNoAi()||mob.level().getGameTime()>=lure.until||!valid(victim)||!victim.hasEffect(MobEffects.GLOWING)
||mob.distanceToSqr(victim)>256||!visible(mob,victim)) { release(mob,lure);LURES.remove(mob);continue; }
if(server.getTickCount()%10==0)mob.setTarget(victim);
}
});
ServerLifecycleEvents.SERVER_STOPPING.register(server->LURES.keySet().removeIf(mob->mob.level().getServer()==server));
}
private static boolean valid(LivingEntity victim) {
return victim!=null&&victim.isAlive()&&!victim.isSpectator()&&!(victim instanceof Player p&&p.isCreative());
}
private static boolean visible(LivingEntity from,LivingEntity to) {
return from.level()==to.level()&&ChargeContact.loaded(from.level(),new AABB(from.getEyePosition(),to.getEyePosition()).inflate(.1))&&from.hasLineOfSight(to);
}
private static void release(Zombie mob,Lure lure) {
if(mob.getTarget()==null||!mob.getTarget().getUUID().equals(lure.victim))return;
var previous=lure.previous!=null&&((ServerLevel)mob.level()).getEntity(lure.previous) instanceof LivingEntity living?living:null;
mob.setTarget(valid(previous)&&mob.canAttack(previous)?previous:null);mob.getNavigation().stop();
}
private static void attract(BoomerEntity source,LivingEntity victim) {
var level=(ServerLevel)source.level();long until=level.getGameTime()+120;
var zombies=level.getEntitiesOfClass(Zombie.class,victim.getBoundingBox().inflate(12),z->z!=source&&!(z instanceof BoomerEntity)
&&z.isAlive()&&!z.isNoAi()&&z.distanceToSqr(victim)<=144&&z.canAttack(victim)&&visible(z,victim))
.stream().sorted(Comparator.comparingDouble(z->z.distanceToSqr(victim))).limit(6).toList();
for(var zombie:zombies) {
var old=LURES.get(zombie);
if(old!=null&&!old.victim.equals(victim.getUUID()))continue;
if(old==null&&LURES.keySet().stream().filter(z->z.level().getServer()==level.getServer()).count()>=64)break;
var previous=old==null?(zombie.getTarget()==null?null:zombie.getTarget().getUUID()):old.previous;
LURES.put(zombie,new Lure(victim.getUUID(),previous,until));zombie.setTarget(victim);
}
}
private static boolean reaches(LivingEntity source,LivingEntity target,Vec3 direction,double radius,boolean burst) {
if(Math.abs(target.getY()-source.getY())>1.5||!visible(source,target))return false;
var delta=target.getBoundingBox().getCenter().subtract(source.getBoundingBox().getCenter());
return delta.lengthSqr()<=radius*radius&&(burst||delta.normalize().dot(direction)>=.65);
}
public static int wild(BoomerEntity source,Vec3 direction,boolean burst) {
if(!(source.level() instanceof ServerLevel level))return 0;
if(!ChargeContact.loaded(level,source.getBoundingBox())||!level.getWorldBorder().isWithinBounds(source.getBoundingBox()))return 0;
double radius=burst?3:5;visual(level,source,direction,radius,burst);
var victims=level.getEntitiesOfClass(LivingEntity.class,source.getBoundingBox().inflate(radius),e->e!=source&&valid(e)
&&!(e instanceof AbstractInfectedEntity)&&source.canAttack(e)&&reaches(source,e,direction,radius,burst))
.stream().sorted(Comparator.comparingDouble(e->e.distanceToSqr(source))).limit(12).toList();
for(var target:victims) {
if(burst)target.hurtServer(level,source.damageSources().mobAttack(source),2);
target.addEffect(new MobEffectInstance(MobEffects.GLOWING,120,0),source);
target.addEffect(new MobEffectInstance(MobEffects.WEAKNESS,60,0),source);attract(source,target);
}
return victims.size();
}
public static void familiar(FamiliarTechniques.Cast cast) {
if(!FamiliarDuels.castAllowed(cast)||cast.pet.isRemoved()||!FamiliarBattle.active(cast.owner))return;
boolean burst=cast.technique.action()==FamiliarProfile.Action.EXPLOSION;double radius=cast.technique.radius();
visual(cast.owner.level(),cast.pet,cast.direction,radius,burst);
for(var target:FamiliarTechniques.enemies(cast,cast.pet.position(),radius))
if(reaches(cast.pet,target,cast.direction,radius,burst))FamiliarTechniques.hit(cast,target,cast.technique.power());
}
public static void coat(FamiliarTechniques.Cast cast,LivingEntity target) {
// Damage, claims and duel permissions were already checked by the shared hit path.
int duration=Math.clamp(cast.technique.duration(),20,120);
target.addEffect(new MobEffectInstance(MobEffects.GLOWING,duration,0),cast.pet);
target.addEffect(new MobEffectInstance(MobEffects.WEAKNESS,Math.min(60,duration),0),cast.pet);
}
private static void visual(ServerLevel level,LivingEntity source,Vec3 direction,double radius,boolean burst) {
var start=source.getBoundingBox().getCenter();
if(burst)level.sendParticles(ParticleTypes.POOF,start.x,start.y,start.z,16,.5,.3,.5,.05);
for(int i=0;i<12;i++) {
var at=burst?start.add(Math.cos(i*Math.PI/6)*radius*.7,0,Math.sin(i*Math.PI/6)*radius*.7)
:start.add(direction.yRot((i%3-1)*.4F).scale(radius*(i/3+1)/4));
level.sendParticles(ParticleTypes.SNEEZE,at.x,at.y,at.z,3,.18,.15,.18,.015);
level.sendParticles(ParticleTypes.ITEM_SLIME,at.x,at.y,at.z,2,.15,.15,.15,.02);
}
level.playSound(null,source.blockPosition(),SoundEvents.PLAYER_BURP,SoundSource.HOSTILE,.9F,burst?.6F:.85F);
}
}
@@ -0,0 +1,64 @@
package fr.koka.sanctuary.infected;
import java.util.EnumSet;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.*;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.*;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.entity.monster.zombie.Zombie;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.storage.*;
import net.minecraft.world.phys.Vec3;
/** A slow infected that coats a short cone and bursts once when actually killed. */
public final class BoomerEntity extends AbstractInfectedEntity {
public static final int WINDUP=20,COOLDOWN=120;
private int windup,cooldown;
private boolean burst;
private Vec3 direction=Vec3.ZERO;
public BoomerEntity(EntityType<? extends BoomerEntity> type,Level level) { super(type,level);xpReward=3; }
public static AttributeSupplier.Builder createAttributes() {
return Zombie.createAttributes().add(Attributes.MAX_HEALTH,10).add(Attributes.MOVEMENT_SPEED,.20)
.add(Attributes.ATTACK_DAMAGE,2).add(Attributes.FOLLOW_RANGE,32);
}
public boolean windingUp() { return windup>0; }
public int bileCooldown() { return cooldown; }
@Override protected boolean convertsInWater() { return false; }
@Override protected void registerGoals() { super.registerGoals();goalSelector.addGoal(-1,new BileGoal()); }
@Override public void tick() { if(!level().isClientSide()&&cooldown>0)cooldown--;super.tick(); }
@Override public boolean doHurtTarget(ServerLevel level,Entity target) { return windup==0&&super.doHurtTarget(level,target); }
@Override public void die(DamageSource cause) {
if(!burst&&level() instanceof ServerLevel) { burst=true;BoomerBile.wild(this,Vec3.ZERO,true); }
super.die(cause);
}
@Override protected void addAdditionalSaveData(ValueOutput out) { super.addAdditionalSaveData(out);out.putInt("BileCooldown",windup>0?Math.max(cooldown,COOLDOWN):cooldown); }
@Override protected void readAdditionalSaveData(ValueInput in) {
super.readAdditionalSaveData(in);cooldown=Math.clamp(in.getIntOr("BileCooldown",0),0,COOLDOWN);windup=0;direction=Vec3.ZERO;burst=getHealth()<=0;
}
private boolean valid(LivingEntity target) {
return target!=null&&target.isAlive()&&target.level()==level()&&!target.isSpectator()&&canAttack(target)
&&!(target instanceof Player p&&p.isCreative())&&distanceToSqr(target)<=25&&hasLineOfSight(target);
}
private void hold() { getNavigation().stop();getMoveControl().setWantedPosition(getX(),getY(),getZ(),0);setSpeed(0);setXxa(0);setZza(0);setDeltaMovement(0,getDeltaMovement().y,0); }
private final class BileGoal extends Goal {
BileGoal() { setFlags(EnumSet.of(Flag.MOVE,Flag.LOOK)); }
@Override public boolean canUse() { return cooldown==0&&valid(getTarget()); }
@Override public boolean canContinueToUse() { return windup>0; }
@Override public boolean requiresUpdateEveryTick() { return true; }
@Override public void start() {
windup=WINDUP;direction=getTarget().getBoundingBox().getCenter().subtract(getBoundingBox().getCenter()).normalize();setAggressive(true);hold();
level().playSound(null,blockPosition(),SoundEvents.SLIME_SQUISH_SMALL,SoundSource.HOSTILE,.9F,.65F);
}
@Override public void stop() { windup=0;direction=Vec3.ZERO;cooldown=COOLDOWN;setAggressive(false);hold(); }
@Override public void tick() {
hold();var point=getEyePosition().add(direction.scale(4));getLookControl().setLookAt(point.x,point.y,point.z);
if(!valid(getTarget())) { windup=0;cooldown=COOLDOWN;return; }
if(level() instanceof ServerLevel level&&windup%4==0)level.sendParticles(ParticleTypes.SNEEZE,getX(),getEyeY()-.2,getZ(),2,.1,.1,.1,.01);
if(--windup==0) { cooldown=COOLDOWN;BoomerBile.wild(BoomerEntity.this,direction,false); }
}
}
}
@@ -0,0 +1,21 @@
package fr.koka.sanctuary.infected;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
/** Swept body contact between two actual positions; never extrapolates through a wall. */
public final class ChargeContact {
public static boolean loaded(Level level,AABB corridor) {
for(int x=((int)Math.floor(corridor.minX))>>4;x<=((int)Math.floor(corridor.maxX))>>4;x++)
for(int z=((int)Math.floor(corridor.minZ))>>4;z<=((int)Math.floor(corridor.maxZ))>>4;z++)
if(!level.hasChunk(x,z))return false;
return true;
}
public static boolean touches(LivingEntity mover, LivingEntity target, Vec3 from, Vec3 to) {
var body=target.getBoundingBox().inflate(mover.getBbWidth()/2.0,mover.getBbHeight()/2.0,mover.getBbWidth()/2.0);
return body.contains(from)||body.contains(to)||body.clip(from,to).isPresent();
}
private ChargeContact() { }
}
@@ -0,0 +1,113 @@
package fr.koka.sanctuary.infected;
import java.util.EnumSet;
import net.minecraft.core.BlockPos;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.AttributeSupplier;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.entity.monster.zombie.Zombie;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.world.phys.Vec3;
/** A telegraphed straight rush followed by a vulnerable recovery. */
public final class ChargerEntity extends AbstractInfectedEntity {
public enum Phase { IDLE, WINDUP, CHARGING, RECOVERING }
public static final int WINDUP=22, CHARGE=24, RECOVERY=48, COOLDOWN=100;
private Phase phase=Phase.IDLE;
private int remaining,cooldown;
private LivingEntity victim;
private Vec3 direction=Vec3.ZERO;
public ChargerEntity(EntityType<? extends ChargerEntity> type,Level level) { super(type,level);xpReward=3; }
public static AttributeSupplier.Builder createAttributes() {
return Zombie.createAttributes().add(Attributes.MAX_HEALTH,10).add(Attributes.MOVEMENT_SPEED,.27)
.add(Attributes.ATTACK_DAMAGE,3).add(Attributes.FOLLOW_RANGE,36).add(Attributes.KNOCKBACK_RESISTANCE,.35);
}
public Phase phase() { return phase; }
public int chargeCooldown() { return cooldown; }
public Vec3 chargeDirection() { return direction; }
@Override protected boolean convertsInWater() { return false; }
@Override protected void registerGoals() { super.registerGoals();goalSelector.addGoal(-1,new RushGoal()); }
@Override public void tick() {
if(!level().isClientSide()&&cooldown>0)cooldown--;
super.tick();
}
@Override public boolean doHurtTarget(ServerLevel level,Entity target) {
return phase==Phase.IDLE&&super.doHurtTarget(level,target);
}
private boolean valid(LivingEntity target) {
return target!=null&&target.isAlive()&&target.level()==level()&&!target.isSpectator()&&canAttack(target)
&&!(target instanceof Player p&&p.isCreative());
}
private void hold() {
getNavigation().stop();getMoveControl().setWantedPosition(getX(),getY(),getZ(),0);
setSpeed(0);setXxa(0);setZza(0);setDeltaMovement(0,getDeltaMovement().y,0);syncVelocity=true;
}
private void recover() { phase=Phase.RECOVERING;remaining=RECOVERY;hold(); }
@Override public void move(MoverType type,Vec3 delta) {
Vec3 before=getBoundingBox().getCenter();super.move(type,delta);
if(type!=MoverType.SELF||phase!=Phase.CHARGING||!(level() instanceof ServerLevel level))return;
if(valid(victim)&&ChargeContact.touches(this,victim,before,getBoundingBox().getCenter())&&hasLineOfSight(victim)) {
var source=damageSources().mobAttack(this);
if(victim.hurtServer(level,source,4)) {
victim.knockback(1.2,-direction.x,-direction.z,source,0);victim.syncVelocity=true;
}
recover();
} else if(horizontalCollision)recover();
}
@Override protected void addAdditionalSaveData(ValueOutput output) {
super.addAdditionalSaveData(output);
output.putInt("ChargeCooldown",phase==Phase.IDLE?cooldown:Math.max(cooldown,COOLDOWN));
}
@Override protected void readAdditionalSaveData(ValueInput input) {
super.readAdditionalSaveData(input);
cooldown=Math.clamp(input.getIntOr("ChargeCooldown",0),0,COOLDOWN);
phase=Phase.IDLE;remaining=0;victim=null;direction=Vec3.ZERO;
setDeltaMovement(0,getDeltaMovement().y,0);setAggressive(false);
}
private final class RushGoal extends Goal {
RushGoal() { setFlags(EnumSet.of(Flag.MOVE,Flag.LOOK,Flag.JUMP)); }
@Override public boolean canUse() {
var target=getTarget();double distance=target==null?0:distanceToSqr(target);
return cooldown==0&&phase==Phase.IDLE&&onGround()&&!isInWater()&&valid(target)
&&distance>=16&&distance<=225&&hasLineOfSight(target);
}
@Override public boolean canContinueToUse() { return phase!=Phase.IDLE; }
@Override public boolean requiresUpdateEveryTick() { return true; }
@Override public void start() { victim=getTarget();phase=Phase.WINDUP;remaining=WINDUP;hold();setAggressive(true); }
@Override public void stop() { phase=Phase.IDLE;victim=null;direction=Vec3.ZERO;hold();setAggressive(false);cooldown=COOLDOWN; }
@Override public void tick() {
if(phase==Phase.RECOVERING) {
hold();if(--remaining<=0)phase=Phase.IDLE;return;
}
if(!valid(victim)) { recover();return; }
if(phase==Phase.WINDUP) {
hold();getLookControl().setLookAt(victim,30,30);
if(remaining%4==0&&level() instanceof ServerLevel level)
level.sendParticles(ParticleTypes.DUST_PLUME,getX(),getY()+.1,getZ(),3,.3,.05,.3,.02);
if(--remaining<=0) {
if(!hasLineOfSight(victim)) { recover();return; }
direction=victim.position().subtract(position()).multiply(1,0,1).normalize();
if(direction.lengthSqr()<.5) { recover();return; }
phase=Phase.CHARGING;remaining=CHARGE;
level().playSound(null,blockPosition(),SoundEvents.RAVAGER_ROAR,SoundSource.HOSTILE,.65F,1.35F);
}
return;
}
if(phase==Phase.CHARGING) {
getNavigation().stop();
var corridor=getBoundingBox().expandTowards(direction.scale(1.18));
if(--remaining<0||!ChargeContact.loaded(level(),corridor)||!level().getWorldBorder().isWithinBounds(corridor)) { recover();return; }
setDeltaMovement(direction.x*1.18,getDeltaMovement().y,direction.z*1.18);syncVelocity=true;
}
}
}
}
@@ -0,0 +1,88 @@
package fr.koka.sanctuary.infected;
import java.util.EnumSet;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.*;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.entity.monster.zombie.Zombie;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.storage.*;
import net.minecraft.world.phys.Vec3;
/** An adult zombie that winds up, physically pounces once and recovers. */
public final class HunterEntity extends AbstractInfectedEntity {
public enum Phase { IDLE, WINDUP, LEAPING, RECOVERING }
public static final int WINDUP=16,RECOVERY=24,COOLDOWN=120;
private Phase phase=Phase.IDLE;
private int remaining,cooldown,flightTicks;
private LivingEntity victim;
private Vec3 lastPosition;
public HunterEntity(EntityType<? extends HunterEntity> type,Level level) { super(type,level);xpReward=3; }
public static AttributeSupplier.Builder createAttributes() {
return Zombie.createAttributes().add(Attributes.MAX_HEALTH,10).add(Attributes.MOVEMENT_SPEED,.30)
.add(Attributes.ATTACK_DAMAGE,3).add(Attributes.FOLLOW_RANGE,36);
}
public Phase phase() { return phase; }
public int pounceCooldown() { return cooldown; }
@Override protected boolean convertsInWater() { return false; }
@Override protected void registerGoals() { super.registerGoals();goalSelector.addGoal(-1,new PounceGoal()); }
@Override public void tick() { if(!level().isClientSide()&&cooldown>0)cooldown--;super.tick(); }
@Override public boolean doHurtTarget(ServerLevel level,Entity target) { return phase==Phase.IDLE&&super.doHurtTarget(level,target); }
private boolean valid(LivingEntity target) {
return target!=null&&target.isAlive()&&!target.isRemoved()&&target.level()==level()&&!target.isSpectator()&&canAttack(target)
&&!(target instanceof Player p&&p.isCreative());
}
private void recover() { phase=Phase.RECOVERING;remaining=RECOVERY;setPose(Pose.STANDING);HunterJump.hold(this); }
@Override public void move(MoverType type,Vec3 movement) {
if(type==MoverType.SELF&&phase==Phase.LEAPING&&lastPosition!=null&&position().distanceToSqr(lastPosition)>4)recover();
var before=getBoundingBox().getCenter();super.move(type,movement);
if(type!=MoverType.SELF||phase!=Phase.LEAPING||!(level() instanceof ServerLevel level))return;
lastPosition=position();
if(valid(victim)&&ChargeContact.touches(this,victim,before,getBoundingBox().getCenter())&&hasLineOfSight(victim)) {
victim.hurtServer(level,damageSources().mobAttack(this),4);recover();
} else if(horizontalCollision||verticalCollision&&movement.y>0||onGround()&&flightTicks>1)recover();
}
@Override protected void addAdditionalSaveData(ValueOutput out) { super.addAdditionalSaveData(out);out.putInt("PounceCooldown",phase==Phase.IDLE?cooldown:Math.max(cooldown,COOLDOWN)); }
@Override protected void readAdditionalSaveData(ValueInput in) {
super.readAdditionalSaveData(in);cooldown=Math.clamp(in.getIntOr("PounceCooldown",0),0,COOLDOWN);
phase=Phase.IDLE;remaining=0;flightTicks=0;victim=null;lastPosition=null;setPose(Pose.STANDING);setAggressive(false);setDeltaMovement(Vec3.ZERO);
}
private final class PounceGoal extends Goal {
PounceGoal() { setFlags(EnumSet.of(Flag.MOVE,Flag.LOOK,Flag.JUMP)); }
@Override public boolean canUse() {
var target=getTarget();double distance=target==null?0:position().subtract(target.position()).horizontalDistanceSqr();
return phase==Phase.IDLE&&cooldown==0&&onGround()&&!isInWater()&&!isPassenger()&&!isVehicle()&&valid(target)
&&distance>=9&&distance<=100&&Math.abs(target.getY()-getY())<=3&&hasLineOfSight(target);
}
@Override public boolean canContinueToUse() { return phase!=Phase.IDLE; }
@Override public boolean requiresUpdateEveryTick() { return true; }
@Override public void start() { victim=getTarget();phase=Phase.WINDUP;remaining=WINDUP;setPose(Pose.CROUCHING);setAggressive(true);HunterJump.hold(HunterEntity.this); }
@Override public void stop() {
if(phase==Phase.WINDUP)cooldown=Math.max(cooldown,40);
phase=Phase.IDLE;victim=null;lastPosition=null;setPose(Pose.STANDING);setAggressive(false);HunterJump.hold(HunterEntity.this);
}
@Override public void tick() {
if(phase==Phase.RECOVERING) { HunterJump.hold(HunterEntity.this);if(--remaining<=0)phase=Phase.IDLE;return; }
if(!valid(victim)||isPassenger()||isVehicle()) { recover();return; }
if(phase==Phase.WINDUP) {
HunterJump.hold(HunterEntity.this);getLookControl().setLookAt(victim,30,30);
if(level() instanceof ServerLevel level&&remaining%4==0)level.sendParticles(ParticleTypes.DUST_PLUME,getX(),getY()+.1,getZ(),2,.2,.05,.2,.01);
if(--remaining==0) {
if(!onGround()||isInWater()||!hasLineOfSight(victim)||distanceToSqr(victim)>144) { cooldown=Math.max(cooldown,40);recover();return; }
var impulse=HunterJump.impulse(position(),victim.position());
if(!HunterJump.clear(HunterEntity.this,impulse)) { cooldown=Math.max(cooldown,40);recover();return; }
setPose(Pose.STANDING);phase=Phase.LEAPING;flightTicks=0;lastPosition=position();cooldown=COOLDOWN;
setDeltaMovement(impulse);setOnGround(false);syncVelocity=true;
level().playSound(null,blockPosition(),SoundEvents.SPIDER_AMBIENT,SoundSource.HOSTILE,1,1.35F);
}
return;
}
HunterJump.stopSteering(HunterEntity.this);
if(++flightTicks>28||!HunterJump.clearNext(HunterEntity.this)||position().distanceToSqr(lastPosition)>4)recover();
}
}
}
@@ -0,0 +1,27 @@
package fr.koka.sanctuary.infected;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.phys.Vec3;
/** One ballistic impulse, followed exclusively by native gravity and collisions. */
public final class HunterJump {
public static final double UP=.62;
public static Vec3 impulse(Vec3 from,Vec3 to) {
var delta=to.subtract(from).multiply(1,0,1);double distance=delta.length();
return delta.normalize().scale(Math.clamp(distance/8,.35,1.25)).add(0,UP,0);
}
public static void stopSteering(Mob mob) {
mob.getNavigation().stop();mob.getMoveControl().setWantedPosition(mob.getX(),mob.getY(),mob.getZ(),0);
mob.setSpeed(0);mob.setXxa(0);mob.setZza(0);mob.setJumping(false);
}
public static void hold(Mob mob) { stopSteering(mob);mob.setDeltaMovement(0,mob.getDeltaMovement().y,0);mob.syncVelocity=true; }
public static boolean clearNext(Mob mob) {
return clear(mob,mob.getDeltaMovement());
}
public static boolean clear(Mob mob,Vec3 movement) {
var corridor=mob.getBoundingBox().expandTowards(movement);
return ChargeContact.loaded(mob.level(),corridor)&&mob.level().getWorldBorder().isWithinBounds(corridor)
&&corridor.minY>=mob.level().getMinY()&&corridor.maxY<=mob.level().getMaxY();
}
private HunterJump() { }
}
@@ -0,0 +1,91 @@
package fr.koka.sanctuary.infected;
import fr.koka.sanctuary.SanctuaryMod;
import net.fabricmc.fabric.api.biome.v1.BiomeModifications;
import net.fabricmc.fabric.api.biome.v1.BiomeSelectors;
import net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents;
import net.fabricmc.fabric.api.object.builder.v1.entity.FabricDefaultAttributeRegistry;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.monster.Monster;
import net.minecraft.world.item.*;
import net.minecraft.world.level.levelgen.Heightmap;
public final class Infected {
public static final EntityType<InfectedEntity> TYPE = Registry.register(BuiltInRegistries.ENTITY_TYPE,
SanctuaryMod.id("infected"), EntityType.Builder.of(InfectedEntity::new, MobCategory.MONSTER)
.sized(.6F, 1.95F).eyeHeight(1.74F).passengerAttachments(2.0125F)
.clientTrackingRange(8).notInPeaceful()
.build(ResourceKey.create(Registries.ENTITY_TYPE, SanctuaryMod.id("infected"))));
public static final Item SPAWN_EGG = Registry.register(BuiltInRegistries.ITEM,
SanctuaryMod.id("infected_spawn_egg"), new SpawnEggItem(new Item.Properties().spawnEgg(TYPE)
.setId(ResourceKey.create(Registries.ITEM, SanctuaryMod.id("infected_spawn_egg")))));
public static final EntityType<ChargerEntity> CHARGER = Registry.register(BuiltInRegistries.ENTITY_TYPE,
SanctuaryMod.id("charger"), EntityType.Builder.of(ChargerEntity::new, MobCategory.MONSTER)
.sized(.6F,1.95F).eyeHeight(1.74F).passengerAttachments(2.0125F)
.clientTrackingRange(8).notInPeaceful()
.build(ResourceKey.create(Registries.ENTITY_TYPE,SanctuaryMod.id("charger"))));
public static final Item CHARGER_EGG = Registry.register(BuiltInRegistries.ITEM,
SanctuaryMod.id("charger_spawn_egg"),new SpawnEggItem(new Item.Properties().spawnEgg(CHARGER)
.setId(ResourceKey.create(Registries.ITEM,SanctuaryMod.id("charger_spawn_egg")))));
public static final EntityType<SpitterEntity> SPITTER=Registry.register(BuiltInRegistries.ENTITY_TYPE,SanctuaryMod.id("spitter"),
EntityType.Builder.of(SpitterEntity::new,MobCategory.MONSTER).sized(.6F,1.95F).eyeHeight(1.74F).passengerAttachments(2.0125F)
.clientTrackingRange(8).notInPeaceful().build(ResourceKey.create(Registries.ENTITY_TYPE,SanctuaryMod.id("spitter"))));
public static final Item SPITTER_EGG=Registry.register(BuiltInRegistries.ITEM,SanctuaryMod.id("spitter_spawn_egg"),
new SpawnEggItem(new Item.Properties().spawnEgg(SPITTER).setId(ResourceKey.create(Registries.ITEM,SanctuaryMod.id("spitter_spawn_egg")))));
public static final EntityType<BoomerEntity> BOOMER=Registry.register(BuiltInRegistries.ENTITY_TYPE,SanctuaryMod.id("boomer"),
EntityType.Builder.of(BoomerEntity::new,MobCategory.MONSTER).sized(.6F,1.95F).eyeHeight(1.74F).passengerAttachments(2.0125F)
.clientTrackingRange(8).notInPeaceful().build(ResourceKey.create(Registries.ENTITY_TYPE,SanctuaryMod.id("boomer"))));
public static final Item BOOMER_EGG=Registry.register(BuiltInRegistries.ITEM,SanctuaryMod.id("boomer_spawn_egg"),
new SpawnEggItem(new Item.Properties().spawnEgg(BOOMER).setId(ResourceKey.create(Registries.ITEM,SanctuaryMod.id("boomer_spawn_egg")))));
public static final EntityType<SmokerEntity> SMOKER=Registry.register(BuiltInRegistries.ENTITY_TYPE,SanctuaryMod.id("smoker"),
EntityType.Builder.of(SmokerEntity::new,MobCategory.MONSTER).sized(.6F,1.95F).eyeHeight(1.74F).passengerAttachments(2.0125F)
.clientTrackingRange(8).notInPeaceful().build(ResourceKey.create(Registries.ENTITY_TYPE,SanctuaryMod.id("smoker"))));
public static final Item SMOKER_EGG=Registry.register(BuiltInRegistries.ITEM,SanctuaryMod.id("smoker_spawn_egg"),
new SpawnEggItem(new Item.Properties().spawnEgg(SMOKER).setId(ResourceKey.create(Registries.ITEM,SanctuaryMod.id("smoker_spawn_egg")))));
public static final EntityType<HunterEntity> HUNTER=Registry.register(BuiltInRegistries.ENTITY_TYPE,SanctuaryMod.id("hunter"),
EntityType.Builder.of(HunterEntity::new,MobCategory.MONSTER).sized(.6F,1.95F).eyeHeight(1.74F).passengerAttachments(2.0125F)
.clientTrackingRange(8).notInPeaceful().build(ResourceKey.create(Registries.ENTITY_TYPE,SanctuaryMod.id("hunter"))));
public static final Item HUNTER_EGG=Registry.register(BuiltInRegistries.ITEM,SanctuaryMod.id("hunter_spawn_egg"),
new SpawnEggItem(new Item.Properties().spawnEgg(HUNTER).setId(ResourceKey.create(Registries.ITEM,SanctuaryMod.id("hunter_spawn_egg")))));
public static final EntityType<SmokerLassoEntity> SMOKER_LASSO=Registry.register(BuiltInRegistries.ENTITY_TYPE,SanctuaryMod.id("smoker_lasso"),
EntityType.Builder.<SmokerLassoEntity>of(SmokerLassoEntity::new,MobCategory.MISC).sized(.25F,.25F).noSave().noSummon().noLootTable()
.clientTrackingRange(8).updateInterval(1).build(ResourceKey.create(Registries.ENTITY_TYPE,SanctuaryMod.id("smoker_lasso"))));
public static final EntityType<AcidSpitEntity> ACID_SPIT=Registry.register(BuiltInRegistries.ENTITY_TYPE,SanctuaryMod.id("acid_spit"),
EntityType.Builder.<AcidSpitEntity>of(AcidSpitEntity::new,MobCategory.MISC).sized(.25F,.25F).noSave().noSummon().noLootTable()
.clientTrackingRange(8).updateInterval(1).build(ResourceKey.create(Registries.ENTITY_TYPE,SanctuaryMod.id("acid_spit"))));
public static final EntityType<AcidZoneEntity> ACID_ZONE=Registry.register(BuiltInRegistries.ENTITY_TYPE,SanctuaryMod.id("acid_zone"),
EntityType.Builder.<AcidZoneEntity>of(AcidZoneEntity::new,MobCategory.MISC).sized(5,.5F).noSave().noSummon().noLootTable()
.clientTrackingRange(8).updateInterval(5).build(ResourceKey.create(Registries.ENTITY_TYPE,SanctuaryMod.id("acid_zone"))));
public static void register() {
FabricDefaultAttributeRegistry.register(TYPE, InfectedEntity.createAttributes());
FabricDefaultAttributeRegistry.register(CHARGER,ChargerEntity.createAttributes());
FabricDefaultAttributeRegistry.register(SPITTER,SpitterEntity.createAttributes());AcidZoneEntity.register();
FabricDefaultAttributeRegistry.register(BOOMER,BoomerEntity.createAttributes());BoomerBile.register();
FabricDefaultAttributeRegistry.register(SMOKER,SmokerEntity.createAttributes());SmokerLassoEntity.register();
FabricDefaultAttributeRegistry.register(HUNTER,HunterEntity.createAttributes());
SpawnPlacements.register(TYPE, SpawnPlacementTypes.ON_GROUND, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
Monster::checkMonsterSpawnRules);
SpawnPlacements.register(HUNTER,SpawnPlacementTypes.ON_GROUND,Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,Monster::checkMonsterSpawnRules);
SpawnPlacements.register(SMOKER,SpawnPlacementTypes.ON_GROUND,Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,Monster::checkMonsterSpawnRules);
SpawnPlacements.register(BOOMER,SpawnPlacementTypes.ON_GROUND,Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,Monster::checkMonsterSpawnRules);
SpawnPlacements.register(SPITTER,SpawnPlacementTypes.ON_GROUND,Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,Monster::checkMonsterSpawnRules);
SpawnPlacements.register(CHARGER,SpawnPlacementTypes.ON_GROUND,Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
Monster::checkMonsterSpawnRules);
BiomeModifications.addSpawn(BiomeSelectors.foundInOverworld().and(BiomeSelectors.spawnsOneOf(EntityTypes.ZOMBIE)),
MobCategory.MONSTER, TYPE, 15, 1, 3);
BiomeModifications.addSpawn(BiomeSelectors.foundInOverworld().and(BiomeSelectors.spawnsOneOf(EntityTypes.ZOMBIE)),
MobCategory.MONSTER,CHARGER,4,1,1);
BiomeModifications.addSpawn(BiomeSelectors.foundInOverworld().and(BiomeSelectors.spawnsOneOf(EntityTypes.ZOMBIE)),MobCategory.MONSTER,SPITTER,4,1,1);
BiomeModifications.addSpawn(BiomeSelectors.foundInOverworld().and(BiomeSelectors.spawnsOneOf(EntityTypes.ZOMBIE)),MobCategory.MONSTER,BOOMER,4,1,1);
BiomeModifications.addSpawn(BiomeSelectors.foundInOverworld().and(BiomeSelectors.spawnsOneOf(EntityTypes.ZOMBIE)),MobCategory.MONSTER,SMOKER,4,1,1);
BiomeModifications.addSpawn(BiomeSelectors.foundInOverworld().and(BiomeSelectors.spawnsOneOf(EntityTypes.ZOMBIE)),MobCategory.MONSTER,HUNTER,4,1,1);
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.SPAWN_EGGS).register(out -> {out.accept(SPAWN_EGG);out.accept(CHARGER_EGG);out.accept(SPITTER_EGG);out.accept(BOOMER_EGG);out.accept(SMOKER_EGG);out.accept(HUNTER_EGG);});
}
private Infected() { }
}
@@ -0,0 +1,25 @@
package fr.koka.sanctuary.infected;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.ai.attributes.AttributeSupplier;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.monster.zombie.Zombie;
import net.minecraft.world.level.Level;
/** The first infected: native zombie melee, slightly faster and with half the health. */
public final class InfectedEntity extends AbstractInfectedEntity {
public InfectedEntity(EntityType<? extends InfectedEntity> type, Level level) {
super(type, level);
xpReward = 3;
}
public static AttributeSupplier.Builder createAttributes() {
return Zombie.createAttributes()
.add(Attributes.MAX_HEALTH, 10)
.add(Attributes.MOVEMENT_SPEED, .25)
.add(Attributes.FOLLOW_RANGE, 36)
.add(Attributes.ATTACK_DAMAGE, 3);
}
@Override protected boolean convertsInWater() { return false; }
}
@@ -0,0 +1,55 @@
package fr.koka.sanctuary.infected;
import java.util.EnumSet;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.*;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.entity.monster.zombie.Zombie;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.storage.*;
public final class SmokerEntity extends AbstractInfectedEntity {
public static final int WINDUP=18,COOLDOWN=160;
private int windup,cooldown;
public SmokerEntity(EntityType<? extends SmokerEntity> type,Level level) { super(type,level);xpReward=3; }
public static AttributeSupplier.Builder createAttributes() {
return Zombie.createAttributes().add(Attributes.MAX_HEALTH,10).add(Attributes.MOVEMENT_SPEED,.23)
.add(Attributes.ATTACK_DAMAGE,3).add(Attributes.FOLLOW_RANGE,36);
}
public boolean windingUp() { return windup>0; }
public int lassoCooldown() { return cooldown; }
@Override protected boolean convertsInWater() { return false; }
@Override protected void registerGoals() { super.registerGoals();goalSelector.addGoal(-1,new LassoGoal()); }
@Override public void tick() { if(!level().isClientSide()&&cooldown>0)cooldown--;super.tick(); }
@Override public boolean doHurtTarget(ServerLevel level,Entity target) { return windup==0&&SmokerLassoEntity.active(this)==null&&super.doHurtTarget(level,target); }
@Override public boolean hurtServer(ServerLevel level,DamageSource damage,float amount) {
boolean hurt=super.hurtServer(level,damage,amount);var rope=SmokerLassoEntity.active(this);
if(hurt&&rope!=null)rope.discard();return hurt;
}
@Override protected void addAdditionalSaveData(ValueOutput out) { super.addAdditionalSaveData(out);out.putInt("LassoCooldown",windup>0||SmokerLassoEntity.active(this)!=null?Math.max(cooldown,COOLDOWN):cooldown); }
@Override protected void readAdditionalSaveData(ValueInput in) { super.readAdditionalSaveData(in);cooldown=Math.clamp(in.getIntOr("LassoCooldown",0),0,COOLDOWN);windup=0; }
private boolean valid(LivingEntity target) {
return target!=null&&target.isAlive()&&target.level()==level()&&!target.isSpectator()&&!target.isPassenger()&&!target.isVehicle()
&&canAttack(target)&&!(target instanceof Player p&&p.isCreative())&&distanceToSqr(target)<=196&&hasLineOfSight(target);
}
private void hold() { getNavigation().stop();getMoveControl().setWantedPosition(getX(),getY(),getZ(),0);setSpeed(0);setXxa(0);setZza(0);setDeltaMovement(0,getDeltaMovement().y,0); }
private final class LassoGoal extends Goal {
LassoGoal() { setFlags(EnumSet.of(Flag.MOVE,Flag.LOOK)); }
@Override public boolean canUse() { return cooldown==0&&SmokerLassoEntity.active(SmokerEntity.this)==null&&valid(getTarget())&&distanceToSqr(getTarget())>=16; }
@Override public boolean canContinueToUse() { return windup>0||SmokerLassoEntity.active(SmokerEntity.this)!=null; }
@Override public boolean requiresUpdateEveryTick() { return true; }
@Override public void start() { windup=WINDUP;setAggressive(true);hold(); }
@Override public void stop() { if(windup>0)cooldown=Math.max(cooldown,40);windup=0;setAggressive(false);hold(); }
@Override public void tick() {
hold();if(SmokerLassoEntity.active(SmokerEntity.this)!=null)return;
if(!valid(getTarget())) { windup=0;cooldown=Math.max(cooldown,40);return; }
getLookControl().setLookAt(getTarget(),30,30);
if(level() instanceof ServerLevel level&&windup%4==0)level.sendParticles(ParticleTypes.SMOKE,getX(),getEyeY(),getZ(),2,.12,.12,.12,.01);
if(--windup==0) { cooldown=COOLDOWN;SmokerLassoEntity.launch(SmokerEntity.this,getTarget(),null);setAggressive(false); }
}
}
}
@@ -0,0 +1,117 @@
package fr.koka.sanctuary.infected;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.companion.CompanionWorld;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.zipline.*;
import net.fabricmc.fabric.api.attachment.v1.*;
import net.minecraft.core.BlockPos;
import net.minecraft.network.syncher.*;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.*;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.entity.projectile.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.*;
/** A physical, non-homing lasso; only an authoritative entity impact begins a zipline trip. */
public final class SmokerLassoEntity extends Projectile implements ItemSupplier {
private static final AttachmentType<SmokerLassoEntity> ACTIVE=AttachmentRegistry.create(SanctuaryMod.id("smoker_lasso_source"));
private static final AttachmentType<SmokerLassoEntity> PULLED=AttachmentRegistry.create(SanctuaryMod.id("smoker_lasso_victim"));
private static final EntityDataAccessor<Integer> VICTIM=SynchedEntityData.defineId(SmokerLassoEntity.class,EntityDataSerializers.INT);
private LivingEntity source,victim;
private FamiliarTechniques.Cast familiar;
private Vec3 anchor,start,end,expected;
private float sourceHealth;
private int flightTicks,rideTicks;
private double progress;
public SmokerLassoEntity(EntityType<? extends SmokerLassoEntity> type,Level level) { super(type,level);setNoGravity(true);noPhysics=true; }
public static void register() { java.util.Objects.requireNonNull(ACTIVE);java.util.Objects.requireNonNull(PULLED); }
public static SmokerLassoEntity active(LivingEntity source) { return source.getAttached(ACTIVE); }
public static boolean pulled(LivingEntity target) { return target.getAttached(PULLED)!=null; }
public static Vec3 castPoint(LivingEntity source) {
return source instanceof fr.koka.sanctuary.cosmetics.FamiliarEntity pet?source.position().add(0,pet.visualHeight()*.85,0):source.getEyePosition();
}
public static SmokerLassoEntity launch(LivingEntity source,LivingEntity target,FamiliarTechniques.Cast cast) {
if(!(source.level() instanceof ServerLevel level)||!source.isAlive()||source.isRemoved()||source.isPassenger()||source.isVehicle()
||active(source)!=null||!eligible(target)||pulled(target)||source.level()!=target.level()||!source.hasLineOfSight(target))return null;
var hook=Infected.SMOKER_LASSO.create(level,EntitySpawnReason.MOB_SUMMONED);
hook.source=source;hook.familiar=cast;hook.anchor=source.position();hook.sourceHealth=source.getHealth();hook.setOwner(source);
var from=castPoint(source);var direction=target.getBoundingBox().getCenter().subtract(from).normalize();
hook.setPos(from.add(direction.scale(.35)));hook.setDeltaMovement(direction.scale(1.05));
if(!ChargeContact.loaded(level,hook.getBoundingBox())||!level.addFreshEntity(hook))return null;
source.setAttached(ACTIVE,hook);
level.playSound(null,source.blockPosition(),SoundEvents.FISHING_BOBBER_THROW,SoundSource.HOSTILE,.9F,.65F);
return hook;
}
@Override protected void defineSynchedData(SynchedEntityData.Builder builder) { builder.define(VICTIM,-1); }
public boolean isReeling() { return entityData.get(VICTIM)>=0; }
public Entity caughtEntity() { return level().getEntity(entityData.get(VICTIM)); }
private static boolean eligible(LivingEntity target) {
return target!=null&&target.isAlive()&&!target.isRemoved()&&!target.isSpectator()&&!target.isPassenger()&&!target.isVehicle()
&&!target.isSleeping()&&!(target instanceof Player p&&p.isCreative())&&!CompanionWorld.boss(target);
}
private boolean permitted(LivingEntity target) {
if(!eligible(target)||target==source||target.level()!=level())return false;
return familiar==null?!(target instanceof AbstractInfectedEntity)&&source.canAttack(target)
:FamiliarDuels.castAllowed(familiar)&&FamiliarBattle.mayHit(familiar.pet,target);
}
@Override protected boolean canHitEntity(Entity entity) {
if(!super.canHitEntity(entity))return false;
if(level().isClientSide())return true;
return entity instanceof LivingEntity living&&permitted(living)&&!pulled(living);
}
@Override public void tick() {
if(isRemoved())return;
super.tick();
if(level().isClientSide()) {
var caught=caughtEntity();if(caught!=null)setPos(caught.getBoundingBox().getCenter());else setPos(position().add(getDeltaMovement()));
return;
}
if(source==null||getOwner()!=source||!source.isAlive()||source.isRemoved()||source.level()!=level()||source.isPassenger()||source.isVehicle()
||source.getHealth()<sourceHealth-.001F||source.position().distanceToSqr(anchor)>.64
||familiar!=null&&(familiar.owner.level()!=level()||!FamiliarBattle.active(familiar.owner)||!FamiliarDuels.castAllowed(familiar))) { discard();return; }
if(isReeling()) { ride();return; }
var movement=getDeltaMovement();var corridor=getBoundingBox().expandTowards(movement);
if(++flightTicks>26||position().distanceToSqr(anchor)>400||!ChargeContact.loaded(level(),corridor)
||!level().getWorldBorder().isWithinBounds(corridor)) { discard();return; }
var hit=ProjectileUtil.getHitResultOnMoveVector(this,this::canHitEntity);
if(hit.getType()!=HitResult.Type.MISS) {
setPos(hit.getLocation());hitTargetOrDeflectSelf(hit);
}else setPos(position().add(movement));
}
@Override protected void onHit(HitResult hit) {
if(level().isClientSide())return;
if(!(hit instanceof EntityHitResult entity)||!(entity.getEntity() instanceof LivingEntity target)||!permitted(target)||pulled(target)) { discard();return; }
if(familiar!=null&&(!FamiliarTechniques.hit(familiar,target,familiar.technique.power())||!FamiliarTechniques.control(target))) { discard();return; }
if(isRemoved()||!source.isAlive()||!eligible(target)) { discard();return; }
var delta=target.position().subtract(anchor).multiply(1,0,1).normalize();
start=target.position();end=anchor.add(delta.scale(1.8));expected=start;
if(start.distanceToSqr(end)<.25) { discard();return; }
if(target instanceof ServerPlayer player) { PortableZipline.stop(player);PulleyHeadController.stopRide(player,false); }
victim=target;target.setAttached(PULLED,this);entityData.set(VICTIM,target.getId());setPos(target.getBoundingBox().getCenter());setDeltaMovement(Vec3.ZERO);
level().playSound(null,target.blockPosition(),SoundEvents.LEAD_TIED,SoundSource.HOSTILE,1,.8F);
}
private void ride() {
if(!permitted(victim)||victim.position().distanceToSqr(expected)>4||++rideTicks>60||!source.hasLineOfSight(victim)) { discard();return; }
double length=start.distanceTo(end);progress=Math.min(1,progress+ZiplineMotion.SPEED/length);
var next=start.lerp(end,progress);var movement=next.subtract(victim.position());
if(familiar!=null&&!CompanionWorld.permitted(familiar.owner,BlockPos.containing(next),"attack")) { discard();return; }
if(!ZiplineMotion.clear(victim,movement)) { ZiplineMotion.fall(victim,movement);discard();return; }
victim.teleportTo(next.x,next.y,next.z);victim.setDeltaMovement(Vec3.ZERO);victim.resetFallDistance();victim.syncVelocity=true;expected=next;
setPos(victim.getBoundingBox().getCenter());if(progress>=1)discard();
}
@Override public void remove(RemovalReason reason) {
if(source!=null&&active(source)==this)source.removeAttached(ACTIVE);
if(victim!=null&&victim.getAttached(PULLED)==this)victim.removeAttached(PULLED);
super.remove(reason);
}
@Override public ItemStack getItem() { return new ItemStack(Items.LEAD); }
@Override public boolean isPickable() { return true; }
@Override public boolean hurtServer(ServerLevel level,DamageSource damage,float amount) { if(amount<=0)return false;discard();return true; }
@Override public boolean shouldRenderAtSqrDistance(double distance) { return distance<64*64; }
}
@@ -0,0 +1,54 @@
package fr.koka.sanctuary.infected;
import java.util.EnumSet;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.*;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.entity.monster.zombie.Zombie;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.storage.*;
public final class SpitterEntity extends AbstractInfectedEntity {
public static final int WINDUP=14,COOLDOWN=100;
private int windup,cooldown;
public SpitterEntity(EntityType<? extends SpitterEntity> type,Level level) { super(type,level);xpReward=3; }
public static AttributeSupplier.Builder createAttributes() {
return Zombie.createAttributes().add(Attributes.MAX_HEALTH,10).add(Attributes.MOVEMENT_SPEED,.24)
.add(Attributes.ATTACK_DAMAGE,3).add(Attributes.FOLLOW_RANGE,36);
}
public boolean windingUp() { return windup>0; }
public int spitCooldown() { return cooldown; }
@Override protected boolean convertsInWater() { return false; }
@Override protected void registerGoals() { super.registerGoals();goalSelector.addGoal(-1,new SpitGoal()); }
@Override public void tick() { if(!level().isClientSide()&&cooldown>0)cooldown--;super.tick(); }
@Override public boolean doHurtTarget(ServerLevel level,Entity target) { return windup==0&&super.doHurtTarget(level,target); }
@Override protected void addAdditionalSaveData(ValueOutput out) { super.addAdditionalSaveData(out);out.putInt("SpitCooldown",windup>0?Math.max(cooldown,COOLDOWN):cooldown); }
@Override protected void readAdditionalSaveData(ValueInput in) { super.readAdditionalSaveData(in);cooldown=Math.clamp(in.getIntOr("SpitCooldown",0),0,COOLDOWN);windup=0; }
private boolean valid(LivingEntity target) {
if(target==null||!target.isAlive()||target.level()!=level()||target.isSpectator()||!canAttack(target)||target instanceof Player p&&p.isCreative())return false;
double distance=distanceToSqr(target);return distance>=9&&distance<=225&&hasLineOfSight(target);
}
private void hold() { getNavigation().stop();getMoveControl().setWantedPosition(getX(),getY(),getZ(),0);setSpeed(0);setXxa(0);setZza(0);setDeltaMovement(0,getDeltaMovement().y,0); }
private final class SpitGoal extends Goal {
SpitGoal() { setFlags(EnumSet.of(Flag.MOVE,Flag.LOOK)); }
@Override public boolean canUse() { return valid(getTarget()); }
@Override public boolean canContinueToUse() { return canUse(); }
@Override public boolean requiresUpdateEveryTick() { return true; }
@Override public void stop() { if(windup>0)cooldown=Math.max(cooldown,20);windup=0;setAggressive(false); }
@Override public void tick() {
var target=getTarget();hold();getLookControl().setLookAt(target,30,30);
if(cooldown>0)return;
if(windup==0) { windup=WINDUP;setAggressive(true);level().playSound(null,blockPosition(),SoundEvents.LLAMA_SPIT,SoundSource.HOSTILE,.9F,.7F); }
if(level() instanceof ServerLevel level&&windup%3==0)level.sendParticles(ParticleTypes.ITEM_SLIME,getX(),getEyeY(),getZ(),2,.12,.12,.12,.01);
if(--windup<=0) {
var shot=new AcidSpitEntity(level(),SpitterEntity.this);shot.setPos(getX(),getEyeY()-.15,getZ());
var aim=target.getBoundingBox().getCenter().subtract(shot.position());shot.shoot(aim.x,aim.y,aim.z,1.12F,0);
level().addFreshEntity(shot);cooldown=COOLDOWN;setAggressive(false);
}
}
}
}
@@ -1,6 +1,7 @@
package fr.koka.sanctuary.mixin;
import java.util.List;
import java.util.ArrayList;
import java.util.Set;
import net.fabricmc.loader.api.FabricLoader;
import org.objectweb.asm.tree.ClassNode;
@@ -10,8 +11,14 @@ import org.spongepowered.asm.mixin.extensibility.IMixinInfo;
/** Do not even inspect optional targets when their owning mod is absent. */
public final class OptionalModMixins implements IMixinConfigPlugin {
@Override public List<String> getMixins() {
return FabricLoader.getInstance().isModLoaded("sound_physics_remastered")
? List.of("SoundPhysicsDefaultMixin") : List.of();
var loader=FabricLoader.getInstance();
var mixins=new ArrayList<String>();
if(loader.isModLoaded("sound_physics_remastered"))mixins.add("SoundPhysicsDefaultMixin");
if(loader.getEnvironmentType()==net.fabricmc.api.EnvType.CLIENT && loader.isModLoaded("sodium")) {
mixins.add("client.SodiumLightContextMixin");
mixins.add("client.SodiumLightSliceMixin");
}
return mixins;
}
@Override public void onLoad(String mixinPackage) {}
@Override public String getRefMapperConfig() { return null; }
@@ -0,0 +1,16 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.DynamicLightContext;
import fr.koka.sanctuary.lighting.DynamicLightField;
import net.caffeinemc.mods.sodium.client.world.cloned.ChunkRenderContext;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
/** Optional Sodium 0.9.2+mc26.3 bridge; never reads the live world on a worker. */
@Mixin(ChunkRenderContext.class)
abstract class SodiumLightContextMixin implements DynamicLightContext {
@Unique private DynamicLightField.Snapshot sanctuary$lights=DynamicLightField.Snapshot.EMPTY;
public void sanctuary$captureLights(DynamicLightField.Snapshot snapshot) {sanctuary$lights=snapshot;}
public DynamicLightField.Snapshot sanctuary$lightSnapshot() {return sanctuary$lights;}
}
@@ -0,0 +1,44 @@
package fr.koka.sanctuary.mixin.client;
import com.llamalad7.mixinextras.injector.ModifyReturnValue;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.lighting.DynamicLightField;
import net.caffeinemc.mods.sodium.client.world.LevelSlice;
import net.caffeinemc.mods.sodium.client.world.cloned.ChunkRenderContext;
import net.caffeinemc.mods.sodium.client.world.cloned.ClonedChunkSectionCache;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.BlockPos;
import net.minecraft.core.SectionPos;
import net.minecraft.util.LightCoordsUtil;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LightLayer;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.*;
/** Sodium replaces RenderSectionRegion; give each reusable worker slice its job's snapshot. */
@Mixin(LevelSlice.class)
abstract class SodiumLightSliceMixin implements DynamicLightRegion {
@Unique private DynamicLightField.Snapshot sanctuary$lights=DynamicLightField.Snapshot.EMPTY;
@Inject(method="prepare",at=@At("RETURN"))
private static void sanctuary$capture(Level level,SectionPos origin,ClonedChunkSectionCache cache,CallbackInfoReturnable<ChunkRenderContext> cir) {
if(cir.getReturnValue()!=null && level instanceof ClientLevel client)
((DynamicLightContext)cir.getReturnValue()).sanctuary$captureLights(DynamicLightClient.snapshot(client));
}
@Inject(method="copyData",at=@At("HEAD"))
private void sanctuary$copy(ChunkRenderContext context,CallbackInfo ci) {
sanctuary$lights=((DynamicLightContext)context).sanctuary$lightSnapshot();
}
@Inject(method="reset",at=@At("RETURN"))
private void sanctuary$reset(CallbackInfo ci) {sanctuary$lights=DynamicLightField.Snapshot.EMPTY;}
// Sodium's AO/flat/fluid pipelines also read brightness directly, bypassing LightCoordsUtil.
@ModifyReturnValue(method="getBrightness",at=@At("RETURN"))
private int sanctuary$brightness(int brightness,LightLayer layer,BlockPos pos) {
return layer==LightLayer.BLOCK?LightCoordsUtil.block(sanctuary$lights.apply(brightness<<4,
pos.getX()+.5,pos.getY()+.5,pos.getZ()+.5)):brightness;
}
public DynamicLightField.Snapshot sanctuary$lightSnapshot() {return sanctuary$lights;}
}
@@ -35,7 +35,7 @@ public final class PortableZipline {
}
public static boolean hasActive(ServerPlayer player) { return ACTIVE.containsKey(player.getUUID()); }
public static boolean launch(ServerPlayer player, InteractionHand hand, ItemStack stack, float charge) {
if (hasActive(player) || !stack.is(Ziplines.GRAPPLING_ROD) || !player.isAlive() || player.isPassenger() || player.isSpectator()) return false;
if (fr.koka.sanctuary.infected.SmokerLassoEntity.pulled(player) || hasActive(player) || !stack.is(Ziplines.GRAPPLING_ROD) || !player.isAlive() || player.isPassenger() || player.isSpectator()) return false;
ItemStack lead=ItemStack.EMPTY;
if (!player.getAbilities().instabuild) {
for (var candidate:player.getInventory().getNonEquipmentItems()) if(candidate.is(Items.LEAD)) { lead=candidate; break; }
@@ -119,6 +119,7 @@ public final class PulleyHeadController {
return InteractionResult.CONSUME;
}
if(fr.koka.sanctuary.infected.SmokerLassoEntity.pulled(serverPlayer))return InteractionResult.CONSUME;
BlockPos targetPos = connectedPos(level, pos);
if (targetPos != null && !level.hasChunkAt(targetPos)) {
message(serverPlayer, "message.sanctuary.pulley.unloaded");
@@ -2,12 +2,16 @@ package fr.koka.sanctuary.zipline;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.phys.*;
/** Checks the whole rider, including intermediate positions, without loading terrain. */
public final class ZiplineMotion {
public static final double SPEED = .58;
public static boolean clear(ServerPlayer player, Vec3 movement) {
return clear((LivingEntity)player,movement);
}
public static boolean clear(LivingEntity player, Vec3 movement) {
if (!Double.isFinite(movement.lengthSqr()) || movement.lengthSqr() > 256) return false;
var level = player.level();
int steps = Math.max(1, (int)Math.ceil(movement.length()/.20));
@@ -23,6 +27,9 @@ public final class ZiplineMotion {
return true;
}
public static void fall(ServerPlayer player, Vec3 movement) {
fall((LivingEntity)player,movement);
}
public static void fall(LivingEntity player, Vec3 movement) {
player.setDeltaMovement(new Vec3(movement.x*.25, Math.min(movement.y,-.18), movement.z*.25));
player.syncVelocity = true;
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:item/boomer_spawn_egg"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:item/charger_spawn_egg"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:item/hunter_spawn_egg"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:item/infected_spawn_egg"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:item/smoker_spawn_egg"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:item/spitter_spawn_egg"
}
}
@@ -3149,5 +3149,24 @@
"sanctuary.claim_duel.outcome.storage": "Claim settlement is suspended; its journal is retained for recovery.",
"sanctuary.claims.duel_locked": "This claim is committed to a duel; wait for settlement.",
"sanctuary.arena.claim_changed": "The staked claim changed; Battle cancelled without transfer.",
"sanctuary.claims.atlas_required": "Unlock and enable the Atlas to access properties."
"sanctuary.claims.atlas_required": "Unlock and enable the Atlas to access properties.",
"sanctuary.atlas.blocks_per_pixel": "1 px ≈ %s blocks",
"sanctuary.atlas.overview_status": "Overview · zoom in for properties and Demeure",
"sanctuary.atlas.operator_overview_status": "Operator overview · zoom in for details",
"entity.sanctuary.infected": "Infected",
"item.sanctuary.infected_spawn_egg": "Infected Spawn Egg",
"sanctuary.familiar.no_work": "This familiar has no work ability.",
"entity.sanctuary.charger": "Charger",
"item.sanctuary.charger_spawn_egg": "Charger Spawn Egg",
"entity.sanctuary.spitter": "Spitter",
"item.sanctuary.spitter_spawn_egg": "Spitter Spawn Egg",
"entity.sanctuary.acid_spit": "Acid spit",
"entity.sanctuary.acid_zone": "Acid zone",
"entity.sanctuary.boomer": "Boomer",
"item.sanctuary.boomer_spawn_egg": "Boomer Spawn Egg",
"entity.sanctuary.smoker": "Smoker",
"item.sanctuary.smoker_spawn_egg": "Smoker Spawn Egg",
"entity.sanctuary.smoker_lasso": "Smoker lasso",
"entity.sanctuary.hunter": "Hunter",
"item.sanctuary.hunter_spawn_egg": "Hunter Spawn Egg"
}
@@ -3149,5 +3149,24 @@
"sanctuary.claim_duel.outcome.storage": "Le règlement du claim est suspendu ; le journal est conservé pour reprise.",
"sanctuary.claims.duel_locked": "Ce claim est engagé dans un duel ; attendez son règlement.",
"sanctuary.arena.claim_changed": "Le claim du duel a changé ; Battle annulé sans transfert.",
"sanctuary.claims.atlas_required": "Débloquez et activez la Carte pour accéder aux propriétés."
"sanctuary.claims.atlas_required": "Débloquez et activez la Carte pour accéder aux propriétés.",
"sanctuary.atlas.blocks_per_pixel": "1 px ≈ %s blocs",
"sanctuary.atlas.overview_status": "Vue d’ensemble · zoomer pour les propriétés et Demeure",
"sanctuary.atlas.operator_overview_status": "Vue opérateur · zoomer pour les détails",
"entity.sanctuary.infected": "Infecté",
"item.sanctuary.infected_spawn_egg": "Œuf d’apparition d’infecté",
"sanctuary.familiar.no_work": "Ce familier n’a pas d’aptitude de travail.",
"entity.sanctuary.charger": "Chargeur",
"item.sanctuary.charger_spawn_egg": "Œuf d’apparition de chargeur",
"entity.sanctuary.spitter": "Cracheur",
"item.sanctuary.spitter_spawn_egg": "Œuf d’apparition de cracheur",
"entity.sanctuary.acid_spit": "Crachat acide",
"entity.sanctuary.acid_zone": "Zone d’acide",
"entity.sanctuary.boomer": "Boomer",
"item.sanctuary.boomer_spawn_egg": "Œuf d’apparition de boomer",
"entity.sanctuary.smoker": "Smoker",
"item.sanctuary.smoker_spawn_egg": "Œuf d’apparition de smoker",
"entity.sanctuary.smoker_lasso": "Lasso du Smoker",
"entity.sanctuary.hunter": "Hunter",
"item.sanctuary.hunter_spawn_egg": "Œuf d’apparition de hunter"
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "sanctuary:item/boomer_spawn_egg"
}
}

Some files were not shown because too many files have changed in this diff Show More