Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72f1cf50ae | ||
|
|
6821f6ee12 | ||
|
|
85d274f2ef | ||
|
|
235e3c08b7 |
@@ -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>`.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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`.
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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é.
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||

|
||||

|
||||
|
||||
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é.
|
||||
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||
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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"
|
||||
|
||||
+153
@@ -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);}
|
||||
}
|
||||
+140
@@ -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);}
|
||||
}
|
||||
+155
@@ -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);}
|
||||
}
|
||||
+104
@@ -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);}
|
||||
}
|
||||
+112
@@ -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);}}
|
||||
}
|
||||
+172
@@ -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);}
|
||||
}
|
||||
+153
@@ -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);}
|
||||
}
|
||||
+110
@@ -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()&¤t.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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
@@ -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;
|
||||
}
|
||||
}
|
||||
+38
@@ -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); }
|
||||
}
|
||||
+3
-1
@@ -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); }
|
||||
}
|
||||
+171
@@ -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; }
|
||||
|
||||
+16
@@ -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;}
|
||||
}
|
||||
+44
@@ -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
Reference in New Issue
Block a user