Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c51e611f4f | ||
|
|
f4d3dad1ca | ||
|
|
653a063b15 | ||
|
|
9d3bc0cee2 |
@@ -6,6 +6,87 @@ 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.269 — Les réponses de Providence
|
||||
|
||||
[Six issues et leurs couleurs](docs/providence-verdicts-beta269.md) :
|
||||
Exaucée en vert, Différée en jaune, Refusée en rouge, Incomprise en gris,
|
||||
Alternative en violet ; Silence sans message visible. Couleurs conservées
|
||||
en SGA et en lecture normale FR/EN. Un verdict différé programme réellement
|
||||
le miracle plus tard ; une alternative accorde une autre aide admissible.
|
||||
Build, **191 vérifications indépendantes**, **22 GameTests serveur** et parcours
|
||||
**Vulkan FR/EN GUI 2/3/4** réussis. [Pack local beta.269](build/Sanctuary-beta.269.mrpack).
|
||||
|
||||
## beta.268 — Les quatorze prières
|
||||
|
||||
[Catalogue complet de Providence](docs/providence-fourteen-beta268.md) :
|
||||
récupération, retour, secours, soleil, pluie, guérison, nourriture, foudre,
|
||||
malédiction, vengeance, vol, désarmement, téléportation et réparation.
|
||||
Ciblage par `@Pseudo`, prières hostiles autorisées contre tous les joueurs
|
||||
connectés, transferts réels des objets et scellement des tombes consommées.
|
||||
L’attente de cinq secondes et les couleurs SGA sont conservées.
|
||||
Build, **146 vérifications indépendantes**, **20 GameTests serveur** et parcours
|
||||
client **Vulkan** réussis. [Pack local beta.268](build/Sanctuary-beta.268.mrpack).
|
||||
|
||||
## beta.267 — Providence prend le temps de répondre
|
||||
|
||||
[Attente de cinq secondes après `/pray`](docs/providence-delay-beta267.md),
|
||||
réglable entre 1 et 60 secondes. Une seule prière en attente par joueur,
|
||||
répétitions ignorées, conditions vérifiées à l'échéance et réponses colorées
|
||||
conservées. Build, 83 vérifications indépendantes, 15 GameTests serveur et
|
||||
parcours Vulkan avec réponse après cinq secondes réussis.
|
||||
[Pack local beta.267](build/Sanctuary-beta.267.mrpack).
|
||||
|
||||
## beta.266 — Couleurs de Providence
|
||||
|
||||
[Réponses SGA selon le verdict](docs/providence-colors-beta266.md) : vert pour
|
||||
un miracle accordé, jaune pour une attente ou une prière à clarifier, rouge
|
||||
pour un refus ou un échec. Build, 67 vérifications indépendantes, 15 GameTests
|
||||
serveur et parcours Vulkan FR/EN GUI 2/3/4 réussis.
|
||||
[Pack local beta.266](build/Sanctuary-beta.266.mrpack).
|
||||
|
||||
## beta.265 — Providence
|
||||
|
||||
[Prières et premiers miracles](docs/providence-beta265.md) : `/pray` interprète
|
||||
les concepts d'un dictionnaire JSON rechargeable, puis décide côté serveur
|
||||
d'éclaircir le temps ou de ramener le joueur près de sa dernière mort, si une
|
||||
arrivée sûre est disponible dans les chunks chargés. Probabilités et cooldowns
|
||||
persistants, journal borné, réponses SGA et lecture normale FR/EN avec
|
||||
`/providence readable true`. Restitution des objets et secours préparés,
|
||||
**désactivés** en attendant leurs contrats de sécurité.
|
||||
Build, **67 vérifications indépendantes**, **15 GameTests serveur** et parcours
|
||||
client **Vulkan FR/EN GUI 2/3/4** réussis.
|
||||
[Pack local beta.265](build/Sanctuary-beta.265.mrpack).
|
||||
|
||||
## beta.264 — Menu central et aperçus alignés
|
||||
|
||||
[Formulaire de la largeur de la hotbar native](docs/hello-layout-beta264.md) :
|
||||
couleur, biographie, choix et nom du familier regroupés au centre. Pseudo et
|
||||
nom au-dessus des aperçus 3D, sol commun et familiers bébé miniatures.
|
||||
Détails de l'espèce uniquement au survol, titre Hello World retiré.
|
||||
Retour séparé, Entrer dans Sanctuary dans le pied de page natif des menus.
|
||||
Build, 13 GameTests serveur et parcours Vulkan FR/EN GUI 2/3/4 réussis,
|
||||
avec 44 modèles bébé, admission, reconnexion et code d'accès vérifiés.
|
||||
[Pack local beta.264](build/Sanctuary-beta.264.mrpack).
|
||||
|
||||
## beta.263 — Bébés familiers dans Hello World
|
||||
|
||||
[Formes bébé natives dans l'aperçu 3D](docs/hello-baby-familiars-beta263.md) :
|
||||
modèles, textures et échelles d'âge adaptés au familier remis en jeu, avec
|
||||
suivi de la souris. Les 44 aperçus sont comparés aux proxies natifs et vérifiés
|
||||
à froid en Vulkan FR/EN GUI 2/3/4. Build, 13 GameTests serveur et export
|
||||
du [pack local beta.263](build/Sanctuary-beta.263.mrpack) vérifiés.
|
||||
|
||||
## beta.262 — Nouveau menu Hello World
|
||||
|
||||
[Personnage et familier dans un menu centré](docs/hello-world-menu-beta262.md) :
|
||||
aperçu du personnage à gauche, familiers à droite, biographie et nom au centre
|
||||
dans deux blocs symétriques. Texture native de hotbar pour neuf couleurs par
|
||||
page, flèches autour du pseudo, fond texturé uniforme et aperçus 3D qui suivent
|
||||
la souris. Saisies et sélection conservées, libellés FR/EN.
|
||||
Build, 13 GameTests serveur et parcours Vulkan FR/EN GUI 2/3/4 réussis,
|
||||
avec les 44 modèles de départ, création, reconnexion et code d’accès vérifiés.
|
||||
[Pack local beta.262](build/Sanctuary-beta.262.mrpack).
|
||||
|
||||
## beta.261 — Alt bref et Alt maintenu
|
||||
|
||||
[Un seul raccourci pour l’attaque et les ordres](docs/familiar-alt-beta261.md) :
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# Backlog Sanctuary
|
||||
|
||||
## HELLO-264 — Menu central et sol commun — livré localement
|
||||
|
||||
Sur `codex/hello-world-menu`, socle beta.263. Colonne centrale de 182 pixels,
|
||||
choix regroupés par section, noms au-dessus des aperçus et familiers bébé
|
||||
miniatures alignés avec les pieds du personnage. Espèce, rareté et description
|
||||
au survol ; Retour séparé et Entrer dans Sanctuary dans le pied de page natif.
|
||||
Build, 13 GameTests serveur et parcours Vulkan FR/EN GUI 2/3/4 réussis ;
|
||||
44 modèles bébé, création, reconnexion et code d'accès vérifiés.
|
||||
Pack local beta.264 exporté et contrôlé.
|
||||
[Disposition et vérifications](hello-layout-beta264.md).
|
||||
|
||||
## HELLO-263 — Aperçus des bébés familiers — livré localement
|
||||
|
||||
Sur `codex/hello-world-menu`, socle beta.262. Modèles et textures bébé, échelles
|
||||
d'âge natives et cadrage adapté. Les 44 aperçus sont comparés aux proxies
|
||||
réels après `Mob.setBaby(true)` ; parcours Vulkan FR/EN GUI 2/3/4 réussi.
|
||||
Build, 13 GameTests serveur et pack local vérifiés.
|
||||
[Correction et vérifications](hello-baby-familiars-beta263.md).
|
||||
|
||||
## HELLO-262 — Personnage, couleurs et familier — livré localement
|
||||
|
||||
Branche `codex/hello-world-menu`, socle beta.261. Menu à trois colonnes, blocs
|
||||
centraux de même hauteur et colonnes latérales de même largeur. Palette serveur
|
||||
paginée par neuf avec la texture native de hotbar et les flèches du pseudo.
|
||||
Fond texturé uniforme ; personnage et familier en 3D suivent la souris.
|
||||
Biographie et nom au centre. Les règles et sauvegardes serveur sont conservées.
|
||||
Build, 13 GameTests serveur et parcours Vulkan FR/EN GUI 2/3/4 réussis ;
|
||||
44 modèles de départ, admission, reconnexion et code d’accès vérifiés.
|
||||
[Contrat et vérifications](hello-world-menu-beta262.md).
|
||||
|
||||
## SHADER-258 — Ombres des falaises et musique différée — livré localement
|
||||
|
||||
Branche `codex/zenith-shadows-beta258`, socle beta.257. Comparaison de profondeur
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# HELLO-263 — Bébés familiers dans Hello World
|
||||
|
||||
Correction demandée le 16 octobre 2026 sur `codex/hello-world-menu`, après
|
||||
beta.262. Minecraft **26.3**, livraison locale **beta.263**.
|
||||
|
||||
La disposition est ensuite ajustée dans [HELLO-264](hello-layout-beta264.md) :
|
||||
colonne de la largeur de la hotbar et aperçus miniatures sur un sol commun.
|
||||
|
||||
Le familier est présenté dans sa forme bébé native, comme le proxy de rendu
|
||||
en jeu après `Mob.setBaby(true)`. Le choix concerne le modèle, les textures
|
||||
bébé et l'échelle d'âge ; le cadrage tient compte de la taille réduite.
|
||||
Les textures du chat et du loup sont explicitement remplacées par celles
|
||||
du chaton et du louveteau. Les espèces sans forme bébé native gardent leur
|
||||
apparence habituelle de familier miniature.
|
||||
Le chaton reprend la variante noire par défaut du proxy natif ; le poisson
|
||||
tropical reprend également son motif natif par défaut.
|
||||
|
||||
Le rendu reste utilisable avant la liaison des composants d'items et avant
|
||||
l'arrivée d'un monde client. Aucun changement des familiers remis, des règles
|
||||
serveur ou des sauvegardes. Hotbar de neuf couleurs, fond texturé, skin du
|
||||
personnage et suivi de la souris conservés.
|
||||
|
||||
## Vérifications
|
||||
|
||||
Le parcours Hello World existant est étendu : après le contrôle à froid des
|
||||
44 modèles/textures, l'âge, l'échelle d'âge et la texture de chaque aperçu
|
||||
sont comparés à un vrai proxy natif créé dans un monde de développement
|
||||
neuf, puis passé par `Mob.setBaby(true)`. Ces proxies ne sont jamais ajoutés
|
||||
au monde ou simulés. Le personnage, la pagination, les saisies, l'admission,
|
||||
la reconnexion et le code d'accès restent couverts par le même parcours.
|
||||
|
||||
```sh
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
|
||||
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
|
||||
-PsanctuaryQuickTests=true -PsanctuaryHello262ClientTests=true \
|
||||
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
|
||||
-PsanctuaryClientNoVsync=true
|
||||
./gradlew check build assemblePack \
|
||||
-PsanctuaryFocusedTests=progression,starter,starter052 -PsanctuaryAtlasOnly=true
|
||||
```
|
||||
|
||||
Parcours client réussi en **1 min 26 s**, **Vulkan / MoltenVK 1.4.2 sur
|
||||
Apple M1**, avec 8 Gio maximum. Journal : `build/hello263-client-vulkan.log`,
|
||||
marqueurs `HELLO263_BABIES_PASS`, `HELLO262_MODELS_PASS` et
|
||||
`HELLO262_CLIENT_PASS`. Les 44 âges, échelles d'âge et textures correspondent
|
||||
aux proxies natifs ; la création, la reconnexion et le code d'accès passent.
|
||||
24 captures conservées dans `build/hello263-screenshots/` ; galerie à froid
|
||||
et poussin dans le menu inspectés.
|
||||
|
||||
`check build assemblePack` réussit en **3 min 4 s**, avec **13 GameTests
|
||||
serveur requis réussis**. Journal : `build/hello263-check-build-pack.log`.
|
||||
Le [pack local beta.263](../build/Sanctuary-beta.263.mrpack) est exporté et
|
||||
vérifié : intégrité ZIP, version, Minecraft 26.3, JAR identique au build et
|
||||
classes de test absentes. Face à beta.262, seule la classe de production
|
||||
`HelloFamiliarPreview` change. Reçu : `build/hello263-validation.json`.
|
||||
|
||||
Aucune publication ou installation personnelle effectuée. Les variantes
|
||||
visuelles restent celles par défaut ; Windows et serveur distant restent
|
||||
hors des essais.
|
||||
@@ -0,0 +1,74 @@
|
||||
# HELLO-264 — Colonne centrale et sol commun
|
||||
|
||||
Demande du 16 octobre 2026 sur `codex/hello-world-menu`, après beta.263.
|
||||
Minecraft **26.3**, livraison locale **beta.264**.
|
||||
|
||||
Le titre Hello World est retiré de l'affichage. Le menu central reprend les
|
||||
**182 pixels de la hotbar native**, avec neuf couleurs par page. La section
|
||||
Personnage contient le choix de couleur puis « Écris ta biographie ». Elle est
|
||||
suivie du choix parmi les trois œufs et du nom du familier. Les noms sont
|
||||
placés au-dessus des aperçus : pseudo coloré à gauche, nom saisi à droite.
|
||||
Les flèches restent dans la largeur de la colonne ; leur infobulle indique
|
||||
la page courante. Libellés français et anglais.
|
||||
|
||||
Les deux aperçus partagent la même hauteur de sol. Le familier garde sa forme
|
||||
bébé et une taille miniature ; son échelle ne cherche plus à remplir la zone.
|
||||
Le cadrage est limité à l'aperçu. Le personnage compense la rotation verticale
|
||||
pour garder ses pieds au sol. Les deux modèles suivent toujours la souris.
|
||||
L'espèce, la rareté et la description apparaissent uniquement au survol de
|
||||
l'œuf ou de l'aperçu, et ne prennent plus de place dans le formulaire.
|
||||
|
||||
Retour est placé en bas du contenu, séparé des sections. « Entrer dans
|
||||
Sanctuary » occupe un pied de page natif de 33 pixels, à la position du bouton
|
||||
Terminé des statistiques. Les deux boutons ont la largeur de la hotbar.
|
||||
Le fond légèrement texturé, les brouillons, la pagination, le verrouillage
|
||||
d'identité et le parcours de code d'accès sont conservés.
|
||||
|
||||
Les règles serveur, les sauvegardes et les familiers remis restent inchangés.
|
||||
Les variantes visuelles sont celles par défaut, comme dans beta.263.
|
||||
|
||||
## Vérifications
|
||||
|
||||
Le parcours Vulkan vérifie FR/EN en GUI 2/3/4, la largeur et le centrage des
|
||||
champs, les boutons sans chevauchement et la place du pied de page natif.
|
||||
Il couvre aussi les neuf couleurs par page, la dernière page incomplète,
|
||||
les saisies après redimensionnement/refus serveur, l'identité verrouillée et
|
||||
le code d'accès. Les aides de saisie FR/EN sont raccourcies pour tenir dans
|
||||
la colonne, y compris lorsque les champs sont vides.
|
||||
|
||||
Les 44 modèles de départ sont rendus à froid et comparés aux vrais proxies
|
||||
natifs après `Mob.setBaby(true)`. Captures spécifiques du bébé zombie,
|
||||
du bébé chameau, du survol et des deux directions de souris. Admission avec
|
||||
nom, sauvegarde et reconnexion dans un monde de développement neuf, graine
|
||||
52 ; code d'accès avec réponse HTTP perdue, nouvel essai et admission unique
|
||||
dans un autre monde neuf, graine 166.
|
||||
|
||||
Parcours final réussi en **1 min 4 s**, sous **Vulkan / MoltenVK 1.4.2,
|
||||
Apple M1**, avec 8 Gio maximum. Les marqueurs `HELLO262_MODELS_PASS`,
|
||||
`HELLO263_BABIES_PASS`, `STARTER052_CLIENT_PASS`, `ACCESS166_CLIENT_PASS`
|
||||
et `HELLO262_CLIENT_PASS` figurent dans `build/hello264-client-vulkan.log`.
|
||||
**43 captures natives** conservées dans `build/hello264-screenshots/` ;
|
||||
menus FR/EN, zombie, chameau, suivi de souris, cadrage et infobulle inspectés.
|
||||
|
||||
`check build assemblePack` réussit en **3 min 9 s**, avec les **13 GameTests
|
||||
serveur requis réussis**. Journal : `build/hello264-check-build-pack.log`.
|
||||
Le [pack local beta.264](../build/Sanctuary-beta.264.mrpack) est exporté et
|
||||
vérifié : intégrité ZIP, version, Minecraft 26.3, JAR identique au build,
|
||||
classes de production identiques aux sorties compilées, traductions FR/EN
|
||||
identiques aux sources et classes de test absentes. Par rapport à beta.263,
|
||||
les seules classes de production modifiées sont `HelloWorldScreen` et ses
|
||||
classes internes, ainsi que `HelloFamiliarPreview`.
|
||||
Reçu : `build/hello264-validation.json`.
|
||||
|
||||
Aucune publication ou installation personnelle effectuée. Essais graphiques
|
||||
sur Apple M1 uniquement ; Windows et serveur distant hors des essais.
|
||||
|
||||
```sh
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
|
||||
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
|
||||
-PsanctuaryQuickTests=true -PsanctuaryHello262ClientTests=true \
|
||||
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
|
||||
-PsanctuaryClientNoVsync=true
|
||||
./gradlew check build assemblePack \
|
||||
-PsanctuaryFocusedTests=progression,starter,starter052 -PsanctuaryAtlasOnly=true
|
||||
```
|
||||
@@ -0,0 +1,96 @@
|
||||
# HELLO-262 — Menu de création du personnage et du familier
|
||||
|
||||
Demande du 16 octobre 2026, branche `codex/hello-world-menu`, socle beta.261,
|
||||
Minecraft **26.3**, livraison locale **beta.262**.
|
||||
|
||||
Les aperçus adultes de cette première version sont remplacés par les
|
||||
[formes bébé en beta.263](hello-baby-familiars-beta263.md).
|
||||
|
||||
## Menu
|
||||
|
||||
Trois colonnes centrées : personnage à gauche, formulaire au centre, familier
|
||||
à droite. Les colonnes latérales ont la même largeur ; le centre est partagé
|
||||
en deux blocs de même hauteur, personnage en haut et familier en bas.
|
||||
Aucun aplat de panneau : fond gris sombre légèrement texturé sur toute la page,
|
||||
avec la texture native de pierre teintée, proche du précédent `#171717`.
|
||||
|
||||
Le pseudo affiche la couleur choisie. Les flèches autour de lui parcourent
|
||||
**neuf couleurs serveur par page**. La barre reprend directement les sprites
|
||||
`minecraft:hud/hotbar` et `minecraft:hud/hotbar_selection`, sans texture dérivée.
|
||||
Aux petites tailles, elle se réduit proportionnellement tout en conservant ses
|
||||
neuf cases. La dernière page garde les emplacements vides inactifs. Noms des
|
||||
couleurs au survol et dans la narration ; libellés FR/EN.
|
||||
|
||||
Le personnage et le familier ont des **aperçus 3D qui suivent la souris**,
|
||||
sans devoir cliquer ou glisser. Le skin utilise le widget natif ; le familier
|
||||
utilise ses modèles et textures natifs. Les trois œufs restent les boutons de
|
||||
choix à droite. Le survol change l'aperçu ; sinon, le familier choisi est
|
||||
montré, ou le premier avant la sélection explicite.
|
||||
|
||||
Ces aperçus ne créent aucun `ItemStack`, entité ou monde de substitution.
|
||||
Les états graphiques sont construits directement avant l'admission. Les vaches,
|
||||
poules et cochons utilisent explicitement leurs variantes tempérées 26.3,
|
||||
sans lier ou remplacer les registres dynamiques du serveur.
|
||||
|
||||
La biographie et le nom du familier restent au centre. Le code d'accès apparaît
|
||||
lorsque le serveur l'exige. Pagination, redimensionnement et refus serveur
|
||||
conservent les saisies et le familier proposé ; une couleur devenue indisponible
|
||||
est retirée de la sélection. Une identité enregistrée reste verrouillée.
|
||||
Règles serveur, tirage, protocole et formats de sauvegarde inchangés.
|
||||
|
||||
## Vérifications
|
||||
|
||||
Parcours client **Vulkan / MoltenVK 1.4.2, Apple M1**, 8 Gio maximum : FR/EN,
|
||||
fenêtre 1280 × 960, GUI 2/3/4, soit 640 × 480, 427 × 320 et 320 × 240.
|
||||
Toutes les couleurs sont accessibles sans doublon, dernière page comprise.
|
||||
Contrôles sans chevauchement, boutons Annuler/Entrer lisibles sans défilement,
|
||||
focus des flèches conservé, brouillons et sélection conservés au redimensionnement.
|
||||
Offres sans familier, palette vide et identité verrouillée couvertes.
|
||||
|
||||
Les **44 modèles de départ** communs/peu communs sont soumis au rendu natif
|
||||
et leurs textures vérifiées sur un client à froid, **avant la liaison des
|
||||
composants d'items**. La galerie est rendue en Vulkan. Les déplacements du
|
||||
curseur vérifient les deux axes du familier et du personnage ; captures
|
||||
comparées pour contrôler le sens de rotation.
|
||||
|
||||
Admission avec nom et reconnexion dans un monde plat neuf, graine **52**.
|
||||
Admission par code dans un autre monde plat neuf, graine **166**, avec une API
|
||||
HTTP locale contrôlée : réponse perdue, nouvel essai et admission unique.
|
||||
Configuration temporaire restaurée. Les parcours Starter052 et Access166 sont
|
||||
réutilisés ; les assertions historiques de disposition de Starter052 et
|
||||
Progression003 sont adaptées.
|
||||
|
||||
```sh
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
|
||||
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
|
||||
-PsanctuaryQuickTests=true -PsanctuaryHello262ClientTests=true \
|
||||
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
|
||||
-PsanctuaryClientNoVsync=true
|
||||
./gradlew check build assemblePack \
|
||||
-PsanctuaryFocusedTests=progression,starter,starter052 -PsanctuaryAtlasOnly=true
|
||||
```
|
||||
|
||||
Parcours client final réussi en **1 min 35 s**. Journal :
|
||||
`build/hello262-3d-client-vulkan-final.log`, marqueurs `HELLO262_MODELS_PASS`
|
||||
et `HELLO262_CLIENT_PASS`. **24 captures finales** conservées dans
|
||||
`build/hello262-final-screenshots/` ; vues FR/2, FR/4 avec accès, EN/4,
|
||||
galerie des 44 modèles et deux positions du curseur inspectées.
|
||||
|
||||
`check build assemblePack` réussit en **3 min 32 s**, avec **13 GameTests
|
||||
serveur requis réussis** et les contrôles purs du dépôt. Journal :
|
||||
`build/hello262-check-build-pack-final.log`. Mod et pack alignés sur beta.262.
|
||||
|
||||
[Pack local beta.262](../build/Sanctuary-beta.262.mrpack).
|
||||
Reçu et empreintes : `build/hello262-validation.json`.
|
||||
Le MRpack est vérifié après export : intégrité ZIP, Minecraft 26.3, version
|
||||
beta.262 et JAR Sanctuary identique au build ; classes de test absentes du mod.
|
||||
|
||||
## Limites
|
||||
|
||||
Aperçus avec les variantes visuelles par défaut, sans choix de variante.
|
||||
Aux petites tailles, la description latérale est abrégée par la place disponible ;
|
||||
l'infobulle garde le texte complet. La biographie conserve sa limite de
|
||||
256 caractères et son champ natif à défilement horizontal.
|
||||
Essais sur serveurs intégrés de développement ; Windows et serveur distant
|
||||
non testés. Aucune publication, modification du canal packwiz ou synchronisation
|
||||
d'une installation personnelle effectuée.
|
||||
@@ -0,0 +1,191 @@
|
||||
# PROVIDENCE-265 — Prières et premiers miracles
|
||||
|
||||
Demande du 24 octobre 2026, branche `codex/providence-prayers`, livraison
|
||||
locale **beta.265**, Minecraft **26.3**, Fabric Loader **0.19.5** et
|
||||
Fabric API **0.160.5+26.3**. Aucune dépendance ajoutée.
|
||||
|
||||
## Intégration
|
||||
|
||||
Providence est un paquet autonome dans le mod Sanctuary existant. Son
|
||||
initialisation enregistre deux commandes et une session par serveur. Le
|
||||
vocabulaire, l'interpréteur, le moteur de décision et le journal utilisent
|
||||
du Java/Gson indépendant de Minecraft. Seuls la validation du monde,
|
||||
l'exécution et les réponses dépendent du jeu.
|
||||
|
||||
La tranche utilise les services actuels : contrat météo journalier,
|
||||
position de dernière mort sauvegardée nativement par Minecraft, écritures
|
||||
atomiques `AtomicJson`, commandes Fabric et ressources FR/EN. Aucun écouteur
|
||||
du chat général, LLM, appel HTTP ou service extérieur. Les tombes et les
|
||||
inventaires ne sont jamais interceptés par Providence.
|
||||
|
||||
## Jouer
|
||||
|
||||
```text
|
||||
/pray Ô seigneur, j'en ai marre de cet orage, faites revenir le soleil !
|
||||
/pray Pitié, ramenez-moi à l'endroit où je suis mort
|
||||
/providence readable true
|
||||
/providence readable false
|
||||
```
|
||||
|
||||
`/pray` est accessible sans opérateur, dans une partie solo comme sur un
|
||||
serveur dédié. La prière est limitée à 512 caractères. Elle reçoit une réponse
|
||||
privée courte, avec deux variantes par verdict. Le mode par défaut utilise
|
||||
la police `sanctuary:providence`, ressource du mod référençant la police SGA
|
||||
native `minecraft:alt`. Les textes SGA sont sans accents ; la lecture normale
|
||||
FR/EN conserve les accents et se choisit individuellement avec `readable`.
|
||||
Le choix persiste. Les variantes « entendue » sont préparées ; le parcours
|
||||
jouable répond directement par le verdict final.
|
||||
|
||||
Les prières espacées de moins de cinq secondes sont silencieuses, sans
|
||||
nouvelle recherche de destination ni nouvelle écriture. Une erreur de
|
||||
persistance ferme le moteur jusqu'à un rechargement valide ; un rechargement
|
||||
de configuration invalide conserve la session courante. Les données en erreur
|
||||
sont conservées.
|
||||
|
||||
## Compréhension symbolique
|
||||
|
||||
Au premier démarrage, le serveur copie deux fichiers embarqués vers :
|
||||
|
||||
- `config/sanctuary/providence/concepts.json` : concepts et synonymes, dont
|
||||
des expressions de plusieurs mots ; vocabulaire français et premiers
|
||||
synonymes anglais.
|
||||
- `config/sanctuary/providence/miracles.json` : motifs de reconnaissance,
|
||||
exclusions, conditions obligatoires, probabilités, cooldowns et rétention.
|
||||
|
||||
Après modification, un opérateur utilise `/providence reload`. Les deux
|
||||
fichiers sont validés avant le remplacement de la session courante. Un
|
||||
synonyme ajouté ne demande aucune recompilation. Les conditions de sécurité
|
||||
des exécuteurs ne peuvent pas être supprimées par les fichiers JSON.
|
||||
|
||||
La normalisation retire accents, casse et ponctuation ; la détection respecte
|
||||
les frontières des mots. Une demande doit commencer un segment, invoquer
|
||||
Providence ou comporter une expression directe comme « ramenez-moi ». Un
|
||||
constat tel que « le serveur arrête la pluie » reste sans effet. Chaque motif contient des groupes de concepts :
|
||||
tous les groupes sont requis, avec au moins un concept par groupe. Par
|
||||
exemple, demande + arrêt + pluie, ou demande + retour + soleil. Le score
|
||||
de couverture vaut le pourcentage de groupes satisfaits (0 à 100). Le seuil
|
||||
par défaut est 90 et un motif incomplet ne déclenche jamais d'effet. Ce score
|
||||
structurel n'est pas une probabilité de compréhension du langage.
|
||||
|
||||
Les indices ne sont pas assemblés entre phrases ou propositions séparées par
|
||||
une conjonction. Les négations, hypothèses et certaines formulations de récit
|
||||
bloquent la proposition concernée. Deux intentions complètes distinctes
|
||||
rendent la prière ambiguë et empêchent toute action. Un mot isolé comme
|
||||
« pluie », un récit de mort ou « J'aime cette pluie, ne l'arrêtez pas »
|
||||
n'exécute rien.
|
||||
|
||||
Ce moteur reconnaît un petit vocabulaire et des relations simples. Il ne
|
||||
résout pas la coréférence, le sarcasme, les citations imbriquées ou toutes
|
||||
les tournures françaises. Une prière correcte peut rester incomprise ; les
|
||||
formulations négatives et hypothétiques sont volontairement conservatrices.
|
||||
|
||||
## Miracles et décisions
|
||||
|
||||
| Miracle | Politique initiale | Conditions et effet |
|
||||
| --- | --- | --- |
|
||||
| `CLEAR_WEATHER` | 35 %, individuel 30 min, global 10 min | Joueur vivant, Overworld avec météo et temps non clair. Profil Sanctuary changé en `CLEAR` jusqu'au prochain jour civil ; prévision normale reprise le lendemain. En mode météo vanilla, éclaircie de 600 secondes configurable. |
|
||||
| `RETURN_TO_DEATH` | 50 %, individuel 15 min, aucun cooldown global | Joueur vivant, dernière mort connue dans la dimension courante, arrivée sûre dans les chunks déjà chargés. Téléportation native et distance de chute remise à zéro. |
|
||||
| `RECOVER_ITEMS` | Désactivé, activation JSON refusée | Intention reconnue, aucune lecture ou restitution des objets. Contrat anti-duplication encore à concevoir autour des tombes. |
|
||||
| `RESCUE_PLAYER` | Désactivé, activation JSON refusée | Intention reconnue, aucune extraction. Les critères de situation bloquée et de destination restent à valider. |
|
||||
|
||||
Aucun coût d'XP ou d'objet dans cette tranche. Les probabilités, délais et
|
||||
activation des deux pouvoirs validés sont configurables. Les délais utilisent
|
||||
l'heure UTC en millisecondes du serveur ; un recul de son horloge prolonge
|
||||
l'attente. Un refus probabiliste consomme les mêmes cooldowns qu'un accord,
|
||||
afin de ne pas relancer le tirage par répétition ou reconnexion. Un refus
|
||||
de condition, une intention inconnue ou un pouvoir désactivé ne consomme que
|
||||
la courte limite de fréquence des prières.
|
||||
|
||||
Le retour cherche le point sûr le plus proche dans une boîte de ±8 blocs
|
||||
horizontalement et ±6 verticalement autour de la dernière mort. Le sol doit
|
||||
être solide, le joueur debout doit tenir dans le volume libre, et les cases voisines
|
||||
doivent être chargées et dans la bordure. Fluides, lave, feu, cactus, magma,
|
||||
neige poudreuse, plantes blessantes et portails sont refusés. Aucun bloc
|
||||
n'est créé ou détruit, aucun chunk n'est chargé ou généré par cette recherche.
|
||||
Une mort dans le vide, dans une autre dimension ou trop loin des chunks
|
||||
chargés reste donc sans retour. Une arrivée sûre ne garantit pas l'absence
|
||||
d'ennemis ou de dangers futurs. Le joueur endormi, mort, spectateur ou passager
|
||||
est inéligible.
|
||||
|
||||
## Nouveau contrat de persistance isolé
|
||||
|
||||
`<monde>/data/sanctuary-providence.json`, schéma 1, contient uniquement les
|
||||
prières explicites admises par la limite de fréquence, leurs UUID, intentions,
|
||||
scores, ambiguïtés, concepts, conditions examinées, décisions et résultats,
|
||||
ainsi que les échéances et préférences de lecture. Il n'enregistre pas les
|
||||
autres messages du chat ou les messages privés entre joueurs.
|
||||
|
||||
Par défaut, les 500 dernières entrées au maximum sont conservées pendant
|
||||
14 jours. Le nettoyage intervient au chargement, à chaque décision et une
|
||||
fois par minute pendant que le serveur tourne. `historyEntries: 0` désactive
|
||||
la conservation du texte des prières tout en conservant les protections
|
||||
contre les répétitions. Les cooldowns non expirés restent indépendants de la
|
||||
rétention ; les échéances expirées sont éliminées. Les fichiers sont bornés
|
||||
(configurations 256 Kio chacune, état 8 Mio) et remplacés atomiquement.
|
||||
|
||||
Avant tout effet, la décision `PENDING` et ses cooldowns sont enregistrés
|
||||
(avec la conservation du texte désactivée, seules les réservations persistent).
|
||||
L'effet réussi devient `GRANTED` avec `executed: true`. Une interruption
|
||||
entre ces étapes laisse `PENDING` et `executed: false`, résultat incertain
|
||||
à examiner manuellement ; aucune exécution n'est rejouée au redémarrage.
|
||||
Il n'existe pas de transaction atomique entre les fichiers du monde Minecraft
|
||||
et le journal : une panne au milieu d'un effet peut donc laisser ce résultat
|
||||
incertain. Les refus et échecs ne sont jamais annoncés comme exécutés.
|
||||
|
||||
Contrat additif : aucune migration de fichier existant, changement de
|
||||
génération, expansion, réécriture de chunk ou manipulation de sauvegarde
|
||||
personnelle. La météo utilise son contrat existant sans en changer le schéma.
|
||||
|
||||
## Vérifications
|
||||
|
||||
`check build assemblePack` réussit en **2 min 50 s**, sur Java 25.0.3,
|
||||
avec les **15 GameTests serveur requis réussis** (Providence, tombes et
|
||||
progression) et **67 vérifications Providence indépendantes de Minecraft**.
|
||||
Le parcours serveur couvre notamment les vraies commandes, le beau temps
|
||||
persistant, le cooldown global après rechargement, un rechargement JSON refusé
|
||||
sans perte de session, le choix d'accessibilité persistant, la position native
|
||||
après mort et sauvegarde/relecture, la téléportation réelle, les arrivées
|
||||
non chargées, la lave, le plafond, la pose rampante, le cactus et la restitution
|
||||
d'objets désactivée. Le journal de l'essai contient une erreur de rechargement
|
||||
JSON attendue, injectée pour vérifier la conservation de la session.
|
||||
|
||||
Les tests indépendants comprennent synonymes rechargés sans compilation,
|
||||
formulations familières FR/EN, récits sans demande, négations, hypothèses,
|
||||
ambiguïtés, refus probabiliste, conditions manquantes, pouvoirs désactivés,
|
||||
limite de fréquence, cooldowns individuels/globaux après redémarrage,
|
||||
interruption d'exécution, rétention par âge et par nombre, journal textuel
|
||||
désactivé, isolation des mondes, taille maximale, corruption et conflit
|
||||
avec une modification extérieure du fichier.
|
||||
|
||||
Le parcours client réussit en **1 min 1 s**, sous **Vulkan / MoltenVK 1.4.2,
|
||||
Apple M1**, avec 8 Gio maximum. Un `/pray` envoyé par la vraie connexion de jeu
|
||||
arrête l'orage et retourne une réponse SGA ; la commande d'accessibilité
|
||||
retourne ensuite une réponse en lecture normale. Les huit verdicts principaux
|
||||
sont rendus dans le chat natif en FR/EN, GUI 2/3/4, en SGA et en lecture normale.
|
||||
Les **12 captures natives** ont été inspectées : alphabet, ponctuation,
|
||||
accents et texte contenu dans le chat. Monde de développement neuf, graine 265.
|
||||
|
||||
Journaux et captures ignorés :
|
||||
`build/providence265-check-build-pack-final.log`,
|
||||
`build/providence265-client-vulkan.log`, `build/providence265-screenshots/`.
|
||||
Aucun essai OpenGL. Les essais concernent le serveur local et le client macOS ;
|
||||
aucune connexion distante multijoueur ni validation Windows n'est revendiquée.
|
||||
Aucune publication, avancée du canal packwiz ou installation personnelle.
|
||||
|
||||
Le [MRpack local beta.265](../build/Sanctuary-beta.265.mrpack) est exporté et
|
||||
vérifié : intégrité ZIP, version, Minecraft 26.3/Fabric exacts, un seul JAR
|
||||
Sanctuary identique au build, classes compilées et ressources Providence/FR/EN
|
||||
identiques aux sources, classes de test absentes. Par rapport à beta.264,
|
||||
les seules classes existantes modifiées sont `SanctuaryMod` et `WeatherService` ;
|
||||
le reste du nouveau code est dans `providence`. Empreintes et reçu :
|
||||
`build/providence265-validation.json`.
|
||||
|
||||
```sh
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
|
||||
./gradlew check build assemblePack \
|
||||
-PsanctuaryFocusedTests=providence265,graves,progression -PsanctuaryAtlasOnly=true
|
||||
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
|
||||
-PsanctuaryQuickTests=true -PsanctuaryProvidence265ClientTests=true \
|
||||
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
|
||||
-PsanctuaryClientNoVsync=true
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
# PROVIDENCE-266 — Couleurs des réponses SGA
|
||||
|
||||
Demande du 24 octobre 2026, branche `codex/providence-prayers`, livraison locale
|
||||
**beta.266**, Minecraft **26.3**.
|
||||
|
||||
Les réponses de Providence affichées en SGA utilisent les couleurs natives de
|
||||
Minecraft selon le verdict, pour les deux variantes et les traductions FR/EN :
|
||||
|
||||
| Couleur | Réponses |
|
||||
| --- | --- |
|
||||
| Vert | Miracle accordé ; confirmation de rechargement si affichée en SGA. |
|
||||
| Jaune | Prière entendue, cooldown, silence, prière incomprise ou ambiguë. |
|
||||
| Rouge | Refus, conditions non remplies, pouvoir désactivé, échec d'exécution ou erreur de configuration. |
|
||||
|
||||
Le jaune invite à attendre ou à reformuler. Il n'ajoute pas de file d'exécution
|
||||
automatique. Les messages d'aide et de changement de lecture restent neutres.
|
||||
Le mode de lecture normale conserve son affichage actuel ; la demande porte
|
||||
sur le SGA. La police et les textes des verdicts sont conservés.
|
||||
|
||||
La modification est limitée à `ProvidenceMessages` et aux métadonnées de
|
||||
livraison. Les règles de décision et la persistance restent celles de
|
||||
[Providence beta.265](providence-beta265.md).
|
||||
|
||||
## Vérifications
|
||||
|
||||
Le parcours existant Providence réussit sous **Vulkan / MoltenVK 1.4.2,
|
||||
Apple M1**, en **55 secondes**, avec 8 Gio maximum. Il couvre une vraie prière
|
||||
transmise au serveur, les traductions FR/EN et les échelles GUI 2/3/4, en SGA
|
||||
et en lecture normale. Douze captures sont conservées dans
|
||||
`build/providence266-screenshots/` ; les vues SGA FR GUI 2/4 et EN GUI 3 ont
|
||||
été inspectées, avec les trois couleurs et la police conservée.
|
||||
Journal : `build/providence266-client-vulkan.log`.
|
||||
|
||||
```sh
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
|
||||
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
|
||||
-PsanctuaryQuickTests=true -PsanctuaryProvidence265ClientTests=true \
|
||||
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
|
||||
-PsanctuaryClientNoVsync=true
|
||||
./gradlew check build assemblePack \
|
||||
-PsanctuaryFocusedTests=providence265,graves,progression -PsanctuaryAtlasOnly=true
|
||||
```
|
||||
|
||||
`check build assemblePack` réussit en **2 min 38 s**, avec les **15 GameTests
|
||||
serveur requis** et les **67 vérifications indépendantes** de Providence.
|
||||
Journal : `build/providence266-check-build-pack.log`.
|
||||
|
||||
Le [pack local beta.266](../build/Sanctuary-beta.266.mrpack) est exporté et
|
||||
vérifié : intégrité ZIP, version, Minecraft 26.3, JAR embarqué identique au
|
||||
build et classes de test absentes. La seule classe modifiée par rapport au
|
||||
JAR beta.265 est `ProvidenceMessages`. Reçu et empreintes :
|
||||
`build/providence266-validation.json`. Aucune publication ni mise à jour
|
||||
d'une installation personnelle.
|
||||
@@ -0,0 +1,103 @@
|
||||
# PROVIDENCE-267 — Attendre la réponse de Providence
|
||||
|
||||
Demande du 24 octobre 2026, branche `codex/providence-prayers`, livraison locale
|
||||
**beta.267**, Minecraft **26.3**.
|
||||
|
||||
Après une prière admise par `/pray`, Providence attend **5 secondes** avant
|
||||
d'interpréter la demande, d'examiner le monde, de prendre sa décision et
|
||||
d'accomplir un éventuel miracle. Aucun verdict ou effet immédiat. Les réponses
|
||||
conservent les couleurs de beta.266 : vert accordé, jaune attente ou
|
||||
clarification, rouge refus ou échec.
|
||||
|
||||
Une seule prière peut attendre par joueur. Les répétitions pendant ce délai
|
||||
sont ignorées : elles ne remplacent pas les mots initiaux, ne raccourcissent
|
||||
pas l'attente et ne déclenchent pas un second tirage. Les autres joueurs
|
||||
attendent indépendamment. Les cooldowns persistants des miracles et la limite
|
||||
de fréquence des prières sont conservés ; cette dernière repart du traitement
|
||||
de la réponse. Le résultat de la commande `/pray` indique désormais l'admission
|
||||
dans la file, et non l'exécution immédiate d'un miracle.
|
||||
|
||||
## Configuration
|
||||
|
||||
Ajouter ou modifier dans `config/sanctuary/providence/miracles.json` :
|
||||
|
||||
```json
|
||||
"responseDelaySeconds": 5
|
||||
```
|
||||
|
||||
Valeur entière entre **1 et 60 secondes**, appliquée aux nouvelles prières
|
||||
après `/providence reload`. Les anciennes configurations beta.265/266 sans
|
||||
cette clé utilisent cinq secondes automatiquement ; leurs autres valeurs et
|
||||
leurs fichiers restent conservés. Le champ est fourni dans les configurations
|
||||
neuves, sans changer le schéma existant.
|
||||
|
||||
Une seconde correspond à 20 ticks du monde serveur. Le délai peut donc durer
|
||||
plus longtemps si le serveur ralentit ou si la partie solo est mise en pause.
|
||||
Le rechargement de la configuration conserve les prières déjà en attente et
|
||||
leur échéance initiale. Leur interprétation et les règles utilisées lors de la
|
||||
décision sont celles de la configuration alors active.
|
||||
|
||||
## Cycle de vie et sécurité
|
||||
|
||||
L'attente est traitée dans les ticks serveur, sans bloquer de thread. La file
|
||||
en mémoire est bornée à 1 024 prières et ne survit pas à l'arrêt du serveur.
|
||||
Une déconnexion ou une réapparition annule la demande en attente ; elle n'est
|
||||
pas remise à un nouvel objet joueur. Les joueurs déconnectés ou retirés sont
|
||||
aussi vérifiés avant tout traitement.
|
||||
|
||||
Les conditions sont évaluées à l'échéance, puis revérifiées juste avant l'effet
|
||||
par l'exécuteur existant. Une dernière mort ou une destination ayant changé
|
||||
pendant l'attente est donc examinée dans son état actuel. Les contrôles d'aide,
|
||||
de lecture normale et de rechargement restent immédiats.
|
||||
|
||||
Les prières sont journalisées quand elles sont traitées. Une attente annulée
|
||||
avant traitement ne crée pas de décision dans l'historique. La réservation
|
||||
`PENDING` avant les effets et les règles de non-rejeu de beta.265 sont
|
||||
conservées. Aucun changement du format des sauvegardes, de la génération ou
|
||||
des tombes ; aucune installation personnelle utilisée pour les essais.
|
||||
|
||||
## Vérifications
|
||||
|
||||
Le contrôle client Vulkan réussit en **47 secondes**, sous **MoltenVK 1.4.2 /
|
||||
Apple M1**, avec 8 Gio maximum, dans un monde de développement neuf graine 265.
|
||||
Une vraie commande réseau n'affiche aucune réponse immédiate ; le serveur
|
||||
conserve la pluie pendant l'attente. Un second `/pray bonjour` ne remplace pas
|
||||
la demande originale. Le miracle et une unique réponse verte arrivent après
|
||||
**101 ticks serveur** depuis l'envoi, soit environ cinq secondes.
|
||||
|
||||
Le parcours FR/EN GUI 2/3/4, en SGA et lecture normale, est conservé. Treize
|
||||
captures natives : `build/providence267-screenshots/`. La vue d'attente et les
|
||||
réponses SGA FR GUI 2 / EN GUI 4 ont été inspectées. Journal :
|
||||
`build/providence267-client-vulkan.log`, marqueur `PROVIDENCE267_DELAY_PASS`.
|
||||
|
||||
La suite serveur utilise un délai de test configurable d'une seconde. Elle
|
||||
vérifie l'absence d'effet et de décision anticipée, les répétitions ignorées,
|
||||
l'échéance conservée après rechargement, les cooldowns existants, les refus
|
||||
différés, la téléportation sûre, la dernière mort modifiée pendant l'attente
|
||||
et l'absence de traitement d'un joueur déconnecté. Le format des fichiers de
|
||||
configuration beta.265/266 est aussi relu sans réécriture dans les tests purs.
|
||||
|
||||
```sh
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
|
||||
./gradlew check build assemblePack \
|
||||
-PsanctuaryFocusedTests=providence265,graves,progression -PsanctuaryAtlasOnly=true
|
||||
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
|
||||
-PsanctuaryQuickTests=true -PsanctuaryProvidence265ClientTests=true \
|
||||
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
|
||||
-PsanctuaryClientNoVsync=true
|
||||
```
|
||||
|
||||
Les essais couvrent le serveur local et le client macOS Vulkan. Aucun essai
|
||||
distant multijoueur ou Windows, aucune publication ou mise à jour personnelle.
|
||||
|
||||
`check build assemblePack` réussit en **2 min 43 s**, avec les **15 GameTests
|
||||
serveur requis** et **83 vérifications indépendantes** de Providence.
|
||||
Journal : `build/providence267-check-build-pack.log`.
|
||||
|
||||
Le [pack local beta.267](../build/Sanctuary-beta.267.mrpack) est exporté et
|
||||
vérifié : intégrité ZIP, version et Minecraft 26.3, JAR embarqué identique au
|
||||
build, configuration et classes Providence identiques aux sorties compilées,
|
||||
classes de test absentes. Les classes existantes modifiées depuis beta.266
|
||||
se limitent aux sources `MiracleRegistry` et `ProvidenceService`, classes
|
||||
internes comprises ; la file `PrayerQueue` est nouvelle. Reçu :
|
||||
`build/providence267-validation.json`.
|
||||
@@ -0,0 +1,190 @@
|
||||
# PROVIDENCE-268 — Les quatorze prières
|
||||
|
||||
Demande du 24 octobre 2026, branche `codex/providence-prayers`, livraison locale
|
||||
**beta.268**, Minecraft **26.3**, Fabric Loader **0.19.5**, Fabric API
|
||||
**0.160.5+26.3** et Java 25.
|
||||
|
||||
Le catalogue compte désormais quatorze miracles actifs. `/pray <texte libre>`
|
||||
garde son interprétation symbolique locale, ses probabilités, ses conditions
|
||||
serveur et ses cooldowns persistants. La réponse et l’effet attendent toujours
|
||||
**cinq secondes**, réglables entre 1 et 60 secondes. Une seule demande attend
|
||||
par joueur. Le SGA reste **vert** si le miracle est accordé, **jaune** pour
|
||||
une attente ou une clarification, **rouge** pour un refus ou un échec.
|
||||
`/providence readable true` conserve la lecture normale FR/EN.
|
||||
|
||||
## Catalogue et réglages initiaux
|
||||
|
||||
Les durées ci-dessous sont les cooldowns individuels après un tirage admis,
|
||||
qu’il soit favorable ou refusé. Une condition impossible ne consomme pas ce
|
||||
tirage. Soleil et Pluie ont chacun un cooldown global supplémentaire de dix
|
||||
minutes. Les réglages restent modifiables dans
|
||||
`config/sanctuary/providence/miracles.json`, puis `/providence reload`.
|
||||
|
||||
| Nº | Prière | Exemple après `/pray` | Effet accordé | Chance / cooldown |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 01 | Récupération | Rendez-moi mon stuff, je suis mort | Transfère les objets restants de la dernière tombe admissible | 15 % / 60 min |
|
||||
| 02 | Retour | Ramenez-moi à ma tombe | Arrivée sûre près de la dernière mort, dans la même dimension | 50 % / 15 min |
|
||||
| 03 | Secours | Ramenez-moi au spawn | Arrivée sûre près du spawn global du serveur | 50 % / 15 min |
|
||||
| 04 | Soleil | Arrêtez la pluie | Arrête pluie et orage | 35 % / 30 min |
|
||||
| 05 | Pluie | Faites pleuvoir | Déclenche la pluie | 35 % / 30 min |
|
||||
| 06 | Guérison | Guéris-moi | Restaure les PV jusqu’au maximum acquis | 50 % / 5 min |
|
||||
| 07 | Nourriture | Nourris-moi | Restaure faim et saturation jusqu’à la capacité acquise | 50 % / 5 min |
|
||||
| 08 | Foudre | Foudroie @Pseudo | Éclair visuel et cinq points de dégâts natifs sur la cible | 25 % / 15 min |
|
||||
| 09 | Malédiction | Maudis @Pseudo | Effet négatif aléatoire pendant vingt secondes | 25 % / 15 min |
|
||||
| 10 | Vengeance | Punis @Pseudo | Punition aléatoire sur la cible | 25 % / 15 min |
|
||||
| 11 | Vol | Vole un objet à @Pseudo | Transfère une unité d’un objet aléatoire vers le demandeur | 25 % / 15 min |
|
||||
| 12 | Désarmement | Désarme @Pseudo | Fait tomber la pile réellement tenue en main principale | 35 % / 10 min |
|
||||
| 13 | Téléportation | Téléporte-moi près de @Pseudo | Arrivée sûre près du joueur, y compris dans une autre dimension | 50 % / 5 min |
|
||||
| 14 | Réparation | Répare mon outil | Répare l’objet endommagé tenu en main principale | 50 % / 5 min |
|
||||
|
||||
Les noms seuls des prières et les mots isolés ne sont pas des commandes
|
||||
impératives. Une négation, une hypothèse ou plusieurs intentions complètes
|
||||
empêchent un effet. Les nouveaux concepts et synonymes FR/EN sont déclaratifs.
|
||||
Guérison, Nourriture et Réparation visent le demandeur ; elles refusent si
|
||||
le besoin correspondant n’existe pas. Secours vise le spawn global, pas le lit.
|
||||
|
||||
## Joueurs visés et effets
|
||||
|
||||
Conformément au choix du créateur, les cinq prières hostiles peuvent viser
|
||||
**tous les joueurs connectés**, sans accord préalable ni exigence de PvP
|
||||
activé. La Téléportation utilise le même choix explicite de destinataire.
|
||||
`@Pseudo` sélectionne un nom complet sans distinction de casse. Un nom complet
|
||||
sans `@` est aussi reconnu ; `@` évite les collisions avec les mots usuels.
|
||||
Un nom inconnu, plusieurs cibles ou un alias partagé ne choisissent jamais
|
||||
un joueur au hasard. Les mentions sont masquées avant la reconnaissance
|
||||
pour qu’un pseudo comme « Pluie » ne change pas l’intention.
|
||||
|
||||
L’UUID est retenu dès la demande, puis la présence de ce même joueur et les
|
||||
conditions réelles sont revérifiées à l’échéance. Un autre compte arrivé sous
|
||||
le même nom ne reçoit pas le miracle. La cible doit encore être vivante et
|
||||
connectée. Une mention explicite sur une prière personnelle est refusée.
|
||||
|
||||
Foudre envoie aux clients proches l’éclair natif et applique les dégâts côté
|
||||
serveur à la seule cible. Aucun éclair n’est ajouté à la simulation serveur :
|
||||
le décor, le cuivre, les paratonnerres et les voisins restent hors de cet effet.
|
||||
Les protections et immunités natives aux dégâts restent applicables.
|
||||
Malédiction tire entre lenteur, faiblesse, fatigue de minage, nausée et faim,
|
||||
niveau I. Vengeance tire entre cet éclair, cette malédiction, ou quatre points
|
||||
de dégâts magiques accompagnés du retrait de quatre points de faim.
|
||||
|
||||
Vol sélectionne une pile admissible dans les rangées de stockage actuellement
|
||||
accessibles, hotbar comprise, puis transfère **une unité**, composants conservés.
|
||||
Équipement porté, main secondaire et stockage verrouillé ne sont pas parcourus.
|
||||
Une destination pleine ou un vol sur soi-même est refusé. Désarmement dépose
|
||||
la pile tenue, sans la recopier, avec le délai natif avant ramassage. Un objet
|
||||
endommagé conserve ses composants et son usure pendant ces transferts.
|
||||
|
||||
Les arrivées cherchent un sol, la place debout et l’absence de danger dans
|
||||
un rayon borné de huit blocs et six blocs verticaux. Elles utilisent uniquement
|
||||
les chunks déjà chargés, respectent la frontière et remettent la chute à zéro.
|
||||
Une destination non disponible entraîne un refus. Soleil et Pluie modifient
|
||||
le profil météo courant jusqu’au prochain minuit civil ; sans contrôleur météo
|
||||
actif, la durée native configurée vaut dix minutes par défaut.
|
||||
|
||||
## Récupération et contrat de sauvegarde additif
|
||||
|
||||
Récupération transfère **les objets encore présents**, sans restaurer une copie
|
||||
de l’inventaire de mort. Elle exige le propriétaire, la position et la dimension
|
||||
de la dernière mort natives, ainsi que le numéro de décès de la tombe.
|
||||
Elle recherche les tombes placées dans les neuf chunks déjà chargés autour de
|
||||
cette mort, les têtes flottantes à proximité (24 blocs horizontaux), et les
|
||||
propres têtes de tombe portées dans le stockage accessible du demandeur.
|
||||
Elle ne charge aucun chunk et n’ouvre pas l’inventaire d’un autre joueur.
|
||||
Les objets déjà retirés ne sont pas recréés. Il faut assez de place pour tout
|
||||
le contenu admissible ; sinon aucun transfert ni scellement n’a lieu.
|
||||
|
||||
Un nouveau registre distinct, `<monde>/data/sanctuary-providence-recoveries.json`,
|
||||
**schéma 1**, conserve la correspondance entre UUID de tombe consommée et UUID
|
||||
du bénéficiaire. Son absence signifie qu’aucune récupération Providence n’a
|
||||
encore été scellée. Il est créé au premier transfert, par remplacement atomique,
|
||||
avant la mutation des objets. Il est borné à 8 Mio et 100 000 tombes : à capacité
|
||||
atteinte, les nouvelles récupérations sont refusées. Les sceaux ne sont jamais
|
||||
purgés, rejoués ou retirés automatiquement.
|
||||
|
||||
Ce fichier doit être conservé avec les sauvegardes du monde. Le format des
|
||||
joueurs et des tombes reste inchangé ; aucune conversion ni réécriture générale
|
||||
d’un monde n’est nécessaire. Les interfaces et drops d’une tombe scellée
|
||||
masquent tout ancien contenu, y compris après rechargement d’un vieux chunk
|
||||
ou d’une ancienne copie de tête. Une corruption du registre bloque l’ouverture
|
||||
des tombes plutôt que d’effacer le registre.
|
||||
Les menus de la tombe placée partagent leur conteneur réel ; le propre menu
|
||||
de tombe du bénéficiaire est fermé avant son transfert.
|
||||
|
||||
Le transfert est **au plus une fois**, sans transaction atomique entre tous
|
||||
les fichiers Minecraft. Une interruption après le scellement mais avant la
|
||||
sauvegarde du bénéficiaire peut perdre la restitution : le système ne rejoue
|
||||
pas l’opération et conserve le sceau contre la duplication. Le journal des
|
||||
prières peut alors garder `PENDING`, avec `executed: false`, résultat incertain
|
||||
à examiner manuellement. Cette limite complète le contrat de beta.265.
|
||||
|
||||
## Configurations existantes
|
||||
|
||||
Le chargement ajoute en mémoire les concepts, les nouveaux synonymes des
|
||||
concepts REQUEST/RAIN/TRAVEL et les dix définitions absentes
|
||||
d’une ancienne configuration à quatre prières. Les deux anciens emplacements
|
||||
Récupération/Secours portant exactement `conditions: ["safety_unvalidated"]`
|
||||
reçoivent leurs nouvelles règles, probabilités et activation validées ; leurs
|
||||
cooldowns sont conservés. Une définition déjà valide explicitement désactivée
|
||||
reste désactivée. Les probabilités, cooldowns et dictionnaires existants sont
|
||||
conservés ; aucun fichier édité n’est réécrit par cette mise à niveau.
|
||||
|
||||
Les identifiants des quatre premiers miracles restent stables. Le journal
|
||||
`sanctuary-providence.json` conserve son schéma et sa rétention. Aucun changement
|
||||
de génération, de graine, d’expansion ou de monde personnel.
|
||||
|
||||
## Vérifications
|
||||
|
||||
`check build assemblePack` réussit en **2 min 57 s**, avec les **20 GameTests
|
||||
serveur requis réussis** et les contrôles purs ci-dessous.
|
||||
|
||||
Le parcours client **Vulkan / MoltenVK 1.4.2 / Apple M1** réussit en **1 min 33 s**,
|
||||
avec 8 Gio maximum et un monde de développement neuf, graine 265. La vraie
|
||||
commande réseau météo attend **102 ticks serveur** avant le miracle et sa
|
||||
réponse unique. Une prière ciblée envoyée par la connexion de jeu produit aussi
|
||||
un éclair natif visible après cinq secondes, avec dégâts vérifiés sur le serveur
|
||||
et absence d’éclair dans sa simulation. Les réponses SGA et la lecture normale
|
||||
sont parcourues en FR/EN, GUI 2/3/4. Quatorze captures sont conservées ; les vues
|
||||
d’attente, SGA FR GUI 2, SGA/lecture normale EN GUI 4 et l’éclair ont été inspectés.
|
||||
|
||||
Les contrôles indépendants de Minecraft comptent **146 vérifications** :
|
||||
quatorze définitions, formulations FR/EN, négations, ambiguïtés, mots isolés,
|
||||
ciblage exact sans préfixe ni substitution, anciens dictionnaires/configurations,
|
||||
probabilités, cooldowns, journaux, corruption, écritures concurrentes, sceaux
|
||||
durables et non-rejeu. Les anciens fichiers personnalisés sont comparés avant
|
||||
et après chargement.
|
||||
|
||||
Les tests serveur couvrent les effets des quatorze miracles, les capacités
|
||||
acquises de PV/faim/stockage, le ciblage avec PvP désactivé, les transferts réels,
|
||||
le stockage verrouillé, les tombes placées/portées/flottantes, l’inventaire plein,
|
||||
les copies de tombe obsolètes après relecture des sceaux, les arrivées sûres,
|
||||
les commandes différées et l’absence de substitution d’UUID après départ.
|
||||
Le rechargement d’un JSON volontairement invalide écrit une erreur attendue
|
||||
pour vérifier que la session valide est conservée.
|
||||
|
||||
```sh
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
|
||||
./gradlew check build assemblePack \
|
||||
-PsanctuaryFocusedTests=providence265,providence268,graves,progression \
|
||||
-PsanctuaryAtlasOnly=true
|
||||
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
|
||||
-PsanctuaryQuickTests=true -PsanctuaryProvidence265ClientTests=true \
|
||||
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
|
||||
-PsanctuaryClientNoVsync=true
|
||||
```
|
||||
|
||||
Les essais utilisent des joueurs simulés côté serveur et une connexion réelle
|
||||
locale côté client ; ils ne constituent pas un essai avec deux clients distants
|
||||
ni une validation Windows. Aucun déploiement personnel ou publication.
|
||||
Journaux et captures ignorés : `build/providence268-check-build-pack-final.log`,
|
||||
`build/providence268-client-vulkan.log`, `build/providence268-screenshots/`.
|
||||
|
||||
Le [pack local beta.268](../build/Sanctuary-beta.268.mrpack) est exporté et
|
||||
vérifié : ZIP intègre, version et dépendances exactes, un seul JAR Sanctuary
|
||||
embarqué identique au build, 33 classes Providence identiques aux sorties
|
||||
compilées, dictionnaires/règles/police/libellés identiques aux sources et
|
||||
absence de classes de test. Les seules classes existantes modifiées depuis
|
||||
beta.267 proviennent de `GraveService`, `WeatherService`, `MiracleExecutor`,
|
||||
`MiracleRegistry`, `MiracleValidator`, `PrayerInterpreter`, `ProvidenceEngine`
|
||||
et `ProvidenceService`, classes internes comprises. Le registre de sceaux,
|
||||
les transferts d’inventaire et le ciblage sont de nouveaux composants.
|
||||
Empreintes et reçu : `build/providence268-validation.json`.
|
||||
@@ -0,0 +1,175 @@
|
||||
# PROVIDENCE-269 — Six issues et leurs couleurs
|
||||
|
||||
Demande du 24 octobre 2026, branche `codex/providence-prayers`, livraison locale
|
||||
**beta.269**, Minecraft **26.3**, Java 25 et Fabric Loader **0.19.5**.
|
||||
|
||||
Providence dispose de six issues. Les couleurs sont appliquées au **SGA et à
|
||||
la lecture normale FR/EN**. Le silence ne produit aucun message, même un texte
|
||||
annonçant que Providence serait silencieuse.
|
||||
|
||||
| Verdict | Couleur | Comportement |
|
||||
| --- | --- | --- |
|
||||
| ✧ Exaucée | Vert `#55FF55` | Le miracle s’exécute juste avant la réponse favorable |
|
||||
| ◇ Différée | Jaune `#FFFF55` | Le serveur annonce l’attente et programme réellement le miracle plus tard |
|
||||
| ✕ Refusée | Rouge `#FF5555` | Aucun miracle n’est accordé |
|
||||
| ? Incomprise | Gris `#AAAAAA` | La demande n’a pas d’intention reconnue unique |
|
||||
| ○ Silence | Aucun affichage | Aucun message visible et aucun miracle ; la décision reste journalisée |
|
||||
| ⌁ Alternative | Violet `#FF55FF` | Une autre aide admissible est réellement accordée au demandeur |
|
||||
|
||||
L’attente initiale de **cinq secondes** de beta.267 est conservée. « Exaucée
|
||||
immédiatement » signifie donc au moment de la réponse, après cette attente.
|
||||
« Différée » ajoute par défaut **trente secondes après la réponse jaune**.
|
||||
Une réponse verte est ensuite envoyée si l’effet aboutit. Une condition devenue
|
||||
impossible produit un refus rouge à cette échéance. Les refus pour cooldown,
|
||||
pouvoir désactivé ou échec sont rouges ; ils ne programment aucun effet futur.
|
||||
Une ambiguïté de formulation rejoint le gris de la demande incomprise.
|
||||
|
||||
## Politique configurable
|
||||
|
||||
Le nouveau fichier `config/sanctuary/providence/responses.json` est créé au
|
||||
premier chargement, sans réécrire `miracles.json`, `concepts.json` ou un fichier
|
||||
`responses.json` existant. `/providence reload` recharge les trois fichiers.
|
||||
Un fichier invalide refuse le rechargement et conserve la session valide.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": 1,
|
||||
"deferredChance": 0.20,
|
||||
"alternativeChance": 0.25,
|
||||
"silenceChance": 0.15,
|
||||
"deferredSeconds": 30,
|
||||
"alternatives": {
|
||||
"RECOVER_ITEMS": ["RETURN_TO_DEATH", "RESCUE_PLAYER"],
|
||||
"HEAL": ["FEED"],
|
||||
"FEED": ["HEAL"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Cet extrait est valide ; le fichier distribué contient les quatorze intentions.
|
||||
Les probabilités sont comprises entre zéro et un, et la somme
|
||||
`alternativeChance + silenceChance` doit rester inférieure ou égale à un.
|
||||
`deferredSeconds` est un entier entre **1 et 3 600 secondes**. Chaque seconde
|
||||
correspond à vingt ticks serveur : pause et ralentissements allongent l’attente.
|
||||
Le réglage indépendant `responseDelaySeconds` de `miracles.json` reste entre
|
||||
une et soixante secondes.
|
||||
|
||||
La chance propre du miracle demandé est examinée en premier. Parmi les
|
||||
miracles accordés, 20 % sont différés par défaut, les autres sont exaucés au
|
||||
moment de la réponse. Pour une décision défavorable, un nouveau tirage alloue
|
||||
15 % au silence et 25 % à une tentative d’alternative. Une alternative n’est
|
||||
possible que pour une intention reconnue, active et hors cooldown ; une
|
||||
intention sans besoin actuel peut aussi recevoir une autre aide. Les demandes
|
||||
inconnues, ambiguës, désactivées ou en cooldown ne reçoivent pas d’alternative.
|
||||
Elles peuvent rester silencieuses selon le même réglage.
|
||||
|
||||
L’alternative choisit la première aide configurée qui est active, admissible
|
||||
et hors cooldown. Elle conserve **sa propre probabilité** : un tirage refusé
|
||||
ne déclenche pas de recherche successive d’un autre miracle. Les cooldowns
|
||||
du miracle demandé et de cette autre aide sont réservés avant son tirage,
|
||||
même si celui-ci refuse l’aide. Une autre formulation ou une autre intention
|
||||
ne permet donc pas de contourner le cooldown de cette aide.
|
||||
|
||||
Seules Guérison, Nourriture, Secours, Retour et Réparation peuvent servir
|
||||
d’alternatives ; elles s’adressent au demandeur. La configuration refuse une
|
||||
alternative identique à l’intention initiale, un doublon ou un effet ciblé
|
||||
hostile. Une cible absente, ambiguë ou remplacée n’est pas un prétexte pour
|
||||
accorder une autre aide. Les cinq prières hostiles demandées explicitement
|
||||
restent autorisées contre tous les joueurs connectés, comme en beta.268.
|
||||
|
||||
Le fichier distribué propose Retour puis Secours à la Récupération, Secours
|
||||
au Retour, Nourriture à la Guérison, Guérison à la Nourriture, et Guérison puis
|
||||
Nourriture aux autres intentions. Un joueur sans besoin admissible reçoit
|
||||
un refus ou du silence.
|
||||
|
||||
## Exécution différée et sauvegardes
|
||||
|
||||
Une prière en attente de réponse et un miracle différé occupent **la même
|
||||
place** dans la file : une seule opération par joueur, 1 024 au total.
|
||||
Les répétitions sont ignorées et ne remplacent pas la demande. Aucun thread
|
||||
ne dort. Le rechargement conserve l’échéance, l’intention et la cible déjà
|
||||
retenues ; le miracle n’est pas réinterprété ni tiré à nouveau à son échéance.
|
||||
|
||||
Le serveur revérifie alors l’état vivant du demandeur, la présence du même
|
||||
UUID ciblé, la destination, les objets réels et toutes les conditions du
|
||||
miracle. Il applique la définition active : désactiver ce miracle avant son
|
||||
échéance empêche l’effet. Un autre UUID portant le même pseudo ne
|
||||
remplace jamais la cible. Les arrivées et transferts conservent les protections
|
||||
de beta.268.
|
||||
|
||||
Les cooldowns positifs sont réservés au-delà du rendez-vous annoncé, puis
|
||||
prolongés si l’exécution réelle survient plus tard. Un cooldown global réglé
|
||||
à zéro reste désactivé : un joueur qui attend sa guérison ne bloque pas la
|
||||
guérison des autres joueurs. L’opération ne peut s’exécuter qu’une fois.
|
||||
|
||||
Une déconnexion, une réapparition ou l’arrêt normal du serveur annule le
|
||||
miracle différé, sans effet hors ligne. Les cooldowns réservés sont conservés. Une interruption brutale laisse une
|
||||
trace `DEFERRED` ou `PENDING`, avec `executed: false` ; les opérations en mémoire
|
||||
ne sont jamais reconstruites ou rejouées au redémarrage. Comme auparavant,
|
||||
une interruption pendant un effet peut laisser un résultat incertain.
|
||||
|
||||
Le journal `data/sanctuary-providence.json` garde **son schéma 1** et ses champs
|
||||
existants. Les nouveaux verdicts sont des valeurs du champ texte `decision`.
|
||||
La complétion ou l’annulation met à jour la même entrée, sans dupliquer la
|
||||
prière. Une alternative garde l’intention demandée et ajoute une condition
|
||||
`alternative_<ID>` pour identifier l’aide effectivement tentée.
|
||||
Le scellement permanent des tombes de beta.268 est inchangé. Aucun monde
|
||||
personnel, format de tombe, générateur ou identifiant `sanctuary:*` n’est modifié.
|
||||
|
||||
## Vérifications
|
||||
|
||||
`check build assemblePack` réussit en **2 min 36 s**, avec **191 vérifications
|
||||
Providence indépendantes de Minecraft** (146 existantes et 45 nouvelles) et
|
||||
les **22 GameTests serveur requis**. Les scénarios serveur partagés gardent
|
||||
une politique de test explicite ; le parcours des six nouvelles issues commence
|
||||
après les scénarios existants pour éviter les rechargements concurrents de leur
|
||||
configuration commune.
|
||||
|
||||
Les nouvelles assertions couvrent les six issues, les cinq couleurs dans les
|
||||
deux modes, le silence sans message, la réparation/guérison différée, une autre
|
||||
aide effective, les cooldowns des deux aides y compris un tirage alternatif
|
||||
refusé, l’absence de blocage global quand le cooldown vaut zéro, les échéances
|
||||
conservées après rechargement, les cibles par UUID, les besoins modifiés,
|
||||
la désactivation avant échéance, l’annulation, le non-rejeu, le journal désactivé
|
||||
et la corruption des configurations. Le JSON invalide injecté dans l’ancien
|
||||
scénario de rechargement écrit une erreur attendue dans le journal serveur.
|
||||
|
||||
Le parcours **Vulkan / MoltenVK 1.4.2 / Apple M1** réussit en **1 min 22 s**,
|
||||
avec 8 Gio maximum, dans un monde de développement neuf de graine 265.
|
||||
Les cinq couleurs visibles sont rendues en FR/EN, GUI 2/3/4, SGA et lecture
|
||||
normale. Les commandes passent par la connexion réelle du jeu. La météo
|
||||
attend **102 ticks serveur** avant sa réponse ; un éclair ciblé reste visible.
|
||||
Une nouvelle prière de réparation conserve les cinq secondes d’attente initiale,
|
||||
puis montre une réponse jaune sans réparation. Avec un délai supplémentaire
|
||||
de **deux secondes réservé à cet essai**, l’objet reste endommagé pendant
|
||||
l’attente, puis est réparé et reçoit une réponse verte. Le réglage distribué
|
||||
reste trente secondes.
|
||||
|
||||
Seize captures natives : `build/providence269-screenshots/`. Les vues SGA
|
||||
FR GUI 2, lecture normale EN GUI 4, réparation différée et réparation terminée
|
||||
ont été inspectées : couleurs, police, texte contenu dans le chat et ordre des
|
||||
effets. Les essais concernent macOS local et des joueurs simulés côté serveur ;
|
||||
aucun essai avec deux clients distants ou sur Windows n’est revendiqué.
|
||||
|
||||
```sh
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
|
||||
./gradlew check build assemblePack \
|
||||
-PsanctuaryFocusedTests=providence265,providence268,providence269,graves,progression \
|
||||
-PsanctuaryAtlasOnly=true
|
||||
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
|
||||
-PsanctuaryQuickTests=true -PsanctuaryProvidence265ClientTests=true \
|
||||
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
|
||||
-PsanctuaryClientNoVsync=true
|
||||
```
|
||||
|
||||
Journaux ignorés : `build/providence269-check-build-pack-final.log` et
|
||||
`build/providence269-client-vulkan.log`.
|
||||
Le [pack local beta.269](../build/Sanctuary-beta.269.mrpack) est exporté et vérifié :
|
||||
ZIP intègre, version et dépendances exactes, un seul JAR Sanctuary identique au
|
||||
build, 36 classes Providence identiques aux sorties compilées, ressources
|
||||
identiques aux sources et absence de classes de test. Depuis beta.268, les
|
||||
classes existantes modifiées proviennent seulement de `PrayerQueue`,
|
||||
`ProvidenceEngine`, `ProvidenceMessages` et `ProvidenceService`, classes internes
|
||||
comprises. `ProvidenceResponses` et les types de réservation/exécution sont
|
||||
nouveaux. Reçu et empreintes : `build/providence269-validation.json`.
|
||||
Aucune publication, avancée du canal ou installation personnelle.
|
||||
+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.261
|
||||
pack_version=beta.261
|
||||
mod_version=beta.269
|
||||
pack_version=beta.269
|
||||
resource_pack_version=beta.121
|
||||
maven_group=fr.koka.sanctuary
|
||||
jei_version=30.32.0-sanctuary.4
|
||||
|
||||
@@ -278,7 +278,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 = [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 = [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)
|
||||
@@ -486,6 +486,12 @@ tasks.named('processGametestResources') {
|
||||
def starterNameClient = providers.gradleProperty('sanctuaryStarterNameClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('starterNameClient', starterNameClient)
|
||||
if (starterNameClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Starter052ClientChecks') }
|
||||
def providence265Client = providers.gradleProperty('sanctuaryProvidence265ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryProvidence265ClientTests', providence265Client)
|
||||
if (providence265Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Providence265ClientChecks') }
|
||||
def hello262Client = providers.gradleProperty('sanctuaryHello262ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryHello262ClientTests', hello262Client)
|
||||
if (hello262Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Hello262ClientChecks') }
|
||||
def head050Client = providers.gradleProperty('sanctuaryHead050ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('head050Client', head050Client)
|
||||
if (head050Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Head050ClientChecks') }
|
||||
@@ -1473,3 +1479,18 @@ tasks.register('exportStandaloneLaunch') {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Providence's interpreter, decisions and persistence run independently of Minecraft.
|
||||
tasks.register('providence265Smoke', JavaExec) {
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.providence.Providence265Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('providence265Smoke') }
|
||||
|
||||
tasks.register('providence269Smoke', JavaExec) {
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.providence.Providence269Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('providence269Smoke') }
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ public final class Access166ClientChecks implements FabricClientGameTest {
|
||||
if(!running.get()||!(m.gui.screen() instanceof HelloWorldScreen screen))return;
|
||||
if(!filled.getAndSet(true)){
|
||||
welcome.set(screen);
|
||||
check(screen.children().stream().anyMatch(child->child instanceof Button b && b.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals("sanctuary.progression.get_code")),"Registration link is offered by configured server");
|
||||
check(screen.children().stream().anyMatch(child->child instanceof Button b && b.getMessage().getContents() instanceof TranslatableContents t && (t.getKey().equals("sanctuary.progression.get_code")||t.getKey().equals("sanctuary.progression.get_code_short"))),"Registration link is offered by configured server");
|
||||
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());
|
||||
for(var child:screen.children())if(child instanceof EditBox box && box.getMessage().getContents() instanceof TranslatableContents text){
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import fr.koka.sanctuary.companion.CompanionType;
|
||||
import fr.koka.sanctuary.gametest.Starter039TestSupport;
|
||||
import fr.koka.sanctuary.gametest.Starter052ClientChecks;
|
||||
import fr.koka.sanctuary.progression.*;
|
||||
import java.util.*;
|
||||
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.*;
|
||||
import net.minecraft.client.gui.screens.TitleScreen;
|
||||
import net.minecraft.client.input.KeyEvent;
|
||||
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||
|
||||
/** Cold configuration UI, all palette pages and server refreshes, then a real named admission. */
|
||||
public final class Hello262ClientChecks implements FabricClientGameTest {
|
||||
private static final KeyEvent PRESS=new KeyEvent(InputConstants.KEY_RETURN,0,0);
|
||||
private static final String BIO="Une habitante qui explore les îles.",NAME="Étoile de lune";
|
||||
private static final List<Integer> PALETTE=java.util.stream.IntStream.range(0,20).map(i->InhabitantStore.color(42,i)).boxed().toList();
|
||||
private static final List<StarterCompanions.Choice> CHOICES=List.of("cat","chicken","pig").stream().map(id->new StarterCompanions.Choice(id,CompanionType.Rarity.COMMON)).toList();
|
||||
private static StarterPayloads.Welcome welcome(List<Integer> colors,String error,boolean locked){return new StarterPayloads.Welcome(new ProgressionPayloads.Hello("Poupoutain",colors,error),locked?BIO:"",locked,CHOICES);}
|
||||
|
||||
@Override public void runTest(ClientGameTestContext c){
|
||||
Starter039TestSupport.manual=true;
|
||||
try{
|
||||
c.runOnClient(m->{require(m.player==null&&m.level==null,"Welcome renders on a cold client");m.getWindow().setWindowed(1280,960);});
|
||||
c.waitTicks(5);
|
||||
Hello262PreviewChecks.run(c);
|
||||
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();m.gui.setScreen(new HelloWorldScreen(welcome(PALETTE,"",false),()->{}));
|
||||
var s=(HelloWorldScreen)m.gui.screen();bounds(s);
|
||||
require(!button(s,"sanctuary.progression.enter").active,"An explicit colour and familiar are required");
|
||||
edit(s,"sanctuary.progression.bio").setValue(BIO);edit(s,"sanctuary.starter.name").setValue(NAME);
|
||||
slots(s).get(1).onPress(PRESS);
|
||||
var seen=new ArrayList<Integer>();int count=colors(s).size();
|
||||
require(count==9,"Nine colours per page at every GUI scale");
|
||||
require(!page(s,false).active,"Previous page disabled at start");
|
||||
do{
|
||||
seen.addAll(colors(s).stream().map(b->number(b,"rgb")).toList());
|
||||
bounds(s);
|
||||
if(!page(s,true).active)break;
|
||||
var next=page(s,true);s.setFocused(next);next.onPress(PRESS);
|
||||
require(s.getFocused()==next,"Keyboard focus remains on the palette arrow while paging");
|
||||
}while(true);
|
||||
require(seen.equals(PALETTE),"Every server colour appears once, in order, including the partial final page");
|
||||
colors(s).getLast().onPress(PRESS);int selected=number(s,"color");
|
||||
page(s,false).onPress(PRESS);
|
||||
require(number(s,"color")==selected&&edit(s,"sanctuary.progression.bio").getValue().equals(BIO)&&edit(s,"sanctuary.starter.name").getValue().equals(NAME),"Paging preserves selection and both drafts");
|
||||
require(button(s,"sanctuary.progression.enter").active,"Selection on another palette page remains valid");
|
||||
m.resizeGui();s=(HelloWorldScreen)m.gui.screen();
|
||||
require(number(s,"color")==selected&&edit(s,"sanctuary.starter.name").getValue().equals(NAME),"Resize preserves choices and drafts");
|
||||
var remaining=PALETTE.subList(0,7);
|
||||
s.accept(welcome(remaining,"color_taken",false));
|
||||
require(number(s,"color")==-1&&!button(s,"sanctuary.progression.enter").active,"A colour taken on the server clears only the rejected colour");
|
||||
require(edit(s,"sanctuary.progression.bio").getValue().equals(BIO)&&edit(s,"sanctuary.starter.name").getValue().equals(NAME)&&value(s,"starter").equals("chicken"),"Server rejection keeps biography, familiar and name");
|
||||
colors(s).getFirst().onPress(PRESS);bounds(s);
|
||||
s.accept(welcome(PALETTE,"",false));colors(s).getFirst().onPress(PRESS);
|
||||
edit(s,"sanctuary.progression.bio").setCursorPosition(0);edit(s,"sanctuary.progression.bio").setHighlightPos(0);bounds(s);
|
||||
});
|
||||
c.waitTicks(3);c.takeScreenshot("hello262-"+locale+"-gui"+scale);
|
||||
if(locale.equals("fr_fr")&&scale==2){
|
||||
cursor(c,false);c.waitTicks(3);c.takeScreenshot("hello262-player-cursor-left");
|
||||
var before=c.computeOnClient(m->portraitAngles((HelloWorldScreen)m.gui.screen()));
|
||||
cursor(c,true);c.waitTicks(3);c.takeScreenshot("hello262-player-cursor-right");
|
||||
c.runOnClient(m->{var after=portraitAngles((HelloWorldScreen)m.gui.screen());require(before[0]<0&&after[0]>0&&after[1]<before[1]-10,"Native 3D player faces the cursor on both axes without dragging: "+Arrays.toString(before)+" -> "+Arrays.toString(after));});
|
||||
cursor(c,false);c.waitTicks(3);
|
||||
}
|
||||
c.runOnClient(m->{
|
||||
var s=(HelloWorldScreen)m.gui.screen();s.setAccessSite("https://example.com/register");s.requireAccess(false);
|
||||
edit(s,"sanctuary.progression.code").setValue("bad");
|
||||
require(!button(s,"sanctuary.progression.enter").active,"Unrecognized access code blocks entry");bounds(s);
|
||||
s.prepareAccess(true);s.accept(welcome(PALETTE,"",false));
|
||||
require(button(s,"sanctuary.progression.enter").active,"Already recognized admission can resume");bounds(s);
|
||||
});
|
||||
c.waitTicks(3);c.takeScreenshot("hello262-access-"+locale+"-gui"+scale);
|
||||
c.runOnClient(m->{
|
||||
var s=new HelloWorldScreen(new StarterPayloads.Welcome(new ProgressionPayloads.Hello("Poupoutain",PALETTE,""),"",false,
|
||||
List.of("zombie","camel","chicken").stream().map(id->new StarterCompanions.Choice(id,CompanionType.Rarity.COMMON)).toList()),()->{});
|
||||
m.gui.setScreen(s);colors(s).getFirst().onPress(PRESS);slots(s).getFirst().onPress(PRESS);edit(s,"sanctuary.starter.name").setValue("Petit Zombie");bounds(s);
|
||||
});
|
||||
cursor(c,false);c.waitTicks(3);c.takeScreenshot("hello264-zombie-"+locale+"-gui"+scale);
|
||||
cursor(c,true);c.waitTicks(3);c.takeScreenshot("hello264-zombie-cursor-right-"+locale+"-gui"+scale);cursor(c,false);
|
||||
c.runOnClient(m->{var s=(HelloWorldScreen)m.gui.screen();slots(s).get(1).onPress(PRESS);edit(s,"sanctuary.starter.name").setValue("Petit chameau");});
|
||||
c.waitTicks(3);c.takeScreenshot("hello264-camel-"+locale+"-gui"+scale);
|
||||
if(locale.equals("fr_fr")&&scale==2){
|
||||
c.runOnClient(m->{var s=(HelloWorldScreen)m.gui.screen();var egg=slots(s).getFirst();s.setFocused(null);
|
||||
var w=m.getWindow();double x=egg.getX()+11,y=egg.getY()+11,sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();
|
||||
m.mouseHandler.onMove(w.handle(),x*sx,y*sy,0,0);m.mouseHandler.handleAccumulatedMovement();});
|
||||
c.waitTicks(10);c.takeScreenshot("hello264-zombie-hover");cursor(c,false);
|
||||
}
|
||||
c.runOnClient(m->{var s=(HelloWorldScreen)m.gui.screen();s.prepareAccess(true);s.accept(welcome(PALETTE,"",false));edit(s,"sanctuary.starter.name").setValue(NAME);slots(s).get(1).onPress(PRESS);});
|
||||
}
|
||||
c.runOnClient(m->{
|
||||
var s=(HelloWorldScreen)m.gui.screen();
|
||||
int chosen=number(s,"color");
|
||||
m.options.guiScale().set(2);m.resizeGui();s=(HelloWorldScreen)m.gui.screen();
|
||||
require(colors(s).size()==9&&number(s,"color")==chosen,"Wider menu keeps nine colours and the selected RGB");
|
||||
m.options.guiScale().set(4);m.resizeGui();s=(HelloWorldScreen)m.gui.screen();
|
||||
require(colors(s).size()==9&&number(s,"color")==chosen&&edit(s,"sanctuary.starter.name").getValue().equals(NAME),"Narrow menu keeps nine colours, the draft and selected RGB");
|
||||
s.accept(welcome(List.of(PALETTE.getLast()),"",true));bounds(s);
|
||||
require(!colors(s).getFirst().active&&!page(s,true).active&&!page(s,false).active,"Saved identity and singleton palette stay locked");
|
||||
require(button(s,"sanctuary.progression.enter").active,"Saved identity can finish its familiar choice");
|
||||
m.gui.setScreen(new HelloWorldScreen(new ProgressionPayloads.Hello("Poupoutain",List.of(PALETTE.getFirst()),""),()->{}));s=(HelloWorldScreen)m.gui.screen();
|
||||
colors(s).getFirst().onPress(PRESS);bounds(s);
|
||||
require(button(s,"sanctuary.progression.enter").active,"Identity-only offers require no familiar");
|
||||
m.gui.setScreen(new HelloWorldScreen(new ProgressionPayloads.Hello("Poupoutain",List.of(),"storage_invalid"),()->{}));bounds((HelloWorldScreen)m.gui.screen());
|
||||
require(!button((HelloWorldScreen)m.gui.screen(),"sanctuary.progression.enter").active,"Empty offers stay visible and cannot be submitted");
|
||||
m.gui.setScreen(new TitleScreen(false));
|
||||
});
|
||||
}finally{Starter039TestSupport.manual=false;}
|
||||
var babies=Hello262PreviewChecks.verifyRealBabies();
|
||||
new Starter052ClientChecks().runTest(c);
|
||||
require(babies.get(),"Welcome previews match the real native baby forms after admission");
|
||||
new Access166ClientChecks().runTest(c);
|
||||
System.out.println("HELLO262_CLIENT_PASS: Vulkan, cold welcome, FR/EN GUI 2/3/4, full palette, access and locked identity, rejection/drafts, real named and code admissions, saved reconnect");
|
||||
}
|
||||
private static List<Button> colors(HelloWorldScreen s){return buttons(s,"ColorButton");}
|
||||
private static List<Button> slots(HelloWorldScreen s){return buttons(s,"StarterSlot");}
|
||||
private static List<Button> buttons(HelloWorldScreen s,String type){return s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.visible&&b.getClass().getSimpleName().equals(type)).toList();}
|
||||
private static Button page(HelloWorldScreen s,boolean next){return buttons(s,"ColorPageButton").stream().filter(b->b.getMessage().getString().equals(next?">":"<")).findFirst().orElseThrow();}
|
||||
private static AbstractWidget widget(HelloWorldScreen s,String key){return s.children().stream().filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->w.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow();}
|
||||
private static Button button(HelloWorldScreen s,String key){return (Button)widget(s,key);}
|
||||
private static EditBox edit(HelloWorldScreen s,String key){return (EditBox)widget(s,key);}
|
||||
private static int number(Object target,String field){return (Integer)value(target,field);}
|
||||
private static float[] portraitAngles(HelloWorldScreen s){var portrait=s.children().stream().filter(w->w.getClass().getSimpleName().equals("PlayerPortrait")).findFirst().orElseThrow();return new float[]{(Float)value(portrait,"yaw"),(Float)value(portrait,"pitch")};}
|
||||
private static void cursor(ClientGameTestContext c,boolean bottomRight){c.runOnClient(m->{var w=m.getWindow();double x=bottomRight?w.getGuiScaledWidth()-1:0,y=bottomRight?w.getGuiScaledHeight()-1:0,sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();m.mouseHandler.onMove(w.handle(),x*sx,y*sy,(x-m.mouseHandler.getScaledXPos(w))*sx,(y-m.mouseHandler.getScaledYPos(w))*sy);m.mouseHandler.handleAccumulatedMovement();});}
|
||||
@SuppressWarnings("unchecked") private static <T> T value(Object target,String field){try{var f=target.getClass().getDeclaredField(field);f.setAccessible(true);return (T)f.get(target);}catch(Exception error){throw new AssertionError(error);}}
|
||||
private static void bounds(HelloWorldScreen s){
|
||||
var widgets=s.children().stream().filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->w.visible).toList();
|
||||
for(var w:widgets)require(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Control fits "+s.width+"x"+s.height+": "+w.getMessage().getString());
|
||||
for(String key:List.of("sanctuary.progression.enter","gui.back")){
|
||||
var b=button(s,key);require(net.minecraft.client.Minecraft.getInstance().font.width(b.getMessage())<=b.getWidth()-8,"Action label fits without scrolling: "+key);
|
||||
}
|
||||
var biography=edit(s,"sanctuary.progression.bio");
|
||||
for(String hint:List.of("sanctuary.progression.bio_hint","sanctuary.starter.name_hint"))
|
||||
require(net.minecraft.client.Minecraft.getInstance().font.width(net.minecraft.network.chat.Component.translatable(hint))<=biography.getWidth()-8,"Placeholder fits the hotbar column: "+hint);
|
||||
var enter=button(s,"sanctuary.progression.enter");var back=button(s,"gui.back");
|
||||
require(biography.getWidth()==182&&Math.abs(biography.getX()*2+biography.getWidth()-s.width)<=1,"Form uses the centred native hotbar width");
|
||||
require(enter.getWidth()==biography.getWidth()&&back.getWidth()==biography.getWidth(),"Both actions use the same width as the form");
|
||||
require(enter.getY()==s.height-26&&back.getBottom()<s.height-33,"Enter occupies the native Done footer; Back stays in content");
|
||||
if(!slots(s).isEmpty()){
|
||||
var name=edit(s,"sanctuary.starter.name");
|
||||
require(name.getX()==biography.getX()&&name.getWidth()==biography.getWidth(),"Name and biography share the hotbar column");
|
||||
require(slots(s).getFirst().getY()>biography.getBottom()&&slots(s).getLast().getBottom()<name.getY(),"Familiar choices follow biography and precede the name");
|
||||
for(var egg:slots(s))require(egg.getX()>=biography.getX()&&egg.getRight()<=biography.getRight(),"Egg choices are inside the central column");
|
||||
}
|
||||
var portrait=widgets.stream().filter(w->w.getClass().getSimpleName().equals("PlayerPortrait")).findFirst().orElseThrow();
|
||||
require(portrait.getY()==number(s,"previewY")&&portrait.getHeight()==number(s,"previewHeight"),"Portraits share the same bounds and floor");
|
||||
for(int i=0;i<widgets.size();i++)for(int j=i+1;j<widgets.size();j++){
|
||||
var a=widgets.get(i);var b=widgets.get(j);
|
||||
require(a.getRight()<=b.getX()||b.getRight()<=a.getX()||a.getBottom()<=b.getY()||b.getBottom()<=a.getY(),"Controls do not overlap: "+a.getMessage().getString()+" / "+b.getMessage().getString());
|
||||
}
|
||||
}
|
||||
private static void require(boolean condition,String message){if(!condition)throw new AssertionError(message);}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import fr.koka.sanctuary.companion.CompanionType;
|
||||
import java.lang.reflect.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||
import net.minecraft.client.renderer.entity.EntityRenderer;
|
||||
import net.minecraft.client.renderer.entity.LivingEntityRenderer;
|
||||
import net.minecraft.client.renderer.entity.state.*;
|
||||
import net.minecraft.client.renderer.state.level.CameraRenderState;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.item.*;
|
||||
import net.minecraft.world.entity.Mob;
|
||||
import net.minecraft.world.entity.EntitySpawnReason;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Every eligible starter submits a textured native model before item components are bound. */
|
||||
final class Hello262PreviewChecks {
|
||||
static final List<CompanionType> TYPES=Arrays.stream(CompanionType.values()).filter(t->t.rarity.ordinal()<2).toList();
|
||||
@SuppressWarnings({"rawtypes","unchecked"}) static void run(ClientGameTestContext c){
|
||||
c.runOnClient(m->{
|
||||
require(m.level==null&&m.player==null,"3D previews precede world admission");
|
||||
boolean unbound=false;
|
||||
try{new ItemStack(Items.COW_SPAWN_EGG);}catch(NullPointerException expected){unbound="Components not bound yet".equals(expected.getMessage());}
|
||||
require(unbound,"Preview is tested in the cold multiplayer component state");
|
||||
for(var type:TYPES){
|
||||
var state=HelloFamiliarPreview.state(type.id());
|
||||
var livingState=(LivingEntityRenderState)state;
|
||||
if(List.of("cat","chicken","cow","pig","wolf","zombie","turtle").contains(type.id()))require(livingState.isBaby,"Native baby appearance is selected for "+type.id());
|
||||
if(List.of("spider","skeleton","frog","cod").contains(type.id()))require(!livingState.isBaby,"Species without native baby forms retain their real familiar appearance: "+type.id());
|
||||
EntityRenderer renderer=m.getEntityRenderDispatcher().getRenderer(state);
|
||||
var calls=new AtomicInteger();
|
||||
var collector=(SubmitNodeCollector)Proxy.newProxyInstance(Hello262PreviewChecks.class.getClassLoader(),new Class<?>[]{SubmitNodeCollector.class},(proxy,method,args)->{
|
||||
if(method.getName().equals("order"))return proxy;
|
||||
if(method.getName().equals("submitModel"))calls.incrementAndGet();
|
||||
if(method.isDefault())return InvocationHandler.invokeDefault(proxy,method,args);
|
||||
return null;
|
||||
});
|
||||
var camera=new CameraRenderState();camera.pos=Vec3.ZERO;
|
||||
renderer.submit(state,new PoseStack(),collector,camera);
|
||||
require(calls.get()>0,"Native model is submitted for "+type.id());
|
||||
require(state.boundingBoxHeight>0&&state.boundingBoxWidth>0,"Preview has finite native dimensions: "+type.id());
|
||||
if(renderer instanceof LivingEntityRenderer living){
|
||||
var texture=living.getTextureLocation((LivingEntityRenderState)state);
|
||||
require(m.getResourceManager().getResource(texture).isPresent(),"Preview texture exists for "+type.id()+": "+texture);
|
||||
}
|
||||
}
|
||||
m.options.guiScale().set(2);m.resizeGui();m.gui.setScreen(new Gallery());
|
||||
});
|
||||
c.getInput().setCursorPos(0,0);c.waitTicks(4);c.takeScreenshot("hello262-cold-models-left");
|
||||
float[] left=c.computeOnClient(m->{var state=(LivingEntityRenderState)((Gallery)m.gui.screen()).states.get("cat");return new float[]{state.yRot,state.xRot};});
|
||||
c.getInput().setCursorPos(640,480);c.waitTicks(4);c.takeScreenshot("hello262-cold-models-right");
|
||||
c.runOnClient(m->{
|
||||
var state=(LivingEntityRenderState)((Gallery)m.gui.screen()).states.get("cat");
|
||||
require(state.yRot<left[0]&&state.xRot>left[1],"Native familiar head follows the cursor horizontally and vertically");
|
||||
require(m.level==null&&m.player==null,"Model rendering never creates a world or player");
|
||||
});
|
||||
c.getInput().setCursorPos(0,0);
|
||||
System.out.println("HELLO262_MODELS_PASS: "+TYPES.size()+" native starter models with baby forms and textures, unbound item components, 3D mouse tracking");
|
||||
}
|
||||
@SuppressWarnings({"rawtypes","unchecked"}) static java.util.concurrent.atomic.AtomicBoolean verifyRealBabies(){
|
||||
var verified=new java.util.concurrent.atomic.AtomicBoolean();
|
||||
ClientTickEvents.END_CLIENT_TICK.register(m->{
|
||||
if(verified.get()||m.player==null||m.level==null)return;
|
||||
var mismatches=new ArrayList<String>();
|
||||
for(var type:TYPES){
|
||||
var preview=(LivingEntityRenderState)HelloFamiliarPreview.state(type.id());
|
||||
var entity=preview.entityType.create(m.level,EntitySpawnReason.MOB_SUMMONED);
|
||||
require(entity instanceof Mob,"Familiar is a native mob: "+type.id());
|
||||
var mob=(Mob)entity;mob.setId(m.player.getId()+1000+type.ordinal());mob.setPos(m.player.position());mob.setBaby(true);
|
||||
EntityRenderer renderer=m.getEntityRenderDispatcher().getRenderer(mob);
|
||||
var actual=(LivingEntityRenderState)renderer.createRenderState(mob,1);
|
||||
if(preview.isBaby!=actual.isBaby)mismatches.add(type.id()+" age "+preview.isBaby+" / "+actual.isBaby);
|
||||
if(preview.ageScale!=actual.ageScale)mismatches.add(type.id()+" age scale "+preview.ageScale+" / "+actual.ageScale);
|
||||
if(renderer instanceof LivingEntityRenderer living){
|
||||
var texture=living.getTextureLocation(actual);var previewTexture=living.getTextureLocation(preview);
|
||||
if(!texture.equals(previewTexture))mismatches.add(type.id()+" texture "+previewTexture+" / "+texture);
|
||||
}
|
||||
}
|
||||
require(mismatches.isEmpty(),"All previews match native Mob.setBaby(true): "+String.join("; ",mismatches));
|
||||
verified.set(true);System.out.println("HELLO263_BABIES_PASS: all 44 previews match native Mob.setBaby(true) ages, age scales and textures");
|
||||
});
|
||||
return verified;
|
||||
}
|
||||
private static final class Gallery extends Screen {
|
||||
final Map<String,EntityRenderState> states=new HashMap<>();
|
||||
Gallery(){super(Component.literal("Hello World · 3D previews"));}
|
||||
@Override public void extractBackground(GuiGraphicsExtractor g,int mx,int my,float delta){extractMenuBackground(g);}
|
||||
@Override public void extractRenderState(GuiGraphicsExtractor g,int mx,int my,float delta){
|
||||
super.extractRenderState(g,mx,my,delta);g.centeredText(font,title,width/2,4,0xFFFFFFFF);
|
||||
int cellWidth=width/8,cellHeight=(height-20)/6;
|
||||
for(int i=0;i<TYPES.size();i++){
|
||||
var type=TYPES.get(i);int x=i%8*cellWidth,y=20+i/8*cellHeight;
|
||||
states.put(type.id(),HelloFamiliarPreview.extract(g,type.id(),x+4,y+2,cellWidth-8,cellHeight-16,mx,my));
|
||||
g.centeredText(font,type.label(),x+cellWidth/2,y+cellHeight-11,0xFFFFFFFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
private static void require(boolean condition,String message){if(!condition)throw new AssertionError(message);}
|
||||
}
|
||||
+2
-2
@@ -48,9 +48,9 @@ public final class Progression003ClientChecks implements FabricClientGameTest {
|
||||
context.runOnClient(client -> {
|
||||
var colorButtons=client.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast)
|
||||
.filter(b -> b.getClass().getSimpleName().equals("ColorButton")).toList();
|
||||
require(colorButtons.size()==20 && colorButtons.stream().allMatch(b -> b.getMessage().getContents()
|
||||
require(colorButtons.size()==9 && colorButtons.stream().allMatch(b -> b.getMessage().getContents()
|
||||
instanceof net.minecraft.network.chat.contents.TranslatableContents t && t.getKey().startsWith("sanctuary.color.")),
|
||||
"All twenty native colour buttons display translated hue names");
|
||||
"A hotbar page of nine native colour swatches exposes translated hue names");
|
||||
});
|
||||
context.takeScreenshot("sanctuary-beta005-couleurs-fr");
|
||||
Blocodex004ClientChecks.language(context,initialLanguage);
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import fr.koka.sanctuary.client.HelloWorldScreen;
|
||||
import fr.koka.sanctuary.providence.*;
|
||||
import fr.koka.sanctuary.client.ProgressionClient;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.*;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.screens.ChatScreen;
|
||||
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
||||
import net.minecraft.client.input.KeyEvent;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||
|
||||
/** Native Vulkan chat rendering plus a prayer sent over the real play connection. */
|
||||
public final class Providence265ClientChecks implements FabricClientGameTest {
|
||||
public void runTest(ClientGameTestContext c) {
|
||||
var onboarding=new AtomicBoolean(true);
|
||||
ClientTickEvents.END_CLIENT_TICK.register(m -> {
|
||||
if (onboarding.get() && m.gui.screen() instanceof HelloWorldScreen s) {
|
||||
Starter039TestSupport.selectFirst(s);
|
||||
var enter=new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);
|
||||
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast)
|
||||
.filter(b -> b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter);
|
||||
s.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);
|
||||
onboarding.set(false);
|
||||
}
|
||||
});
|
||||
try (var config=new Providence265TestConfig();
|
||||
var world=c.worldBuilder().adjustSettings(s -> {
|
||||
s.setName("Providence265 "+UUID.randomUUID());s.setSeed("265");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()) {
|
||||
world.getConnection().waitForChunksDownload(2400);
|
||||
c.waitFor(m -> m.player!=null && ProgressionClient.active() && m.gui.screen()==null,1200);
|
||||
c.runOnClient(m -> {m.getWindow().setWindowed(1280,960);m.options.guiScale().set(2);m.resizeGui();m.gui.hud.getChat().clearMessages(true);});
|
||||
var submittedAt=new AtomicLong();
|
||||
world.getServer().runOnServer(s -> {
|
||||
s.setDifficulty(net.minecraft.world.Difficulty.PEACEFUL,true);
|
||||
if(fr.koka.sanctuary.weather.WeatherService.active(s.overworld()))
|
||||
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather rain");
|
||||
else {var weather=s.overworld().getWeatherData();weather.setRainTime(10000);weather.setRaining(true);}
|
||||
submittedAt.set(s.overworld().getGameTime());
|
||||
});
|
||||
c.runOnClient(m -> m.getConnection().sendCommand("pray Ô seigneur, j'en ai marre de cet orage, faites revenir le soleil !"));
|
||||
c.waitTicks(10);
|
||||
c.runOnClient(m -> {
|
||||
require(messages(m).stream().noneMatch(message -> message.getContents() instanceof TranslatableContents t && t.getKey().startsWith("sanctuary.providence.")),"No immediate Providence reply");
|
||||
m.getConnection().sendCommand("pray bonjour");
|
||||
m.gui.setScreen(new ChatScreen("/pray ",false));
|
||||
});
|
||||
world.getServer().runOnServer(s -> require(s.overworld().getWeatherData().isRaining(),"No miracle executes during the response wait"));
|
||||
c.takeScreenshot("providence267-waiting");
|
||||
c.waitFor(m -> messages(m).stream().anyMatch(message -> message.getContents() instanceof TranslatableContents t && t.getKey().contains("sga.granted")),300);
|
||||
world.getServer().runOnServer(s -> {
|
||||
long elapsed=s.overworld().getGameTime()-submittedAt.get();
|
||||
require(elapsed>=100 && !s.overworld().getWeatherData().isRaining() && !s.overworld().getWeatherData().isThundering(),"Real network prayer changes weather only after five seconds");
|
||||
System.out.println("PROVIDENCE267_DELAY_PASS elapsed_server_ticks="+elapsed);
|
||||
});
|
||||
c.runOnClient(m -> {
|
||||
require(messages(m).stream().filter(message -> message.getContents() instanceof TranslatableContents t && t.getKey().contains("sga.granted")).count()==1,"Repeated prayer never replaces or duplicates the delayed reply");
|
||||
m.gui.setScreen(null);
|
||||
});
|
||||
c.runOnClient(m -> m.getConnection().sendCommand("providence readable true"));
|
||||
c.waitFor(m -> messages(m).stream().anyMatch(message -> message.getContents() instanceof TranslatableContents t && t.getKey().contains("readable.accessibility")),200);
|
||||
for (String locale:List.of("fr_fr","en_us")) for (int scale:List.of(2,3,4)) for (boolean readable:List.of(false,true)) {
|
||||
c.runOnClient(m -> {
|
||||
m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
|
||||
m.options.guiScale().set(scale);m.resizeGui();m.gui.hud.getChat().clearMessages(true);
|
||||
for (String key:List.of("granted","deferred","refused","incomprehensible","alternative")) {
|
||||
var message=ProvidenceMessages.message(key,readable,0);
|
||||
require(!message.getString().startsWith("sanctuary."),"Translated Providence message");
|
||||
var expected=switch(key){case "granted"->net.minecraft.ChatFormatting.GREEN;case "deferred"->net.minecraft.ChatFormatting.YELLOW;case "refused"->net.minecraft.ChatFormatting.RED;case "incomprehensible"->net.minecraft.ChatFormatting.GRAY;default->net.minecraft.ChatFormatting.LIGHT_PURPLE;};
|
||||
require(message.getStyle().getColor()!=null&&message.getStyle().getColor().getValue()==net.minecraft.network.chat.TextColor.fromLegacyFormat(expected).getValue(),"Five distinct verdict colours in SGA and readable mode");
|
||||
require(!ProvidenceMessages.visible("silent"),"Silent outcome is never added to visible chat");
|
||||
if (!readable) require(message.getStyle().getFont().toString().contains("providence"),"SGA font retained");
|
||||
m.gui.hud.getChat().addClientSystemMessage(message);
|
||||
}
|
||||
m.gui.setScreen(new ChatScreen("/pray ",false));
|
||||
});
|
||||
c.waitTicks(4);c.takeScreenshot("providence265-"+locale+"-gui"+scale+(readable?"-readable":"-sga"));
|
||||
}
|
||||
c.runOnClient(m -> {m.gui.setScreen(null);m.getConnection().sendCommand("providence readable false");});
|
||||
c.waitFor(m -> messages(m).stream().anyMatch(message -> message.getContents() instanceof TranslatableContents t && t.getKey().contains("sga.accessibility")),200);
|
||||
c.waitTicks(110);
|
||||
world.getServer().runOnServer(s -> {
|
||||
var p=s.getPlayerList().getPlayers().getFirst();p.setHealth(p.getMaxHealth());p.setInvulnerableTime(0);p.damageCooldownTime=0;
|
||||
});
|
||||
c.runOnClient(m -> {
|
||||
m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(2);m.resizeGui();
|
||||
m.gui.hud.getChat().clearMessages(true);m.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK);
|
||||
m.getConnection().sendCommand("pray foudroie @"+m.player.getGameProfile().name());
|
||||
});
|
||||
c.waitTicks(10);
|
||||
world.getServer().runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();require(p.getHealth()==p.getMaxHealth(),"Targeted lightning waits for the same five-second response deadline");});
|
||||
c.waitFor(m -> !m.level.getEntitiesOfClass(net.minecraft.world.entity.LightningBolt.class,m.player.getBoundingBox().inflate(4)).isEmpty(),300);
|
||||
c.takeScreenshot("providence268-lightning");
|
||||
world.getServer().runOnServer(s -> {
|
||||
var p=s.getPlayerList().getPlayers().getFirst();
|
||||
require(p.getHealth()<p.getMaxHealth(),"Real named prayer damages its target on the server");
|
||||
require(p.level().getEntitiesOfClass(net.minecraft.world.entity.LightningBolt.class,p.getBoundingBox().inflate(4)).isEmpty(),"Native lightning visual has no server world mutations");
|
||||
});
|
||||
c.waitFor(m -> messages(m).stream().anyMatch(message -> message.getContents() instanceof TranslatableContents t && t.getKey().contains("sga.granted")),100);
|
||||
c.runOnClient(m -> {m.options.setCameraType(net.minecraft.client.CameraType.FIRST_PERSON);m.gui.setScreen(null);});
|
||||
System.out.println("PROVIDENCE268_LIGHTNING_CLIENT_PASS: actual named network prayer, five-second delay, native visible bolt and server-only target damage");
|
||||
c.waitTicks(110);
|
||||
world.getServer().runOnServer(s -> {
|
||||
try{Providence269GameTests.configure(s,1,1,0,0,5,2);}catch(Exception e){throw new RuntimeException(e);}
|
||||
var tool=new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.DIAMOND_PICKAXE);tool.setDamageValue(77);
|
||||
s.getPlayerList().getPlayers().getFirst().setItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND,tool);
|
||||
});
|
||||
c.runOnClient(m -> {m.gui.hud.getChat().clearMessages(true);m.getConnection().sendCommand("pray repare mon outil");});
|
||||
c.waitFor(m -> messages(m).stream().anyMatch(message -> message.getContents() instanceof TranslatableContents t&&t.getKey().contains("sga.deferred")),300);
|
||||
world.getServer().runOnServer(s -> require(s.getPlayerList().getPlayers().getFirst().getMainHandItem().getDamageValue()==77,"Yellow deferred response precedes the real repair effect"));
|
||||
c.runOnClient(m -> m.gui.setScreen(new ChatScreen("",false)));c.takeScreenshot("providence269-deferred");c.waitTicks(10);
|
||||
world.getServer().runOnServer(s -> require(s.getPlayerList().getPlayers().getFirst().getMainHandItem().getDamageValue()==77,"Deferred repair still waits after its first visible response"));
|
||||
c.waitFor(m -> messages(m).stream().anyMatch(message -> message.getContents() instanceof TranslatableContents t&&t.getKey().contains("sga.granted")),160);
|
||||
world.getServer().runOnServer(s -> require(s.getPlayerList().getPlayers().getFirst().getMainHandItem().getDamageValue()==0,"Deferred repair actually runs at the later deadline"));
|
||||
c.takeScreenshot("providence269-granted");c.runOnClient(m -> m.gui.setScreen(null));
|
||||
System.out.println("PROVIDENCE269_DEFERRED_CLIENT_PASS: real network prayer, five-second response wait, yellow deferral, separate two-second effect wait, real repair and green completion");
|
||||
System.out.println("PROVIDENCE265_CLIENT_PASS: real network command, server weather, accessibility option, native chat FR/EN GUI 2/3/4 SGA/readable");
|
||||
} catch (Exception e) { throw new RuntimeException(e); }
|
||||
finally { onboarding.set(false); }
|
||||
}
|
||||
@SuppressWarnings("unchecked") private static List<Component> messages(net.minecraft.client.Minecraft m) {
|
||||
try {
|
||||
var state=m.gui.hud.getChat().storeState();var field=state.getClass().getDeclaredField("messages");field.setAccessible(true);
|
||||
return ((List<net.minecraft.client.multiplayer.chat.GuiMessage>)field.get(state)).stream().map(net.minecraft.client.multiplayer.chat.GuiMessage::content).toList();
|
||||
} catch(Exception e) { throw new RuntimeException(e); }
|
||||
}
|
||||
private static void require(boolean value,String message) { if(!value) throw new AssertionError(message); }
|
||||
}
|
||||
+2
-2
@@ -35,8 +35,8 @@ public final class Starter052ClientChecks implements FabricClientGameTest {
|
||||
require(name(screen).getValue().equals(NAME)&&screen.starterChoices().equals(choices),"Rejected colour preserves name and original eggs");
|
||||
screen.accept(welcome);
|
||||
var slots=screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("StarterSlot")).toList();
|
||||
require(slots.getFirst().getX()==name(screen).getX()&&slots.getFirst().getX()==color(screen).getX(),"Egg choices align with the name field and colour controls on the left");
|
||||
require(name(screen).getY()>slots.getFirst().getBottom()&&button(screen,"sanctuary.progression.enter").getY()>name(screen).getBottom(),"Name beneath eggs and above actions");
|
||||
require(slots.getFirst().getX()>=name(screen).getX()&&slots.getLast().getRight()<=name(screen).getRight(),"Egg choices occupy the central familiar section");
|
||||
require(name(screen).getY()>color(screen).getBottom()&&button(screen,"sanctuary.progression.enter").getY()>Math.max(name(screen).getBottom(),slots.getFirst().getBottom()),"Character above familiar name, actions below both panels");
|
||||
for(var child:screen.children())if(child instanceof AbstractWidget w)require(w.getX()>=0&&w.getRight()<=screen.width&&w.getY()>=0&&w.getBottom()<=screen.height,"Widget fits "+locale+" "+screen.width+"x"+screen.height);
|
||||
require(!name(screen).getMessage().getString().startsWith("sanctuary."),"Localized name field");
|
||||
System.out.println("STARTER052_LAYOUT "+locale+" gui="+screen.width+"x"+screen.height);
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import fr.koka.sanctuary.providence.*;
|
||||
import fr.koka.sanctuary.weather.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.*;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.storage.*;
|
||||
import net.minecraft.util.ProblemReporter;
|
||||
|
||||
public final class Providence265GameTests {
|
||||
@GameTest(maxTicks=200) public void commandsWeatherDeathSafetyAndDurability(GameTestHelper h) throws Exception {
|
||||
var server=h.getLevel().getServer();var level=h.getLevel();configured(server);
|
||||
var commands=server.getCommands().getDispatcher();var p=player(h);var traveler=player(h);
|
||||
var unsafe=player(h);var changed=player(h);var other=player(h);var unknown=player(h);
|
||||
var recovery=player(h);var disconnected=player(h);
|
||||
var historyPath=server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-providence.json");
|
||||
var death=traveler.blockPosition().offset(5,0,0);var distant=new BlockPos(30000000,-500,30000000);
|
||||
for(int dx=-1;dx<=1;dx++)for(int dz=-1;dz<=1;dz++) {
|
||||
level.setBlockAndUpdate(death.offset(dx,-1,dz),Blocks.STONE.defaultBlockState());
|
||||
for(int dy=0;dy<=2;dy++)level.setBlockAndUpdate(death.offset(dx,dy,dz),Blocks.AIR.defaultBlockState());
|
||||
}
|
||||
traveler.setLastDeathLocation(Optional.of(GlobalPos.of(level.dimension(),death)));
|
||||
changed.setLastDeathLocation(traveler.getLastDeathLocation());
|
||||
unsafe.setLastDeathLocation(Optional.of(GlobalPos.of(level.dimension(),distant)));
|
||||
var output=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,level.registryAccess());traveler.saveWithoutId(output);
|
||||
var loaded=player(h);loaded.load(TagValueInput.create(ProblemReporter.DISCARDING,level.registryAccess(),output.buildResult()));
|
||||
h.assertTrue(loaded.getLastDeathLocation().equals(traveler.getLastDeathLocation()),"Last death uses native saved player contract");
|
||||
h.assertTrue(commands.getRoot().getChild("pray").canUse(p.createCommandSourceStack()),"Prayer needs no operator");
|
||||
h.assertTrue(!commands.getRoot().getChild("providence").getChild("reload").canUse(p.createCommandSourceStack()),"Reload is operator-only");
|
||||
rain(server);int initialEntries=new PrayerHistory(historyPath).state().entries().size();
|
||||
h.assertTrue(commands.execute("pray Ô seigneur, j'en ai marre de cet orage, faites revenir le soleil !",p.createCommandSourceStack())==1,"Actual free-text command accepts a waiting prayer");
|
||||
h.assertTrue(commands.execute("pray ramenez moi a ma tombe",p.createCommandSourceStack())==0,"A second prayer cannot replace or rush the first");
|
||||
h.assertTrue(level.getWeatherData().isRaining(),"Accepting a prayer does not execute its miracle immediately");
|
||||
configured(server); // A valid reload preserves the original waiting request and its deadline.
|
||||
h.startSequence().thenIdle(10).thenExecute(checked(() -> {
|
||||
h.assertTrue(level.getWeatherData().isRaining() && new PrayerHistory(historyPath).state().entries().size()==initialEntries,"Halfway through the delay there is no effect or final decision");
|
||||
})).thenIdle(12).thenExecute(checked(() -> {
|
||||
h.assertTrue(!level.getWeatherData().isRaining() && !level.getWeatherData().isThundering(),"Delayed weather prayer clears native rain and thunder");
|
||||
if(WeatherService.active(level))h.assertTrue(WeatherService.session(server).state().weather()==DailyWeather.Kind.CLEAR,"Daily forecast changed as well");
|
||||
var history=new PrayerHistory(historyPath);
|
||||
h.assertTrue(history.state().entries().stream().filter(e->e.player().equals(p.getUUID())).count()==1 && decision(historyPath,p).executed(),"One original request executes and is persisted once after reload");
|
||||
h.assertTrue(commands.execute("providence readable true",p.createCommandSourceStack())==1,"Readability control remains immediate");
|
||||
configured(server);h.assertTrue(new PrayerHistory(historyPath).readable(p.getUUID()),"Readability persists across reload");
|
||||
var path=ProvidenceService.CONFIG.resolve("miracles.json");var valid=Files.readAllBytes(path);
|
||||
try {
|
||||
Files.writeString(path,"{invalid-config");
|
||||
h.assertTrue(commands.execute("providence reload",server.createCommandSourceStack())==0,"Invalid JSON refuses reload");
|
||||
h.assertTrue(commands.execute("providence readable false",p.createCommandSourceStack())==1,"Previous valid session stays usable");
|
||||
} finally {Files.write(path,valid);}
|
||||
rain(server);traveler.fallDistance=50;
|
||||
for(var request:Map.of(other,"arrêtez la pluie",unknown,"J'aime cette pluie, ne l'arrêtez pas",
|
||||
traveler,"Pitié ramenez moi à l'endroit où je suis mort",unsafe,"TP moi a ma tombe stp",
|
||||
changed,"ramenez moi a ma tombe",recovery,"Seigneur rendez moi mon stuff je suis mort",
|
||||
disconnected,"bonjour").entrySet())
|
||||
h.assertTrue(commands.execute("pray "+request.getValue(),request.getKey().createCommandSourceStack())==1,"Each player's request waits independently");
|
||||
h.assertTrue(!traveler.blockPosition().equals(death),"Return to death also waits before teleporting");
|
||||
changed.setLastDeathLocation(Optional.of(GlobalPos.of(level.dimension(),distant)));
|
||||
disconnected.disconnect();
|
||||
})).thenIdle(22).thenExecute(checked(() -> {
|
||||
h.assertTrue(decision(historyPath,other).decision().equals("COOLDOWN"),"Global miracle cooldown still applies at delayed resolution");
|
||||
h.assertTrue(decision(historyPath,unknown).decision().equals("INCOMPREHENSIBLE"),"Negated prayer is refused after its wait");
|
||||
h.assertTrue(traveler.blockPosition().equals(death) && traveler.fallDistance==0,"Delayed native teleport arrives safely and resets fall distance");
|
||||
h.assertTrue(decision(historyPath,unsafe).decision().equals("INELIGIBLE"),"Unloaded/out-of-border death refuses without generating chunks");
|
||||
h.assertTrue(decision(historyPath,changed).decision().equals("INELIGIBLE") && !changed.blockPosition().equals(death),"Destination is validated at resolution, not submission");
|
||||
h.assertTrue(decision(historyPath,recovery).decision().equals("INELIGIBLE"),"Recovery without an available death grave is refused");
|
||||
h.assertTrue(new PrayerHistory(historyPath).state().entries().stream().noneMatch(e -> e.player().equals(disconnected.getUUID())),"Disconnected player receives no decision or effect");
|
||||
unsafe.setLastDeathLocation(Optional.of(GlobalPos.of(net.minecraft.world.level.Level.NETHER,death)));
|
||||
h.assertTrue(!MiracleValidator.validate(unsafe,MiracleRegistry.Id.RETURN_TO_DEATH).conditions().get("same_dimension"),"Cross-dimension return is refused");
|
||||
level.setBlockAndUpdate(death,Blocks.LAVA.defaultBlockState());h.assertTrue(!MiracleValidator.safe(traveler,death),"Lava is unsafe");
|
||||
level.setBlockAndUpdate(death,Blocks.AIR.defaultBlockState());level.setBlockAndUpdate(death.above(),Blocks.STONE.defaultBlockState());
|
||||
h.assertTrue(!MiracleValidator.safe(traveler,death),"Low ceiling rejects standing collision box");
|
||||
traveler.setPose(net.minecraft.world.entity.Pose.SWIMMING);h.assertTrue(!MiracleValidator.safe(traveler,death),"Crawling cannot bypass standing-height safety");traveler.setPose(net.minecraft.world.entity.Pose.STANDING);
|
||||
level.setBlockAndUpdate(death.above(),Blocks.AIR.defaultBlockState());level.setBlockAndUpdate(death.east(),Blocks.CACTUS.defaultBlockState());
|
||||
h.assertTrue(!MiracleValidator.safe(traveler,death),"Adjacent cactus is unsafe");
|
||||
var mortal=player(h);mortal.setHealth(0);mortal.die(mortal.damageSources().genericKill());
|
||||
h.assertTrue(mortal.getLastDeathLocation().isPresent() && mortal.getLastDeathLocation().get().pos().equals(mortal.blockPosition()),"Native death records the position Providence reads");
|
||||
configured(server);
|
||||
System.out.println("PROVIDENCE267_SERVER_PASS: delayed effects and verdicts, no early execution, duplicate suppression, reload, changed destination, disconnect, safe teleport and existing cooldowns");
|
||||
})).thenSucceed();
|
||||
}
|
||||
private static void configured(MinecraftServer server)throws Exception {try(var config=new Providence265TestConfig(1)){ProvidenceService.reload(server);}}
|
||||
private static void rain(MinecraftServer server) throws Exception {
|
||||
if(WeatherService.active(server.overworld()))server.getCommands().getDispatcher().execute("sanctuary weather rain",server.createCommandSourceStack());
|
||||
else {var weather=server.overworld().getWeatherData();weather.setRainTime(10000);weather.setThunderTime(10000);weather.setRaining(true);weather.setThundering(true);}
|
||||
}
|
||||
private static PrayerHistory.Entry decision(Path path,ServerPlayer player)throws Exception {
|
||||
return new PrayerHistory(path).state().entries().stream().filter(e -> e.player().equals(player.getUUID())).reduce((a,b)->b).orElseThrow();
|
||||
}
|
||||
@FunctionalInterface private interface Checked {void run()throws Exception;}
|
||||
private static Runnable checked(Checked action) {return () -> {try {action.run();}catch(Exception e){throw new RuntimeException(e);}};}
|
||||
private static ServerPlayer player(GameTestHelper h) {var p=Progression003GameTests.player(h);p.setUUID(UUID.randomUUID());return p;}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.*;
|
||||
import fr.koka.sanctuary.providence.*;
|
||||
import java.nio.file.*;
|
||||
|
||||
/** Explicit disposable test launch only. Production defaults are never changed by the feature. */
|
||||
public final class Providence265TestConfig implements AutoCloseable {
|
||||
private final Path path=ProvidenceService.CONFIG.resolve("miracles.json");
|
||||
private final byte[] previous=Files.exists(path)?Files.readAllBytes(path):null;
|
||||
private final Path responsePath=ProvidenceService.CONFIG.resolve("responses.json");
|
||||
private final byte[] previousResponses=Files.exists(responsePath)?Files.readAllBytes(responsePath):null;
|
||||
public Providence265TestConfig() throws Exception {
|
||||
this(MiracleRegistry.DEFAULT_RESPONSE_DELAY_SECONDS);
|
||||
}
|
||||
public Providence265TestConfig(int responseDelaySeconds) throws Exception {
|
||||
MiracleRegistry.load(ProvidenceService.CONFIG);
|
||||
final JsonObject json;
|
||||
try(var stream=Providence265TestConfig.class.getResourceAsStream("/providence/miracles.json")) {
|
||||
json=JsonParser.parseString(new String(stream.readAllBytes(),java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
}
|
||||
json.addProperty("responseDelaySeconds",responseDelaySeconds);
|
||||
for (var element:json.getAsJsonArray("miracles")) {
|
||||
var o=element.getAsJsonObject(); o.addProperty("probability",1);
|
||||
}
|
||||
Files.writeString(path,new Gson().toJson(json));
|
||||
ProvidenceResponses.load(ProvidenceService.CONFIG);
|
||||
var responses=JsonParser.parseString(Files.readString(responsePath)).getAsJsonObject();
|
||||
for(String key:java.util.List.of("deferredChance","alternativeChance","silenceChance"))responses.addProperty(key,0);
|
||||
Files.writeString(responsePath,responses.toString());
|
||||
}
|
||||
public void close() throws Exception {
|
||||
if (previous==null) Files.deleteIfExists(path); else Files.write(path,previous);
|
||||
if (previousResponses==null) Files.deleteIfExists(responsePath); else Files.write(responsePath,previousResponses);
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import fr.koka.sanctuary.grave.*;
|
||||
import fr.koka.sanctuary.providence.*;
|
||||
import fr.koka.sanctuary.progression.*;
|
||||
import fr.koka.sanctuary.weather.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.*;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.network.*;
|
||||
import net.minecraft.network.protocol.PacketFlow;
|
||||
import net.minecraft.server.level.*;
|
||||
import net.minecraft.server.network.*;
|
||||
import net.minecraft.stats.Stats;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.entity.*;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.world.item.*;
|
||||
import net.minecraft.world.item.component.*;
|
||||
import net.minecraft.world.level.block.*;
|
||||
import net.minecraft.world.level.block.entity.SkullBlockEntity;
|
||||
import net.minecraft.world.level.gamerules.GameRules;
|
||||
import net.minecraft.world.level.storage.*;
|
||||
|
||||
public final class Providence268GameTests {
|
||||
@GameTest public void healthFoodRepairAndSafeSpawn(GameTestHelper h)throws Exception {
|
||||
var p=player(h,"Aid268");var server=p.level().getServer();var registry=registry();
|
||||
ProgressEffects.apply(p,ProgressRecord.INITIAL);p.setHealth(1);p.getFoodData().setFoodLevel(0);
|
||||
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.HEAL))&&p.getHealth()==6,"Healing respects the acquired six-point health cap");
|
||||
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.FEED))&&p.getFoodData().getFoodLevel()==6,"Food respects Sanctuary hunger capacity");
|
||||
var tool=new ItemStack(Items.DIAMOND_PICKAXE);tool.setDamageValue(77);p.setItemInHand(InteractionHand.MAIN_HAND,tool);
|
||||
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.REPAIR_ITEM))&&p.getMainHandItem().getDamageValue()==0,"Held item is repaired without replacement");
|
||||
h.assertTrue(!MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.REPAIR_ITEM)),"An undamaged held item is ineligible");
|
||||
var before=server.getRespawnData();var spawn=p.blockPosition().offset(7,0,0);platform(p.level(),spawn);
|
||||
try {
|
||||
server.setRespawnData(new LevelData.RespawnData(GlobalPos.of(p.level().dimension(),spawn),0,0));p.fallDistance=50;
|
||||
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.RESCUE_PLAYER))&&p.blockPosition().equals(spawn)&&p.fallDistance==0,"Rescue uses a safe server spawn, not a personal bed");
|
||||
server.setRespawnData(new LevelData.RespawnData(GlobalPos.of(p.level().dimension(),new BlockPos(30000000,-500,30000000)),0,0));
|
||||
h.assertTrue(!MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.RESCUE_PLAYER)),"Unsafe or unloaded spawn is never generated by rescue");
|
||||
}finally{server.setRespawnData(before);}
|
||||
h.succeed();
|
||||
}
|
||||
@GameTest public void weatherAndAllConnectedTargetsIgnorePvpGate(GameTestHelper h)throws Exception {
|
||||
var p=player(h,"Weather268");var target=player(h,"Victim268");var neighbour=player(h,"Nearby268");var registry=registry();var server=p.level().getServer();
|
||||
var weather=p.level().getWeatherData();var previous=WeatherService.session(server).state().weather();boolean pvp=server.getGlobalGameRules().get(GameRules.PVP);
|
||||
try {
|
||||
server.getGlobalGameRules().set(GameRules.PVP,false,server);
|
||||
WeatherService.providenceClear(p.level(),600);
|
||||
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.RAIN))&&weather.isRaining(),"Rain prayer updates the native weather");
|
||||
h.assertTrue(WeatherService.session(server).state().weather()==DailyWeather.Kind.RAIN,"Rain survives the daily weather controller");
|
||||
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.CLEAR_WEATHER))&&!weather.isRaining(),"Sun prayer still clears it");
|
||||
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.LIGHTNING),target)&&target.getHealth()<20,"Lightning strikes a connected target even with PvP disabled");
|
||||
h.assertTrue(neighbour.getHealth()==20,"Only the named target is damaged");
|
||||
h.assertTrue(target.level().getEntitiesOfClass(LightningBolt.class,target.getBoundingBox().inflate(3)).isEmpty(),"Lightning stays a client visual; no server bolt can ignite fire, power rods or change copper");
|
||||
target.removeAllEffects();
|
||||
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.CURSE),target)&&!target.getActiveEffects().isEmpty(),"Curse applies a native negative effect");
|
||||
target.removeAllEffects();target.setHealth(20);target.setInvulnerableTime(0);target.damageCooldownTime=0;target.getFoodData().setFoodLevel(20);
|
||||
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.VENGEANCE),target),"Vengeance selects an available punishment");
|
||||
h.assertTrue(target.getHealth()<20||target.getFoodData().getFoodLevel()<20||!target.getActiveEffects().isEmpty(),"Punishment has an observable effect");
|
||||
var destination=target.blockPosition().offset(5,0,0);platform(target.level(),destination);target.setPos(destination.getX()+.5,destination.getY(),destination.getZ()+.5);
|
||||
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.TELEPORT_PLAYER),target)&&p.distanceToSqr(target)<100,"Teleportation joins the named player at a safe loaded location");
|
||||
}finally {
|
||||
server.getGlobalGameRules().set(GameRules.PVP,pvp,server);
|
||||
server.getCommands().getDispatcher().execute("sanctuary weather "+previous.key(),server.createCommandSourceStack());
|
||||
}
|
||||
h.succeed();
|
||||
}
|
||||
@GameTest public void theftAndDisarmConserveRealStacks(GameTestHelper h)throws Exception {
|
||||
var receiver=player(h,"Thief268");var target=player(h,"ItemOwner268");var registry=registry();
|
||||
ProgressEffects.apply(receiver,ProgressRecord.INITIAL);ProgressEffects.apply(target,ProgressRecord.INITIAL);
|
||||
h.assertTrue(ProvidenceInventory.slots(receiver).size()==9,"A starting player exposes only the acquired storage row");
|
||||
target.getInventory().setItem(0,new ItemStack(Items.DIAMOND,3));target.getInventory().setItem(9,new ItemStack(Items.EMERALD,64));
|
||||
for(int i=0;i<3;i++)h.assertTrue(MiracleExecutor.execute(receiver,registry.definition(MiracleRegistry.Id.STEAL),target),"One actual item is stolen per grant");
|
||||
h.assertTrue(count(receiver,Items.DIAMOND)==3&&count(target,Items.DIAMOND)==0,"Repeated theft conserves total item count");
|
||||
h.assertTrue(!MiracleExecutor.execute(receiver,registry.definition(MiracleRegistry.Id.STEAL),target),"Inaccessible locked storage cannot be stolen");
|
||||
h.assertTrue(!MiracleExecutor.execute(receiver,registry.definition(MiracleRegistry.Id.STEAL),receiver),"Self theft never duplicates items");
|
||||
for(int slot:ProvidenceInventory.slots(receiver))receiver.getInventory().setItem(slot,new ItemStack(Items.STONE,64));
|
||||
target.getInventory().setItem(0,new ItemStack(Items.DIAMOND,2));
|
||||
h.assertTrue(!MiracleExecutor.execute(receiver,registry.definition(MiracleRegistry.Id.STEAL),target)&&count(target,Items.DIAMOND)==2,"Full receiver leaves the source intact");
|
||||
var sword=new ItemStack(Items.DIAMOND_SWORD);sword.setDamageValue(19);target.setItemInHand(InteractionHand.MAIN_HAND,sword);
|
||||
h.assertTrue(MiracleExecutor.execute(receiver,registry.definition(MiracleRegistry.Id.DISARM),target)&&target.getMainHandItem().isEmpty(),"Disarm removes the actual held stack");
|
||||
var drops=target.level().getEntitiesOfClass(ItemEntity.class,target.getBoundingBox().inflate(3),e->ItemStack.matches(e.getItem(),sword));
|
||||
h.assertTrue(drops.size()==1&&drops.getFirst().getItem().getDamageValue()==19,"Exactly one intact damaged sword is dropped");
|
||||
h.assertTrue(!MiracleExecutor.execute(receiver,registry.definition(MiracleRegistry.Id.DISARM),target),"Repeated disarm cannot copy the previous weapon");h.succeed();
|
||||
}
|
||||
@GameTest public void recoverySealsPlacedCarriedAndFloatingGraves(GameTestHelper h)throws Exception {
|
||||
var registry=registry();var p=player(h,"Recovery268");ProgressEffects.apply(p,ProgressRecord.INITIAL);var head=deathHead(p,4);var stale=head.copy();var at=p.blockPosition().offset(5,0,0);platform(p.level(),at);
|
||||
var skull=place(p.level(),at,head);var live=GraveService.container(skull);
|
||||
h.assertTrue(MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.RECOVER_ITEMS)),"Remaining items move from the actual placed grave");
|
||||
h.assertTrue(count(p,Items.DIAMOND)==4&&live.isEmpty(),"The shared live grave container is emptied");
|
||||
h.assertTrue(!MiracleExecutor.execute(p,registry.definition(MiracleRegistry.Id.RECOVER_ITEMS))&&count(p,Items.DIAMOND)==4,"Repeated recovery never recreates the lost inventory");
|
||||
p.level().setBlockAndUpdate(at,Blocks.AIR.defaultBlockState());skull=place(p.level(),at,stale);GraveRecovery.stopped(p.level().getServer());
|
||||
h.assertTrue(GraveService.head(skull).getOrDefault(DataComponents.CONTAINER,ItemContainerContents.EMPTY).nonEmptyItemCopyStream().findAny().isEmpty(),"A restored stale chunk cannot drop the consumed contents");
|
||||
h.assertTrue(GraveService.container(skull).isEmpty(),"A restored stale chunk cannot reopen consumed contents");
|
||||
p.getInventory().setItem(8,stale.copy());GraveService.open(p,p.getInventory().getItem(8));p.closeContainer();
|
||||
h.assertTrue(p.getInventory().getItem(8).getOrDefault(DataComponents.CONTAINER,ItemContainerContents.EMPTY).nonEmptyItemCopyStream().findAny().isEmpty(),"A stale carried copy is also sealed after ledger reload");
|
||||
var carried=player(h,"Carried268");ProgressEffects.apply(carried,ProgressRecord.INITIAL);carried.getInventory().setItem(0,deathHead(carried,3));
|
||||
h.assertTrue(MiracleExecutor.execute(carried,registry.definition(MiracleRegistry.Id.RECOVER_ITEMS))&&count(carried,Items.DIAMOND)==3,"Carried grave contents can be recovered");
|
||||
h.assertTrue(carried.getInventory().getItem(0).getOrDefault(DataComponents.CONTAINER,ItemContainerContents.EMPTY).nonEmptyItemCopyStream().findAny().isEmpty(),"Destination commit never restores the source head snapshot");
|
||||
var floating=player(h,"Floating268");ProgressEffects.apply(floating,ProgressRecord.INITIAL);var entity=new ItemEntity(floating.level(),floating.getX(),floating.getY()+2,floating.getZ(),deathHead(floating,2));entity.setNoGravity(true);floating.level().addFreshEntity(entity);
|
||||
h.assertTrue(MiracleExecutor.execute(floating,registry.definition(MiracleRegistry.Id.RECOVER_ITEMS))&&count(floating,Items.DIAMOND)==2,"Floating grave contents transfer without an inventory snapshot");
|
||||
h.assertTrue(entity.getItem().getOrDefault(DataComponents.CONTAINER,ItemContainerContents.EMPTY).nonEmptyItemCopyStream().findAny().isEmpty(),"Floating source remains empty");
|
||||
var full=player(h,"Full268");ProgressEffects.apply(full,ProgressRecord.INITIAL);var fullHead=deathHead(full,1);var fullAt=full.blockPosition().offset(-5,0,0);platform(full.level(),fullAt);var fullSkull=place(full.level(),fullAt,fullHead);
|
||||
for(int slot:ProvidenceInventory.slots(full))full.getInventory().setItem(slot,new ItemStack(Items.STONE,64));
|
||||
h.assertTrue(!MiracleExecutor.execute(full,registry.definition(MiracleRegistry.Id.RECOVER_ITEMS))&&!GraveService.container(fullSkull).isEmpty(),"Insufficient space refuses without consuming or sealing a grave");
|
||||
full.getInventory().setItem(0,ItemStack.EMPTY);
|
||||
h.assertTrue(MiracleExecutor.execute(full,registry.definition(MiracleRegistry.Id.RECOVER_ITEMS))&&count(full,Items.DIAMOND)==1,"Freeing space allows the untouched source to be recovered");
|
||||
System.out.println("PROVIDENCE268_RECOVERY_PASS: live containers, repeated requests, stale chunk/item copies, durable seal reload, carried/floating graves and full inventory");h.succeed();
|
||||
}
|
||||
@GameTest(maxTicks=160) public void namedTargetsAreBoundBeforeTheDelayedDecision(GameTestHelper h)throws Exception {
|
||||
var server=h.getLevel().getServer();var actor=player(h,"Praying268");var target=player(h,"Target268");var next=player(h,"Target268");var second=player(h,"SecondPrayer268");
|
||||
server.getPlayerList().getPlayers().add(target);server.getPlayerList().getPlayersByUUID().put(target.getUUID(),target);
|
||||
configured(server);
|
||||
var commands=server.getCommands().getDispatcher();
|
||||
h.assertTrue(commands.execute("pray foudroie @Target268",actor.createCommandSourceStack())==1&&target.getHealth()==20,"Named hostile prayer is accepted without an immediate effect");
|
||||
h.startSequence().thenIdle(10).thenExecute(()->h.assertTrue(target.getHealth()==20,"Named player still waits for the response deadline"))
|
||||
.thenIdle(12).thenExecute(()->{
|
||||
h.assertTrue(target.getHealth()<20,"Delayed prayer strikes the connected exact-name target");
|
||||
target.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.DIAMOND_SWORD));next.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.IRON_SWORD));
|
||||
try{configured(server);h.assertTrue(commands.execute("pray desarme @Target268",second.createCommandSourceStack())==1,"Second targeted request waits");}catch(Exception e){throw new RuntimeException(e);}
|
||||
server.getPlayerList().getPlayers().remove(target);server.getPlayerList().getPlayersByUUID().remove(target.getUUID());
|
||||
server.getPlayerList().getPlayers().add(next);server.getPlayerList().getPlayersByUUID().put(next.getUUID(),next);
|
||||
}).thenIdle(22).thenExecute(()->{
|
||||
try{
|
||||
h.assertTrue(!target.getMainHandItem().isEmpty()&&!next.getMainHandItem().isEmpty(),"A new player with the same name is never substituted for the bound UUID");
|
||||
var path=server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-providence.json");
|
||||
var entry=new PrayerHistory(path).state().entries().stream().filter(e->e.player().equals(second.getUUID())).findFirst().orElseThrow();
|
||||
h.assertTrue(entry.decision().equals("INELIGIBLE")&&!entry.conditions().get("target_online"),"Absent target is recorded as refused");
|
||||
server.getPlayerList().getPlayers().remove(next);server.getPlayerList().getPlayersByUUID().remove(next.getUUID());configured(server);
|
||||
System.out.println("PROVIDENCE268_TARGETS_PASS: exact connected target, delayed execution and no name-to-UUID substitution");
|
||||
}catch(Exception e){throw new RuntimeException(e);}
|
||||
}).thenSucceed();
|
||||
}
|
||||
private static void configured(net.minecraft.server.MinecraftServer server)throws Exception {try(var config=new Providence265TestConfig(1)){ProvidenceService.reload(server);}}
|
||||
private static MiracleRegistry registry()throws Exception {try(var config=new Providence265TestConfig(1)){return MiracleRegistry.load(ProvidenceService.CONFIG);}}
|
||||
private static ItemStack deathHead(ServerPlayer p,int diamonds) {
|
||||
var head=GraveService.head(p,List.of(new ItemStack(Items.DIAMOND,diamonds)));
|
||||
p.getStats().setValue(p,Stats.CUSTOM.get(Stats.DEATHS),1);p.setLastDeathLocation(Optional.of(GlobalPos.of(p.level().dimension(),p.blockPosition())));return head;
|
||||
}
|
||||
private static SkullBlockEntity place(ServerLevel level,BlockPos at,ItemStack head) {
|
||||
level.setBlockAndUpdate(at,Blocks.PLAYER_HEAD.defaultBlockState());var skull=(SkullBlockEntity)level.getBlockEntity(at);skull.applyComponentsFromItemStack(head);skull.setChanged();return skull;
|
||||
}
|
||||
private static void platform(ServerLevel level,BlockPos center) {for(int dx=-2;dx<=2;dx++)for(int dz=-2;dz<=2;dz++){level.setBlockAndUpdate(center.offset(dx,-1,dz),Blocks.STONE.defaultBlockState());for(int dy=0;dy<3;dy++)level.setBlockAndUpdate(center.offset(dx,dy,dz),Blocks.AIR.defaultBlockState());}}
|
||||
private static int count(ServerPlayer p,Item item) {return ProvidenceInventory.slots(p).stream().mapToInt(i->{var s=p.getInventory().getItem(i);return s.is(item)?s.getCount():0;}).sum();}
|
||||
private static ServerPlayer player(GameTestHelper h,String name) {
|
||||
var profile=new GameProfile(UUID.randomUUID(),name);var p=new ServerPlayer(h.getLevel().getServer(),h.getLevel(),profile,ClientInformation.createDefault());
|
||||
var connection=new Connection(PacketFlow.SERVERBOUND);new io.netty.channel.embedded.EmbeddedChannel(connection);
|
||||
p.connection=new ServerGamePacketListenerImpl(h.getLevel().getServer(),connection,p,CommonListenerCookie.createInitial(profile,false));p.setGameMode(net.minecraft.world.level.GameType.SURVIVAL);p.setInvulnerableTime(0);p.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());p.setPos(h.absoluteVec(new net.minecraft.world.phys.Vec3(2,2,2)));return p;
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import fr.koka.sanctuary.providence.*;
|
||||
import fr.koka.sanctuary.progression.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.network.*;
|
||||
import net.minecraft.network.chat.*;
|
||||
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||
import net.minecraft.network.protocol.PacketFlow;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.*;
|
||||
import net.minecraft.server.network.*;
|
||||
import net.minecraft.world.level.GameType;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
|
||||
public final class Providence269GameTests {
|
||||
private record Actor(ServerPlayer player,List<Component> messages) { }
|
||||
@GameTest public void everyVisibleResponseHasTheSameColourInSgaAndReadable(GameTestHelper h) {
|
||||
var colours=Map.of("granted",ChatFormatting.GREEN,"deferred",ChatFormatting.YELLOW,"refused",ChatFormatting.RED,"incomprehensible",ChatFormatting.GRAY,"alternative",ChatFormatting.LIGHT_PURPLE);
|
||||
for(boolean readable:List.of(false,true))for(var e:colours.entrySet()) {
|
||||
var message=ProvidenceMessages.message(e.getKey(),readable,0);
|
||||
h.assertTrue(ProvidenceMessages.visible(e.getKey())&&message.getStyle().getColor()!=null&&message.getStyle().getColor().getValue()==TextColor.fromLegacyFormat(e.getValue()).getValue(),"Verdict colour in both reading modes: "+e.getKey());
|
||||
if(!readable)h.assertTrue(message.getStyle().getFont().toString().contains("providence"),"SGA font is retained alongside colour");
|
||||
}
|
||||
h.assertTrue(!ProvidenceMessages.visible("SILENT"),"Silence has no visible message");
|
||||
h.assertTrue(ProvidenceMessages.message("cooldown",true,0).getStyle().getColor().getValue()==TextColor.RED.getValue(),"Cooldown refusal never promises a deferred miracle");h.succeed();
|
||||
}
|
||||
@GameTest(maxTicks=400) public void sixResponsesHaveRealEffectsAndDeferredWorkNeverRetargets(GameTestHelper h)throws Exception {
|
||||
var server=h.getLevel().getServer();var hero=actor(h,"Deferred269");var caster=actor(h,"Caster269");var victim=actor(h,"Victim269");var replacement=actor(h,"Victim269");
|
||||
var alternative=actor(h,"Alternative269");var refused=actor(h,"Refused269");var unknown=actor(h,"Unknown269");var silent=actor(h,"Silent269");var cancelled=actor(h,"Cancelled269");var changed=actor(h,"Changed269");
|
||||
// Earlier Providence tests finish before this case changes the shared response policy.
|
||||
h.startSequence().thenIdle(120).thenExecute(checked(()->{
|
||||
configure(server,1,1,0,0);add(server,victim.player());
|
||||
pray(h,hero,"gueris moi");pray(h,caster,"foudroie @Victim269");
|
||||
h.assertTrue(hero.player().getHealth()==1&&keys(hero).isEmpty(),"There is no immediate reply or effect");
|
||||
})).thenIdle(22).thenExecute(checked(()->{
|
||||
h.assertTrue(hero.player().getHealth()==1&&has(hero,"deferred")&&keys(hero).size()==1,"Differee is announced while healing is still pending");
|
||||
h.assertTrue(victim.player().getHealth()==1&&has(caster,"deferred"),"Targeted deferred prayer has no early damage");
|
||||
h.assertTrue(ProvidenceService.pray(hero.player(),"nourris moi")==0,"A later miracle occupies the same per-player queue slot");
|
||||
remove(server,victim.player());add(server,replacement.player());
|
||||
var path=ProvidenceService.CONFIG.resolve("responses.json");var valid=Files.readAllBytes(path);
|
||||
try{Files.writeString(path,"{invalid-response-policy");boolean rejected=false;try{ProvidenceService.reload(server);}catch(Exception expected){rejected=true;}h.assertTrue(rejected,"Invalid response policy refuses reload without replacing the session");}finally{Files.write(path,valid);}
|
||||
configure(server,1,0,0,0);
|
||||
})).thenIdle(10).thenExecute(()->h.assertTrue(hero.player().getHealth()==1&&keys(hero).size()==1,"Reload never shortens the deferred effect deadline"))
|
||||
.thenIdle(35).thenExecute(checked(()->{
|
||||
h.assertTrue(hero.player().getHealth()==6&&keys(hero).size()==2&&has(hero,"granted"),"Exaucee follows the actual later effect, once");
|
||||
h.assertTrue(victim.player().getHealth()==1&&replacement.player().getHealth()==1&&has(caster,"ineligible"),"Departed target is refused; same-name replacement never receives the strike");
|
||||
h.assertTrue(entry(server,hero).executed()&&entries(server,hero).size()==1,"Deferred completion updates its original persistent decision");
|
||||
remove(server,replacement.player());
|
||||
configure(server,0,0,1,0);refused.player().getFoodData().setFoodLevel(6);
|
||||
pray(h,alternative,"gueris moi");pray(h,refused,"gueris moi");pray(h,unknown,"bonjour");
|
||||
})).thenIdle(22).thenExecute(checked(()->{
|
||||
h.assertTrue(alternative.player().getHealth()==1&&alternative.player().getFoodData().getFoodLevel()==6&&has(alternative,"alternative"),"Alternative feeds the player instead of healing");
|
||||
h.assertTrue(entry(server,alternative).decision().equals("ALTERNATIVE")&&entry(server,alternative).conditions().get("alternative_FEED"),"Actual different help is journalled");
|
||||
h.assertTrue(refused.player().getHealth()==1&&has(refused,"refused")&&!entry(server,refused).executed(),"Refusee has no effect when no alternative is possible");
|
||||
h.assertTrue(has(unknown,"incomprehensible")&&!entry(server,unknown).executed(),"Incomprise stays a distinct visible response");
|
||||
configure(server,0,0,0,1);pray(h,silent,"gueris moi");
|
||||
})).thenIdle(22).thenExecute(checked(()->{
|
||||
h.assertTrue(keys(silent).isEmpty()&&silent.player().getHealth()==1&&entry(server,silent).decision().equals("SILENT"),"Silence is journalled but sends no visible reply or effect");
|
||||
configure(server,1,1,0,0);pray(h,cancelled,"gueris moi");pray(h,changed,"gueris moi");
|
||||
})).thenIdle(22).thenExecute(()->{
|
||||
h.assertTrue(has(cancelled,"deferred")&&has(changed,"deferred"),"Both later grants are initially deferred");
|
||||
cancelled.player().disconnect();changed.player().setHealth(6);
|
||||
}).thenIdle(45).thenExecute(checked(()->{
|
||||
h.assertTrue(cancelled.player().getHealth()==1&&entry(server,cancelled).decision().equals("CANCELLED")&&keys(cancelled).size()==1,"A disconnected actor never receives its delayed grant");
|
||||
h.assertTrue(changed.player().getHealth()==6&&entry(server,changed).decision().equals("INELIGIBLE")&&has(changed,"ineligible"),"Changed eligibility is refused at the actual effect time");
|
||||
h.assertTrue(keys(hero).size()==2,"No timer or reload replays an earlier miracle");ProvidenceService.reload(server);
|
||||
System.out.println("PROVIDENCE269_SERVER_PASS: six real outcomes, true invisible silence, alternative food, deferred execution once, reload, original entry, no target substitution, cancellation and changed eligibility");
|
||||
})).thenSucceed();
|
||||
}
|
||||
public static void configure(MinecraftServer server,double healChance,double deferred,double alternative,double silence)throws Exception {
|
||||
configure(server,healChance,deferred,alternative,silence,1,2);
|
||||
}
|
||||
public static void configure(MinecraftServer server,double healChance,double deferred,double alternative,double silence,int responseSeconds,int deferredSeconds)throws Exception {
|
||||
try(var fixture=new Providence265TestConfig(responseSeconds)) {
|
||||
var path=ProvidenceService.CONFIG.resolve("miracles.json");var miracles=JsonParser.parseString(Files.readString(path)).getAsJsonObject();
|
||||
for(var e:miracles.getAsJsonArray("miracles"))if(e.getAsJsonObject().get("id").getAsString().equals("HEAL"))e.getAsJsonObject().addProperty("probability",healChance);
|
||||
Files.writeString(path,miracles.toString());path=ProvidenceService.CONFIG.resolve("responses.json");var responses=JsonParser.parseString(Files.readString(path)).getAsJsonObject();
|
||||
responses.addProperty("deferredChance",deferred);responses.addProperty("alternativeChance",alternative);responses.addProperty("silenceChance",silence);responses.addProperty("deferredSeconds",deferredSeconds);
|
||||
Files.writeString(path,responses.toString());ProvidenceService.reload(server);
|
||||
}
|
||||
}
|
||||
private static void pray(GameTestHelper h,Actor actor,String text)throws Exception {h.assertTrue(actor.player().level().getServer().getCommands().getDispatcher().execute("pray "+text,actor.player().createCommandSourceStack())==1,"Actual command admits a waiting prayer");}
|
||||
private static List<String> keys(Actor actor){return actor.messages().stream().filter(c->c.getContents() instanceof TranslatableContents t&&t.getKey().startsWith("sanctuary.providence.")).map(c->((TranslatableContents)c.getContents()).getKey()).toList();}
|
||||
private static boolean has(Actor actor,String verdict){return keys(actor).stream().anyMatch(k->k.contains("."+verdict+"."));}
|
||||
private static List<PrayerHistory.Entry> entries(MinecraftServer server,Actor actor)throws Exception {return new PrayerHistory(server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-providence.json")).state().entries().stream().filter(e->e.player().equals(actor.player().getUUID())).toList();}
|
||||
private static PrayerHistory.Entry entry(MinecraftServer server,Actor actor)throws Exception{return entries(server,actor).getLast();}
|
||||
private static void add(MinecraftServer server,ServerPlayer p){server.getPlayerList().getPlayers().add(p);server.getPlayerList().getPlayersByUUID().put(p.getUUID(),p);}
|
||||
private static void remove(MinecraftServer server,ServerPlayer p){server.getPlayerList().getPlayers().remove(p);server.getPlayerList().getPlayersByUUID().remove(p.getUUID());}
|
||||
@FunctionalInterface private interface Checked{void run()throws Exception;}
|
||||
private static Runnable checked(Checked action){return ()->{try{action.run();}catch(RuntimeException e){throw e;}catch(Exception e){throw new RuntimeException(e);}};}
|
||||
private static Actor actor(GameTestHelper h,String name) {
|
||||
var messages=new ArrayList<Component>();var profile=new GameProfile(UUID.randomUUID(),name);
|
||||
var p=new ServerPlayer(h.getLevel().getServer(),h.getLevel(),profile,ClientInformation.createDefault()) {
|
||||
@Override public void sendSystemMessage(Component message){messages.add(message);super.sendSystemMessage(message);}
|
||||
};
|
||||
var connection=new Connection(PacketFlow.SERVERBOUND);new io.netty.channel.embedded.EmbeddedChannel(connection);
|
||||
p.connection=new ServerGamePacketListenerImpl(h.getLevel().getServer(),connection,p,CommonListenerCookie.createInitial(profile,false));p.setGameMode(GameType.SURVIVAL);
|
||||
p.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());p.setPos(h.absoluteVec(new net.minecraft.world.phys.Vec3(2,2,2)));
|
||||
ProgressEffects.apply(p,ProgressRecord.INITIAL);p.setHealth(1);p.getFoodData().setFoodLevel(0);return new Actor(p,messages);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"entrypoints": {
|
||||
"client": ["fr.koka.sanctuary.gametest.Starter039TestSupport"],
|
||||
"preLaunch": ["fr.koka.sanctuary.gametest.Inventory010MixinAudit"],
|
||||
"fabric-gametest": ["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.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"],
|
||||
|
||||
@@ -87,6 +87,7 @@ public final class SanctuaryMod implements ModInitializer {
|
||||
fr.koka.sanctuary.food.FoodSync.register();
|
||||
fr.koka.sanctuary.food.FoodKnowledgeService.register();
|
||||
fr.koka.sanctuary.grave.GraveService.register();
|
||||
fr.koka.sanctuary.providence.ProvidenceService.register();
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("expansion_lab"), fr.koka.sanctuary.expansion.ExpansionChunkGenerator.CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("expansion_lab"), fr.koka.sanctuary.expansion.ExpansionBiomeSource.CODEC);
|
||||
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("expansion_island"), fr.koka.sanctuary.expansion.ExpansionDensity.CODEC);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.renderer.entity.AgeableMobRenderer;
|
||||
import net.minecraft.client.renderer.entity.state.*;
|
||||
import net.minecraft.core.ClientAsset;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.util.LightCoordsUtil;
|
||||
import net.minecraft.world.entity.animal.chicken.ChickenVariant;
|
||||
import net.minecraft.world.entity.animal.cow.CowVariant;
|
||||
import net.minecraft.world.entity.animal.pig.PigVariant;
|
||||
import net.minecraft.world.entity.variant.ModelAndTexture;
|
||||
import net.minecraft.world.entity.variant.SpawnPrioritySelectors;
|
||||
import org.joml.Quaternionf;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/** Native render states only: safe before the world, dynamic registries or item components arrive. */
|
||||
final class HelloFamiliarPreview {
|
||||
private static final CowVariant COW=new CowVariant(new ModelAndTexture<>(CowVariant.ModelType.NORMAL,asset("cow","")),asset("cow","_baby"),SpawnPrioritySelectors.EMPTY);
|
||||
private static final ChickenVariant CHICKEN=new ChickenVariant(new ModelAndTexture<>(ChickenVariant.ModelType.NORMAL,asset("chicken","")),asset("chicken","_baby"),SpawnPrioritySelectors.EMPTY);
|
||||
private static final PigVariant PIG=new PigVariant(new ModelAndTexture<>(PigVariant.ModelType.NORMAL,asset("pig","")),asset("pig","_baby"),SpawnPrioritySelectors.EMPTY);
|
||||
private HelloFamiliarPreview() { }
|
||||
|
||||
private static ClientAsset.ResourceTexture asset(String mob,String suffix){
|
||||
return new ClientAsset.ResourceTexture(Identifier.withDefaultNamespace("entity/"+mob+"/"+mob+"_temperate"+suffix));
|
||||
}
|
||||
static EntityRenderState state(String mob){
|
||||
var type=BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.withDefaultNamespace(mob));
|
||||
var lookup=new EntityRenderState();lookup.entityType=type;
|
||||
var renderer=Minecraft.getInstance().getEntityRenderDispatcher().getRenderer(lookup);
|
||||
var state=renderer.createRenderState();state.entityType=type;
|
||||
var dimensions=type.getDimensions();
|
||||
state.boundingBoxWidth=dimensions.width();state.boundingBoxHeight=dimensions.height();
|
||||
state.eyeHeight=dimensions.eyeHeight();state.lightCoords=LightCoordsUtil.FULL_BRIGHT;
|
||||
// These variant records normally come from the server's dynamic registries.
|
||||
// Use the exact 26.3 temperate assets for the welcome preview, never bind a local registry.
|
||||
if(state instanceof CowRenderState cow)cow.variant=COW;
|
||||
if(state instanceof ChickenRenderState chicken)chicken.variant=CHICKEN;
|
||||
if(state instanceof PigRenderState pig)pig.variant=PIG;
|
||||
if(state instanceof TropicalFishRenderState fish)fish.pattern=net.minecraft.world.entity.animal.fish.TropicalFish.Pattern.KOB;
|
||||
// Match FamiliarRenderer's Mob.setBaby(true), including species with no native baby form.
|
||||
boolean baby=(renderer instanceof AgeableMobRenderer<?,?,?> && !(state instanceof SkeletonRenderState)
|
||||
&& !mob.equals("piglin_brute")) || state instanceof CowRenderState || state instanceof ChickenRenderState || state instanceof PigRenderState;
|
||||
if(baby && state instanceof LivingEntityRenderState living){
|
||||
living.isBaby=true;
|
||||
living.ageScale=switch(state){
|
||||
case TurtleRenderState ignored -> .3f;
|
||||
case ArmadilloRenderState ignored -> .6f;
|
||||
case CamelRenderState ignored -> .6f;
|
||||
case DolphinRenderState ignored -> .65f;
|
||||
case GoatRenderState ignored -> .55f;
|
||||
default -> .5f;
|
||||
};
|
||||
state.boundingBoxWidth*=living.ageScale;state.boundingBoxHeight*=living.ageScale;state.eyeHeight*=living.ageScale;
|
||||
if(state instanceof CatRenderState cat)cat.texture=Identifier.withDefaultNamespace("textures/entity/cat/cat_black_baby.png");
|
||||
if(state instanceof WolfRenderState wolf)wolf.texture=Identifier.withDefaultNamespace("textures/entity/wolf/wolf_baby.png");
|
||||
}
|
||||
return state;
|
||||
}
|
||||
static EntityRenderState extract(GuiGraphicsExtractor graphics,String mob,int x,int y,int width,int height,int mouseX,int mouseY){
|
||||
var state=state(mob);
|
||||
float yaw=(float)Math.atan((x+width/2f-mouseX)/160f);
|
||||
float pitch=(float)Math.atan((y+height/3f-mouseY)/180f);
|
||||
var tilt=new Quaternionf().rotateX(pitch*(float)Math.toRadians(20));
|
||||
var rotation=new Quaternionf().rotateZ((float)Math.PI).mul(tilt);
|
||||
if(state instanceof LivingEntityRenderState living){
|
||||
living.bodyRot=180+yaw*20;living.yRot=yaw*20;living.xRot=-pitch*20;
|
||||
}else rotation.rotateY(yaw*.35f);
|
||||
// Keep a miniature beside the player; never enlarge a baby to fill the portrait.
|
||||
float scale=Math.min((width-12)/Math.max(.6f,state.boundingBoxWidth*2.2f),height*.4f/Math.max(.6f,state.boundingBoxHeight*1.3f));
|
||||
graphics.enableScissor(x,y,x+width,y+height);
|
||||
graphics.entity(state,scale,new Vector3f(0,(height/2f-12)/scale,0),rotation,tilt,x,y,x+width,y+height);
|
||||
graphics.disableScissor();
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.components.PlayerSkinWidget;
|
||||
import net.minecraft.client.gui.components.Tooltip;
|
||||
import net.minecraft.client.gui.layouts.HeaderAndFooterLayout;
|
||||
import net.minecraft.network.chat.CommonComponents;
|
||||
import net.minecraft.client.input.MouseButtonEvent;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.resources.Identifier;
|
||||
@@ -31,8 +34,15 @@ public final class HelloWorldScreen extends Screen {
|
||||
}
|
||||
private boolean accessRequired,accessPending,admitting;private EditBox codeBox;
|
||||
private List<StarterCompanions.Choice> starterChoices=List.of();
|
||||
private final List<Button> colors=new ArrayList<>(),starters=new ArrayList<>();
|
||||
private int panelX,panelY,panelWidth,bioLabelY,colorLabelY,starterLabelY,nameLabelY;private boolean compact;
|
||||
private final List<ColorButton> colors=new ArrayList<>();
|
||||
private final List<StarterSlot> starters=new ArrayList<>();
|
||||
private Button previousColors,nextColors;
|
||||
private int panelX,panelY,panelWidth,sideWidth,formX,formWidth,familiarY,backY,previewY,previewHeight;
|
||||
private static final int FOOTER_HEIGHT=33, FLOOR_INSET=12;
|
||||
private static final int PAGE_SIZE=9;
|
||||
private int colorStart,paletteX,paletteY;
|
||||
private float paletteScale;
|
||||
private boolean compact;
|
||||
public HelloWorldScreen(ProgressionPayloads.Hello offer,Runnable disconnect){super(ProgressionService.label("hello_title"));this.offer=offer;this.disconnect=disconnect;}
|
||||
public HelloWorldScreen(StarterPayloads.Welcome welcome,Runnable disconnect){this(welcome.identity(),disconnect);load(welcome);}
|
||||
public List<StarterCompanions.Choice> starterChoices(){return starterChoices;}
|
||||
@@ -44,38 +54,60 @@ public final class HelloWorldScreen extends Screen {
|
||||
public void prepareAccess(boolean pending){accessRequired=true;accessPending=pending;if(pending)codeStatus="code_recognized";}
|
||||
public void requireAccess(boolean pending){boolean changed=!accessRequired||accessPending!=pending;prepareAccess(pending);if(changed&&minecraft!=null&&minecraft.gui.screen()==this){keepDrafts();rebuildWidgets();}}
|
||||
public void acceptCode(String code,String reason){if(admitting)return;if(!AccessCodes.normalize(code).equals(AccessCodes.normalize(codeDraft))){if(AccessCodes.validFormat(AccessCodes.normalize(codeDraft)))sendCheck(AccessCodes.normalize(codeDraft));return;}codeStatus=reason;waiting=false;updateSelection();}
|
||||
public void accept(StarterPayloads.Welcome welcome){keepDrafts();load(welcome);waiting=false;admitting=false;noteCodeError();if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
|
||||
public void accept(ProgressionPayloads.Hello offer){keepDrafts();this.offer=offer;waiting=false;admitting=false;noteCodeError();if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
|
||||
public void accept(StarterPayloads.Welcome welcome){keepDrafts();load(welcome);acceptedOffer();}
|
||||
public void accept(ProgressionPayloads.Hello offer){keepDrafts();this.offer=offer;acceptedOffer();}
|
||||
private void acceptedOffer(){
|
||||
waiting=false;admitting=false;noteCodeError();
|
||||
if(!offer.colors().contains(color))color=-1;
|
||||
else colorStart=offer.colors().indexOf(color);
|
||||
rebuildWidgets();
|
||||
}
|
||||
private void keepDrafts(){if(bio!=null)draft=bio.getValue();if(codeBox!=null)codeDraft=codeBox.getValue();if(familiarName!=null)nameDraft=familiarName.getValue();}
|
||||
private void noteCodeError(){if(offer.error().startsWith("code_"))codeStatus=offer.error();}
|
||||
@Override protected void init(){
|
||||
boolean naming=!starterChoices.isEmpty();int shift=accessRequired?36:0;compact=height<278+shift;
|
||||
panelWidth=Math.min(520,width-20);panelX=(width-panelWidth)/2;panelY=Math.max(0,(height-((compact?202:naming?270:238)+shift))/2);colors.clear();starters.clear();familiarName=null;codeBox=null;
|
||||
bioLabelY=panelY+(compact?37:44)+shift;colorLabelY=panelY+(compact?71:81)+shift;starterLabelY=panelY+(compact?145:177)+shift;nameLabelY=starterLabelY+(compact?0:36);
|
||||
var skin=addRenderableWidget(new PlayerSkinWidget(64,145,minecraft.getEntityModels(),minecraft.getSkinManager().createLookup(minecraft.getGameProfile(),false)));skin.setPosition(panelX,panelY+35);
|
||||
int right=panelX+72,w=panelWidth-72;
|
||||
boolean naming=!starterChoices.isEmpty();compact=height<(accessRequired?360:310);
|
||||
formWidth=Math.min(182,width-128);formX=(width-formWidth)/2;
|
||||
sideWidth=Math.min(128,(width-formWidth-32)/2);
|
||||
panelWidth=formWidth+2*(sideWidth+8);panelX=(width-panelWidth)/2;
|
||||
backY=height-FOOTER_HEIGHT-26;
|
||||
int formHeight=compact?(accessRequired?162:142):(accessRequired?256:212);
|
||||
panelY=Math.max(4,Math.min((height-FOOTER_HEIGHT-formHeight)/2,backY-12-formHeight));
|
||||
familiarY=panelY+(compact?82:131);
|
||||
previewHeight=Math.min(170,Math.min(sideWidth*2,formHeight-20));
|
||||
previewY=panelY+(formHeight-previewHeight)/2;
|
||||
colors.clear();starters.clear();familiarName=null;codeBox=null;
|
||||
colorStart=Math.min(colorStart/PAGE_SIZE*PAGE_SIZE,lastColorPage());
|
||||
var skin=addRenderableWidget(new PlayerPortrait(sideWidth,previewHeight));
|
||||
skin.setPosition(panelX,previewY);
|
||||
previousColors=addRenderableWidget(new ColorPageButton(formX,panelY+(compact?9:18),false));
|
||||
nextColors=addRenderableWidget(new ColorPageButton(formX+formWidth-18,panelY+(compact?9:18),true));
|
||||
paletteScale=formWidth/182f;paletteX=formX;paletteY=panelY+(compact?28:39);
|
||||
for(int i=0;i<PAGE_SIZE;i++)colors.add(addRenderableWidget(new ColorButton(i)));
|
||||
int bioY=panelY+(compact?62:87);
|
||||
bio=addRenderableWidget(new EditBox(font,formX,bioY,formWidth,compact?16:18,ProgressionService.label("bio")));
|
||||
bio.setMaxLength(256);bio.setHint(ProgressionService.label("bio_hint"));bio.setValue(draft);bio.setResponder(value->draft=value);
|
||||
if(naming){
|
||||
familiarName=addRenderableWidget(new EditBox(font,formX,panelY+(compact?126:194),formWidth,compact?16:18,Component.translatable("sanctuary.starter.name")));
|
||||
familiarName.setMaxLength(StarterCompanions.NAME_LIMIT);familiarName.setHint(Component.translatable("sanctuary.starter.name_hint"));familiarName.setValue(nameDraft);
|
||||
familiarName.setResponder(value->{nameDraft=value;updateSelection();});
|
||||
}
|
||||
if(accessRequired){
|
||||
codeBox=addRenderableWidget(new EditBox(font,right,panelY+(compact?48:57),accessSite==null?w:w-116,18,ProgressionService.label("code")));
|
||||
if(accessSite!=null)addRenderableWidget(Button.builder(ProgressionService.label("get_code"),b->{keepDrafts();net.minecraft.client.gui.screens.ConfirmLinkScreen.confirmLinkNow(this,accessSite);}).bounds(right+w-112,panelY+(compact?48:57),112,18).build());
|
||||
int codeY=panelY+(compact?146:238),linkWidth=44;
|
||||
codeBox=addRenderableWidget(new EditBox(font,formX,codeY,accessSite==null?formWidth:formWidth-linkWidth-6,compact?16:18,ProgressionService.label("code")));
|
||||
if(accessSite!=null){
|
||||
var link=addRenderableWidget(Button.builder(ProgressionService.label("get_code_short"),b->{keepDrafts();net.minecraft.client.gui.screens.ConfirmLinkScreen.confirmLinkNow(this,accessSite);}).bounds(formX+formWidth-linkWidth,codeY,linkWidth,compact?16:18).build());
|
||||
link.setTooltip(Tooltip.create(ProgressionService.label("get_code")));
|
||||
}
|
||||
codeBox.setTooltip(Tooltip.create(ProgressionService.label("code")));
|
||||
codeBox.setMaxLength(32);codeBox.setHint(Component.translatable("sanctuary.progression.code_hint").withStyle(style -> style.withFont(GALACTIC)));codeBox.setValue(codeDraft);
|
||||
codeBox.addFormatter((text,offset) -> FormattedCharSequence.forward(text, GALACTIC_STYLE));
|
||||
codeBox.setResponder(value->{codeDraft=value;if(accessPending){updateSelection();return;}String normalized=AccessCodes.normalize(value);if(!AccessCodes.validFormat(normalized)){codeStatus=normalized.isEmpty()?"":"code_malformed";lastChecked="";updateSelection();return;}if(!normalized.equals(lastChecked))sendCheck(normalized);else updateSelection();});
|
||||
}
|
||||
bio=addRenderableWidget(new EditBox(font,right,panelY+(compact?48:57)+shift,w,18,ProgressionService.label("bio")));bio.setMaxLength(256);bio.setHint(ProgressionService.label("bio_hint"));bio.setValue(draft);bio.setResponder(value->draft=value);
|
||||
int cell=w/4;
|
||||
for(int i=0;i<offer.colors().size();i++){
|
||||
final int rgb=offer.colors().get(i);int rowHeight=compact?12:16;Button button=addRenderableWidget(new ColorButton(right+(i%4)*cell,panelY+(compact?82:94)+shift+(i/4)*rowHeight,cell-2,rowHeight,rgb,i+1,b->{color=rgb;updateSelection();}));
|
||||
button.setTooltip(Tooltip.create(InhabitantColorNames.label(rgb)));colors.add(button);
|
||||
}
|
||||
for(int i=0;i<starterChoices.size();i++)starters.add(addRenderableWidget(new StarterSlot(right+i*28,starterLabelY+11,starterChoices.get(i))));
|
||||
if(naming){
|
||||
familiarName=addRenderableWidget(new EditBox(font,right+(compact?88:0),nameLabelY+12,w-(compact?88:0),18,Component.translatable("sanctuary.starter.name")));
|
||||
familiarName.setMaxLength(StarterCompanions.NAME_LIMIT);familiarName.setHint(Component.translatable("sanctuary.starter.name_hint"));familiarName.setValue(nameDraft);
|
||||
familiarName.setResponder(value->{nameDraft=value;updateSelection();});
|
||||
}
|
||||
int actionsY=panelY+(compact?181:naming?248:216)+shift;
|
||||
addRenderableWidget(Button.builder(ProgressionService.label("cancel"),b->onClose()).bounds(panelX,actionsY,90,20).build());
|
||||
submit=addRenderableWidget(Button.builder(ProgressionService.label("enter"),b->{
|
||||
int slotsWidth=starterChoices.size()*22+Math.max(0,starterChoices.size()-1)*2;
|
||||
for(int i=0;i<starterChoices.size();i++)starters.add(addRenderableWidget(new StarterSlot(formX+(formWidth-slotsWidth)/2+i*24,familiarY+(compact?10:15),starterChoices.get(i))));
|
||||
addRenderableWidget(Button.builder(CommonComponents.GUI_BACK,b->onClose()).bounds(formX,backY,formWidth,20).build());
|
||||
var frame=new HeaderAndFooterLayout(this,0,FOOTER_HEIGHT);
|
||||
submit=frame.addToFooter(Button.builder(ProgressionService.label("enter"),b->{
|
||||
if(waiting||color<0||(!starterChoices.isEmpty()&&(starter.isEmpty()||!StarterCompanions.validName(nameDraft))))return;
|
||||
if(accessRequired){
|
||||
if(!(accessPending||"code_recognized".equals(codeStatus))){if(retryCode())sendCheck(AccessCodes.normalize(codeDraft));return;}
|
||||
@@ -90,7 +122,14 @@ public final class HelloWorldScreen extends Screen {
|
||||
if(starterChoices.isEmpty())ClientConfigurationNetworking.send(new ProgressionPayloads.Create(bio.getValue(),color));
|
||||
else if(ClientConfigurationNetworking.canSend(StarterPayloads.NamedCreate.TYPE))ClientConfigurationNetworking.send(new StarterPayloads.NamedCreate(bio.getValue(),color,starter,nameDraft));
|
||||
else ClientConfigurationNetworking.send(new StarterPayloads.Create(bio.getValue(),color,starter));
|
||||
}).bounds(panelX+96,actionsY,panelWidth-96,20).build());updateSelection();
|
||||
}).width(formWidth).build());
|
||||
frame.arrangeElements();frame.visitWidgets(this::addRenderableWidget);updateSelection();
|
||||
}
|
||||
private int rightX(){return formX+formWidth+8;}
|
||||
private int lastColorPage(){return Math.max(0,(offer.colors().size()-1)/PAGE_SIZE)*PAGE_SIZE;}
|
||||
private void turnColorPage(int direction){
|
||||
colorStart=Math.clamp(colorStart+direction*PAGE_SIZE,0,lastColorPage());
|
||||
updateSelection();
|
||||
}
|
||||
private void sendCheck(String normalized){lastChecked=normalized;codeStatus="code_checking";updateSelection();if(ClientConfigurationNetworking.canSend(AccessPayloads.Check.TYPE))ClientConfigurationNetworking.send(new AccessPayloads.Check(normalized));}
|
||||
private boolean retryCode(){return accessRequired&&!accessPending&&AccessCodes.validFormat(AccessCodes.normalize(codeDraft))&&(codeStatus.equals("code_unreachable")||codeStatus.equals("code_unauthorized"));}
|
||||
@@ -100,45 +139,130 @@ public final class HelloWorldScreen extends Screen {
|
||||
if(submit!=null)submit.active=!waiting&&color>=0&&(starterChoices.isEmpty()||(!starter.isEmpty()&&StarterCompanions.validName(nameDraft)))&&codeOk;
|
||||
if(familiarName!=null)familiarName.setEditable(!waiting);
|
||||
if(codeBox!=null)codeBox.setEditable(!waiting&&!accessPending);
|
||||
if(bio!=null)bio.setEditable(!waiting&&!identityLocked);colors.forEach(b->b.active=!waiting&&!identityLocked);starters.forEach(b->b.active=!waiting);
|
||||
if(bio!=null)bio.setEditable(!waiting&&!identityLocked);
|
||||
for(int i=0;i<colors.size();i++){
|
||||
var button=colors.get(i);int index=colorStart+i;
|
||||
button.visible=index<offer.colors().size();button.active=button.visible&&!waiting&&!identityLocked;
|
||||
if(button.visible){
|
||||
button.showColor(offer.colors().get(index));
|
||||
}
|
||||
}
|
||||
if(previousColors!=null)previousColors.active=!waiting&&!identityLocked&&colorStart>0;
|
||||
if(nextColors!=null)nextColors.active=!waiting&&!identityLocked&&colorStart<lastColorPage();
|
||||
for(var arrow:Arrays.asList(previousColors,nextColors))if(arrow!=null)arrow.setTooltip(Tooltip.create(
|
||||
ProgressionService.label(arrow==previousColors?"previous_colors":"next_colors").copy().append("\n")
|
||||
.append(ProgressionService.label("color_page",colorStart/PAGE_SIZE+1,lastColorPage()/PAGE_SIZE+1))));
|
||||
starters.forEach(b->b.active=!waiting);
|
||||
}
|
||||
@Override public void extractBackground(GuiGraphicsExtractor g,int mx,int my,float a){
|
||||
g.fill(0,0,width,height,IntroScreen.WELCOME_BACKGROUND);
|
||||
// Keep the former dark grey tone, with a subtle tiled native stone grain.
|
||||
g.blit(RenderPipelines.GUI_TEXTURED,Identifier.withDefaultNamespace("textures/block/stone.png"),0,0,0,0,width,height,32,32,0xFF2E2E2E);
|
||||
g.blitSprite(RenderPipelines.GUI_TEXTURED,Identifier.withDefaultNamespace("hud/hotbar"),paletteX,paletteY,Math.round(182*paletteScale),Math.round(22*paletteScale));
|
||||
}
|
||||
@Override public void extractRenderState(GuiGraphicsExtractor g,int mx,int my,float a){
|
||||
super.extractRenderState(g,mx,my,a);g.centeredText(font,title,width/2,panelY+7,0xFFFFFFFF);
|
||||
boolean compactError=compact&&!offer.error().isEmpty()&&!offer.error().startsWith("code_");
|
||||
g.text(font,compactError?ProgressionService.label(offer.error()):Component.literal(offer.name()).withStyle(s->color<0?s:s.withColor(color)),panelX+72,panelY+(compact?24:29),compactError?0xFFFFFFAA:0xFFFFFFFF);
|
||||
if(accessRequired){
|
||||
String codeKey=codeStatus.isEmpty()?"code":codeStatus;
|
||||
int codeColor="code_recognized".equals(codeStatus)?0xFF88CC88:codeStatus.isEmpty()?0xFFBBBBBB:0xFFFFFFAA;
|
||||
g.text(font,ProgressionService.label(codeKey),panelX+72,panelY+(compact?37:44),codeColor);
|
||||
super.extractRenderState(g,mx,my,a);
|
||||
centeredFit(g,Component.literal(offer.name()),panelX,previewY-14,sideWidth,color<0?0xFFFFFFFF:0xFF000000|color);
|
||||
centeredFit(g,ProgressionService.label("hello_character"),formX,panelY,formWidth,0xFFFFFFFF);
|
||||
centeredFit(g,ProgressionService.label("choose_color"),formX+20,panelY+(compact?14:23),formWidth-40,0xFFBBBBBB);
|
||||
g.text(font,ProgressionService.label("write_bio"),formX,bio.getY()-11,0xFFBBBBBB);
|
||||
if(!compact)g.fill(formX,familiarY-12,formX+formWidth,familiarY-11,0xFF444444);
|
||||
centeredFit(g,ProgressionService.label(namingAvailable()?"hello_choose_familiar":"hello_no_familiar"),formX,familiarY,formWidth,0xFFFFFFFF);
|
||||
if(familiarName!=null)g.text(font,Component.translatable("sanctuary.starter.name"),formX,familiarName.getY()-11,0xFFBBBBBB);
|
||||
if(codeBox!=null&&!compact)g.text(font,ProgressionService.label("code"),formX,codeBox.getY()-11,0xFFBBBBBB);
|
||||
renderFamiliar(g,mx,my);
|
||||
g.blit(RenderPipelines.GUI_TEXTURED,Screen.FOOTER_SEPARATOR,0,height-FOOTER_HEIGHT,0,0,width,2,32,2);
|
||||
String status=waiting?"joining":!offer.error().isEmpty()&&!offer.error().startsWith("code_")?offer.error():accessRequired?codeStatus:"";
|
||||
if(!status.isEmpty())centeredFit(g,ProgressionService.label(status),panelX,backY-11,panelWidth,"code_recognized".equals(status)?0xFF88CC88:0xFFFFFFAA);
|
||||
}
|
||||
private void centeredFit(GuiGraphicsExtractor g,Component text,int x,int y,int w,int tint){
|
||||
float scale=Math.min(1f,w/(float)Math.max(1,font.width(text)));
|
||||
g.pose().pushMatrix();g.pose().translate(x+w/2f,y);g.pose().scale(scale,scale);
|
||||
g.centeredText(font,text,0,0,tint);g.pose().popMatrix();
|
||||
}
|
||||
private boolean namingAvailable(){return !starterChoices.isEmpty();}
|
||||
private void renderFamiliar(GuiGraphicsExtractor g,int mx,int my){
|
||||
var preview=starters.stream().filter(b->b.isHoveredOrFocused()).findFirst()
|
||||
.or(()->starters.stream().filter(b->b.choice.mob().equals(starter)).findFirst())
|
||||
.or(()->starters.stream().findFirst());
|
||||
var name=nameDraft.isBlank()?Component.translatable("sanctuary.starter.name"):Component.literal(nameDraft.strip());
|
||||
centeredFit(g,name,rightX(),previewY-14,sideWidth,0xFFFFFFFF);
|
||||
if(preview.isEmpty())return;
|
||||
var choice=preview.get().choice;
|
||||
HelloFamiliarPreview.extract(g,choice.mob(),rightX(),previewY,sideWidth,previewHeight,mx,my);
|
||||
if(mx>=rightX()&&mx<rightX()+sideWidth&&my>=previewY&&my<previewY+previewHeight)
|
||||
g.setTooltipForNextFrame(font,description(choice),mx,my);
|
||||
}
|
||||
private static Component description(StarterCompanions.Choice choice){
|
||||
return choice.type().label().copy().withStyle(choice.rarity().ordinal()==0?ChatFormatting.WHITE:ChatFormatting.YELLOW)
|
||||
.append("\n").append(choice.rarity().label().copy().withStyle(ChatFormatting.GRAY))
|
||||
.append("\n").append(about(choice).copy().withStyle(ChatFormatting.GRAY));
|
||||
}
|
||||
private static Identifier eggTexture(StarterCompanions.Choice choice){return Identifier.withDefaultNamespace("textures/item/"+choice.mob()+"_spawn_egg.png");}
|
||||
private static Component about(StarterCompanions.Choice choice){
|
||||
String key="sanctuary.starter.about."+choice.mob();
|
||||
return Component.translatable(net.minecraft.locale.Language.getInstance().has(key)?key:"sanctuary.starter.about.default");
|
||||
}
|
||||
private final class ColorPageButton extends Button.Plain {
|
||||
ColorPageButton(int x,int y,boolean forward){
|
||||
super(x,y,18,18,Component.literal(forward?">":"<"),b->turnColorPage(forward?1:-1),ignored->ProgressionService.label(forward?"next_colors":"previous_colors").copy());
|
||||
setTooltip(Tooltip.create(ProgressionService.label(forward?"next_colors":"previous_colors")));
|
||||
}
|
||||
g.text(font,ProgressionService.label("bio"),panelX+72,bioLabelY,0xFFBBBBBB);g.text(font,ProgressionService.label("choose_color"),panelX+72,colorLabelY,0xFFBBBBBB);
|
||||
String shown=offer.error().startsWith("code_")?"":offer.error();
|
||||
String status=waiting?"joining":shown;
|
||||
if(!starterChoices.isEmpty()){
|
||||
g.text(font,compact?Component.translatable("sanctuary.starter.compact_slots"):status.isEmpty()?Component.translatable("sanctuary.starter.slots"):ProgressionService.label(status),panelX+72,starterLabelY,compact||status.isEmpty()?0xFFBBBBBB:0xFFFFFFAA);
|
||||
g.text(font,Component.translatable("sanctuary.starter.name"),panelX+72+(compact?88:0),nameLabelY,0xFFBBBBBB);
|
||||
}
|
||||
else if(!status.isEmpty())g.centeredText(font,ProgressionService.label(status),width/2,panelY+(compact?169:204)+(accessRequired?36:0),0xFFFFFFAA);
|
||||
}
|
||||
private final class ColorButton extends Button.Plain {
|
||||
ColorButton(int x,int y,int w,int h,int rgb,int number,OnPress press){super(x,y,w,h,InhabitantColorNames.label(rgb).copy().withStyle(s->s.withColor(rgb)),press,ignored->ProgressionService.label("color_number",number).copy().append(": ").append(InhabitantColorNames.label(rgb)));setOverrideRenderHighlightedSprite(()->color==rgb||isHoveredOrFocused());}
|
||||
private int rgb;
|
||||
private final int slot;
|
||||
ColorButton(int slot){
|
||||
super(paletteX+Math.round((1+slot*20)*paletteScale),paletteY+Math.round(paletteScale),Math.round((21+slot*20)*paletteScale)-Math.round((1+slot*20)*paletteScale),Math.round(20*paletteScale),Component.empty(),b->{color=((ColorButton)b).rgb;updateSelection();},Button.DEFAULT_NARRATION);
|
||||
this.slot=slot;
|
||||
}
|
||||
void showColor(int rgb){
|
||||
this.rgb=rgb;setMessage(InhabitantColorNames.label(rgb));
|
||||
setTooltip(Tooltip.create(InhabitantColorNames.label(rgb)));
|
||||
}
|
||||
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float a){
|
||||
int x=paletteX+Math.round((slot*20+4)*paletteScale),y=paletteY+Math.round(4*paletteScale),size=Math.round(14*paletteScale);
|
||||
g.fill(x,y,x+size,y+size,0xFF000000|rgb);
|
||||
if(color==rgb)g.blitSprite(RenderPipelines.GUI_TEXTURED,Identifier.withDefaultNamespace("hud/hotbar_selection"),paletteX+Math.round((slot*20-1)*paletteScale),paletteY-Math.round(paletteScale),Math.round(24*paletteScale),Math.round(24*paletteScale));
|
||||
else if(isHoveredOrFocused())g.outline(x-1,y-1,size+2,size+2,0xFFFFFFFF);
|
||||
}
|
||||
}
|
||||
private final class PlayerPortrait extends PlayerSkinWidget {
|
||||
private float yaw=30,pitch=-5;
|
||||
PlayerPortrait(int w,int h){super(w,h,minecraft.getEntityModels(),minecraft.getSkinManager().createLookup(minecraft.getGameProfile(),false));active=false;}
|
||||
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int mx,int my,float delta){
|
||||
float nextYaw=(float)Math.toDegrees(Math.atan((mx-getX()-getWidth()/2f)/160f))*.55f;
|
||||
float nextPitch=-(float)Math.toDegrees(Math.atan((my-getY()-getHeight()*.18f)/180f))*.4f;
|
||||
super.onDrag(null,(nextYaw-yaw)/2.5,(pitch-nextPitch)/2.5);
|
||||
yaw=nextYaw;pitch=nextPitch;
|
||||
// Native skin rendering pivots around the body. Offset its foot centre back onto
|
||||
// the same floor as the familiar while looking up/down, and clip to this portrait.
|
||||
int originalY=getY(),originalHeight=getHeight(),renderHeight=Math.round(originalHeight*.88f);
|
||||
float scale=.97f*renderHeight/2.125f;
|
||||
float cosine=(float)Math.cos(Math.toRadians(pitch));
|
||||
float depth=.25f*(float)Math.abs(Math.sin(Math.toRadians(yaw)))+.125f*(float)Math.abs(Math.cos(Math.toRadians(yaw)))+.016f;
|
||||
float footOffset=(renderHeight-1.0625f*scale)*(cosine-1)-.085f*scale*cosine
|
||||
+scale*depth*(float)Math.abs(Math.sin(Math.toRadians(pitch)));
|
||||
int offset=Math.round(-FLOOR_INSET-footOffset);
|
||||
g.enableScissor(getX(),originalY,getRight(),getBottom());
|
||||
// The native picture-in-picture skin API ignores the GUI pose matrix.
|
||||
// Move its render rectangle, then restore the widget's layout bounds.
|
||||
setHeight(renderHeight);setY(originalY+originalHeight-renderHeight+offset);
|
||||
super.extractWidgetRenderState(g,mx,my,delta);
|
||||
setY(originalY);setHeight(originalHeight);g.disableScissor();
|
||||
}
|
||||
@Override protected void onDrag(MouseButtonEvent event,double dx,double dy){ }
|
||||
}
|
||||
private final class StarterSlot extends Button.Plain {
|
||||
private final StarterCompanions.Choice choice;
|
||||
StarterSlot(int x,int y,StarterCompanions.Choice choice){
|
||||
super(x,y,22,22,choice.type().label(),b->{starter=choice.mob();updateSelection();},ignored->choice.type().label().copy());this.choice=choice;
|
||||
String key="sanctuary.starter.about."+choice.mob();if(!net.minecraft.locale.Language.getInstance().has(key))key="sanctuary.starter.about.default";
|
||||
var description=choice.type().label().copy().withStyle(choice.rarity().ordinal()==0?ChatFormatting.WHITE:ChatFormatting.YELLOW).append("\n").append(choice.rarity().label().copy().withStyle(ChatFormatting.GRAY)).append("\n").append(Component.translatable(key).withStyle(ChatFormatting.GRAY));
|
||||
setTooltip(Tooltip.create(description));
|
||||
setTooltip(Tooltip.create(description(choice)));
|
||||
}
|
||||
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float a){
|
||||
g.blitSprite(RenderPipelines.GUI_TEXTURED,Identifier.withDefaultNamespace("container/slot"),getX(),getY(),22,22);
|
||||
// Configuration can precede item component binding on a cold multiplayer client.
|
||||
// The native flat egg texture is already loaded; an ItemStack is not safe here.
|
||||
g.blit(RenderPipelines.GUI_TEXTURED,Identifier.withDefaultNamespace("textures/item/"+choice.mob()+"_spawn_egg.png"),getX()+3,getY()+3,0,0,16,16,16,16);
|
||||
g.blit(RenderPipelines.GUI_TEXTURED,eggTexture(choice),getX()+3,getY()+3,0,0,16,16,16,16);
|
||||
if(choice.mob().equals(starter))g.blitSprite(RenderPipelines.GUI_TEXTURED,Identifier.withDefaultNamespace("hud/hotbar_selection"),getX()-1,getY()-1,24,24);
|
||||
else if(isHoveredOrFocused())g.fill(getX()+1,getY()+1,getX()+21,getY()+21,0x60FFFFFF);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package fr.koka.sanctuary.grave;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.progression.ProgressEffects;
|
||||
import fr.koka.sanctuary.providence.GraveRecovery;
|
||||
import net.fabricmc.fabric.api.event.player.*;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.*;
|
||||
import net.minecraft.core.*;
|
||||
@@ -70,7 +71,12 @@ public final class GraveService {
|
||||
var tag=new CompoundTag();tag.put(MARKER,marker);s.set(DataComponents.CUSTOM_DATA,CustomData.of(tag));
|
||||
s.set(DataComponents.CONTAINER,ItemContainerContents.fromItems(items));return s;
|
||||
}
|
||||
public static ItemStack head(SkullBlockEntity skull){var s=new ItemStack(Items.PLAYER_HEAD);s.applyComponents(skull.collectComponents());s.set(DataComponents.MAX_STACK_SIZE,1);return s;}
|
||||
public static ItemStack head(SkullBlockEntity skull){
|
||||
var s=new ItemStack(Items.PLAYER_HEAD);s.applyComponents(skull.collectComponents());s.set(DataComponents.MAX_STACK_SIZE,1);
|
||||
if(skull.getLevel() instanceof ServerLevel level)try{if(GraveRecovery.sealed(level.getServer(),s))s.set(DataComponents.CONTAINER,ItemContainerContents.EMPTY);}
|
||||
catch(Exception e){SanctuaryMod.LOGGER.error("Recovery ledger unavailable; grave remains locked",e);}
|
||||
return s;
|
||||
}
|
||||
public static boolean safe(ServerLevel level,BlockPos pos){
|
||||
if(!level.hasChunkAt(pos)||!level.getWorldBorder().isWithinBounds(pos)||pos.getY()<=level.getMinY()||pos.getY()>=level.getMaxY())return false;
|
||||
if(!level.getBlockState(pos).isAir())return false;
|
||||
@@ -99,16 +105,25 @@ public final class GraveService {
|
||||
p.sendSystemMessage(label("floating",entity.blockPosition().getX(),entity.blockPosition().getY(),entity.blockPosition().getZ()));
|
||||
}
|
||||
public static GraveContainer container(SkullBlockEntity skull){
|
||||
return OPEN.computeIfAbsent(skull,s->new GraveContainer(s.components().getOrDefault(DataComponents.CONTAINER,ItemContainerContents.EMPTY),
|
||||
var source=OPEN.computeIfAbsent(skull,s->new GraveContainer(s.components().getOrDefault(DataComponents.CONTAINER,ItemContainerContents.EMPTY),
|
||||
()->!s.isRemoved()&&s.getLevel()!=null&&s.getLevel().getBlockEntity(s.getBlockPos())==s,
|
||||
contents->{s.setComponents(DataComponentMap.builder().addAll(s.components()).set(DataComponents.CONTAINER,contents).build());s.setChanged();}));
|
||||
try{
|
||||
if(skull.getLevel() instanceof ServerLevel level&&GraveRecovery.sealed(level.getServer(),head(skull)))
|
||||
for(int i=0;i<source.getContainerSize();i++)if(!source.getItem(i).isEmpty())source.removeItemNoUpdate(i);
|
||||
}catch(Exception e){throw new IllegalStateException("Recovery ledger unavailable",e);}
|
||||
return source;
|
||||
}
|
||||
public static void open(ServerPlayer p,SkullBlockEntity skull){
|
||||
var source=container(skull);var title=head(skull).getHoverName();
|
||||
final GraveContainer source;
|
||||
try{source=container(skull);}catch(Exception e){p.sendSystemMessage(label("recovery_unavailable"));return;}
|
||||
var title=head(skull).getHoverName();
|
||||
// A distance guard belongs to the menu, independent of other viewers.
|
||||
p.openMenu(new SimpleMenuProvider((id,inv,player)->menu(id,inv,source,ItemStack.EMPTY,head(skull),()->p.level()==skull.getLevel()&&p.distanceToSqr(net.minecraft.world.phys.Vec3.atCenterOf(skull.getBlockPos()))<=64),title));
|
||||
}
|
||||
public static void open(ServerPlayer p,ItemStack head){
|
||||
try{if(GraveRecovery.sealed(p.level().getServer(),head))head.set(DataComponents.CONTAINER,ItemContainerContents.EMPTY);}
|
||||
catch(Exception e){p.sendSystemMessage(label("recovery_unavailable"));return;}
|
||||
int slot=-1;for(int i=0;i<p.getInventory().getContainerSize();i++)if(p.getInventory().getItem(i)==head){slot=i;break;}
|
||||
if(slot<0||head.getCount()!=1)return;final int index=slot;
|
||||
var source=new GraveContainer(head.getOrDefault(DataComponents.CONTAINER,ItemContainerContents.EMPTY),()->p.getInventory().getItem(index)==head&&p.isAlive(),contents->{head.set(DataComponents.CONTAINER,contents);p.getInventory().setChanged();});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import java.text.Normalizer;
|
||||
import java.util.*;
|
||||
|
||||
/** Pure, token-bounded phrase matching. The vocabulary is data, never executable code. */
|
||||
public final class ConceptDictionary {
|
||||
private final Map<String,List<String>> phrases;
|
||||
|
||||
public ConceptDictionary(JsonObject json) {
|
||||
var result = new LinkedHashMap<String,List<String>>();
|
||||
if (json.size() == 0 || json.size() > 128) throw new IllegalArgumentException("Invalid concept count");
|
||||
for (var entry : json.entrySet()) {
|
||||
if (!entry.getKey().matches("[A-Z_]{1,40}") || !entry.getValue().isJsonArray())
|
||||
throw new IllegalArgumentException("Invalid concept");
|
||||
var values = new ArrayList<String>();
|
||||
for (var value : entry.getValue().getAsJsonArray()) {
|
||||
if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw new IllegalArgumentException("Invalid synonym");
|
||||
String phrase = normalize(value.getAsString());
|
||||
if (phrase.isBlank() || phrase.length() > 100) throw new IllegalArgumentException("Invalid synonym length");
|
||||
values.add(phrase);
|
||||
}
|
||||
if (values.isEmpty() || values.size() > 256) throw new IllegalArgumentException("Invalid synonyms");
|
||||
result.put(entry.getKey(), List.copyOf(values));
|
||||
}
|
||||
phrases = Collections.unmodifiableMap(result);
|
||||
}
|
||||
|
||||
public boolean contains(String concept) { return phrases.containsKey(concept); }
|
||||
|
||||
public boolean startsWith(String concept, String normalized) {
|
||||
return phrases.getOrDefault(concept,List.of()).stream()
|
||||
.anyMatch(phrase -> normalized.equals(phrase) || normalized.startsWith(phrase+" "));
|
||||
}
|
||||
|
||||
public Set<String> detect(String normalized) {
|
||||
String bounded = " " + normalized + " ";
|
||||
var found = new LinkedHashSet<String>();
|
||||
phrases.forEach((concept, values) -> {
|
||||
if (values.stream().anyMatch(phrase -> bounded.contains(" " + phrase + " "))) found.add(concept);
|
||||
});
|
||||
return Collections.unmodifiableSet(found);
|
||||
}
|
||||
|
||||
public static String normalize(String text) {
|
||||
return Normalizer.normalize(text.toLowerCase(Locale.ROOT).replace("œ", "oe"), Normalizer.Form.NFD)
|
||||
.replaceAll("\\p{M}+", "").replaceAll("[^a-z0-9]+", " ").trim().replaceAll(" +", " ");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import fr.koka.sanctuary.grave.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.*;
|
||||
import net.minecraft.stats.Stats;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.component.*;
|
||||
import net.minecraft.world.level.block.entity.SkullBlockEntity;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
|
||||
/** Moves only the last death's remaining authoritative contents; a seal precedes every inventory mutation. */
|
||||
public final class GraveRecovery {
|
||||
private static final Map<MinecraftServer,RecoveryClaims> CLAIMS=new HashMap<>();
|
||||
private record Source(String id,Container contents) { }
|
||||
private GraveRecovery() { }
|
||||
private static RecoveryClaims claims(MinecraftServer server)throws Exception {
|
||||
var ledger=CLAIMS.get(server);
|
||||
if(ledger==null){ledger=new RecoveryClaims(server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-providence-recoveries.json"));CLAIMS.put(server,ledger);}
|
||||
return ledger;
|
||||
}
|
||||
public static void stopped(MinecraftServer server) {CLAIMS.remove(server);}
|
||||
public static boolean sealed(MinecraftServer server,ItemStack head)throws Exception {
|
||||
var marker=head.getOrDefault(DataComponents.CUSTOM_DATA,CustomData.EMPTY).copyTag().getCompound(GraveService.MARKER).orElse(null);
|
||||
return marker!=null&&claims(server).claimed(marker.getStringOr("id",""));
|
||||
}
|
||||
public static boolean available(ServerPlayer player) {
|
||||
try {
|
||||
var sources=sources(player);if(sources.isEmpty())return false;
|
||||
var plan=new ProvidenceInventory.Plan(player);
|
||||
for(var source:sources)for(int i=0;i<source.contents().getContainerSize();i++)
|
||||
if(!source.contents().getItem(i).isEmpty()&&!plan.give(source.contents().getItem(i)))return false;
|
||||
return plan.valid();
|
||||
}catch(Exception e){return false;}
|
||||
}
|
||||
public static boolean recover(ServerPlayer player)throws Exception {
|
||||
if(player.containerMenu instanceof GraveMenu||player.containerMenu instanceof GraveInventoryMenu)player.closeContainer();
|
||||
var sources=sources(player);if(sources.isEmpty())return false;
|
||||
var plan=new ProvidenceInventory.Plan(player);
|
||||
for(var source:sources)for(int i=0;i<source.contents().getContainerSize();i++)
|
||||
if(!source.contents().getItem(i).isEmpty()&&!plan.give(source.contents().getItem(i)))return false;
|
||||
if(!plan.valid())return false;
|
||||
claims(player.level().getServer()).claim(sources.stream().map(Source::id).toList(),player.getUUID());
|
||||
// Permanent seals make stale chunks/items unreadable after a crash. Never roll back a seal.
|
||||
for(var source:sources)for(int i=0;i<source.contents().getContainerSize();i++)if(!source.contents().getItem(i).isEmpty())source.contents().removeItemNoUpdate(i);
|
||||
plan.commit();return true;
|
||||
}
|
||||
private static List<Source> sources(ServerPlayer player)throws Exception {
|
||||
var death=player.getLastDeathLocation();if(death.isEmpty())return List.of();
|
||||
var level=player.level().getServer().getLevel(death.get().dimension());if(level==null)return List.of();
|
||||
var result=new LinkedHashMap<String,Source>();
|
||||
var ledger=claims(level.getServer());
|
||||
var origin=death.get().pos();
|
||||
for(int dx=-1;dx<=1;dx++)for(int dz=-1;dz<=1;dz++) {
|
||||
var chunk=level.getChunkSource().getChunkNow((origin.getX()>>4)+dx,(origin.getZ()>>4)+dz);if(chunk==null)continue;
|
||||
for(var be:chunk.getBlockEntities().values())if(be instanceof SkullBlockEntity skull&&GraveService.isGrave(skull)) {
|
||||
var head=GraveService.head(skull);String id=match(player,head,ledger);
|
||||
if(id!=null)put(result,id,GraveService.container(skull));
|
||||
}
|
||||
}
|
||||
var area=new AABB(origin.getX()-24,level.getMinY(),origin.getZ()-24,origin.getX()+25,level.getMaxY(),origin.getZ()+25);
|
||||
for(var item:level.getEntitiesOfClass(ItemEntity.class,area,e->GraveService.isGrave(e.getItem()))) {
|
||||
var head=item.getItem();String id=match(player,head,ledger);
|
||||
if(id!=null)put(result,id,new GraveContainer(head.getOrDefault(DataComponents.CONTAINER,ItemContainerContents.EMPTY),()->!item.isRemoved()&&item.getItem()==head,
|
||||
contents->{head.set(DataComponents.CONTAINER,contents);item.setItem(head);}));
|
||||
}
|
||||
for(int slot:ProvidenceInventory.slots(player)) {
|
||||
var head=player.getInventory().getItem(slot);String id=match(player,head,ledger);final int index=slot;
|
||||
if(id!=null)put(result,id,new GraveContainer(head.getOrDefault(DataComponents.CONTAINER,ItemContainerContents.EMPTY),()->player.getInventory().getItem(index)==head,
|
||||
contents->{head.set(DataComponents.CONTAINER,contents);player.getInventory().setChanged();}));
|
||||
}
|
||||
if(result.size()>16)return List.of();
|
||||
return List.copyOf(result.values());
|
||||
}
|
||||
private static void put(Map<String,Source> result,String id,GraveContainer contents) {
|
||||
if(contents.valid()&&!contents.isEmpty())result.putIfAbsent(id,new Source(id,contents));
|
||||
}
|
||||
private static String match(ServerPlayer player,ItemStack head,RecoveryClaims ledger) {
|
||||
if(!GraveService.isGrave(head)||head.getCount()!=1)return null;
|
||||
var marker=head.getOrDefault(DataComponents.CUSTOM_DATA,CustomData.EMPTY).copyTag().getCompound(GraveService.MARKER).orElseThrow();
|
||||
var death=player.getLastDeathLocation().orElseThrow();String id=marker.getStringOr("id","");
|
||||
if(!marker.getStringOr("owner","").equals(player.getUUID().toString())||ledger.claimed(id)
|
||||
||marker.getIntOr("death_number",-1)!=player.getStats().getValue(Stats.CUSTOM.get(Stats.DEATHS))
|
||||
||!marker.getStringOr("dimension","").equals(death.dimension().identifier().toString())
|
||||
||marker.getIntOr("x",Integer.MIN_VALUE)!=death.pos().getX()||marker.getIntOr("y",Integer.MIN_VALUE)!=death.pos().getY()
|
||||
||marker.getIntOr("z",Integer.MIN_VALUE)!=death.pos().getZ())return null;
|
||||
try {UUID.fromString(id);return id;}catch(IllegalArgumentException e){return null;}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import java.util.*;
|
||||
import net.minecraft.core.particles.ParticleTypes;
|
||||
import net.minecraft.network.protocol.game.ClientboundAddEntityPacket;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.sounds.*;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.effect.*;
|
||||
import net.minecraft.world.entity.*;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import fr.koka.sanctuary.weather.WeatherService;
|
||||
|
||||
public final class MiracleExecutor {
|
||||
private MiracleExecutor() { }
|
||||
public static boolean execute(ServerPlayer player,MiracleRegistry.Definition miracle)throws Exception {return execute(player,miracle,null);}
|
||||
public static boolean execute(ServerPlayer player,MiracleRegistry.Definition miracle,ServerPlayer target)throws Exception {
|
||||
var validation=MiracleValidator.validate(player,miracle.id(),target);
|
||||
if(!miracle.enabled()||!miracle.conditions().stream().allMatch(c->Boolean.TRUE.equals(validation.conditions().get(c))))return false;
|
||||
boolean executed=switch(miracle.id()) {
|
||||
case CLEAR_WEATHER -> WeatherService.providenceClear(player.level(),miracle.clearSeconds());
|
||||
case RAIN -> WeatherService.providenceRain(player.level(),miracle.clearSeconds());
|
||||
case RETURN_TO_DEATH,RESCUE_PLAYER,TELEPORT_PLAYER -> {
|
||||
var p=validation.destination();var level=validation.destinationLevel();
|
||||
boolean moved=p!=null&&level!=null&&player.teleportTo(level,p.getX()+.5,p.getY(),p.getZ()+.5,Set.of(),player.getYRot(),player.getXRot(),true);
|
||||
if(moved)player.fallDistance=0;yield moved;
|
||||
}
|
||||
case RECOVER_ITEMS -> GraveRecovery.recover(player);
|
||||
case HEAL -> {player.setHealth(player.getMaxHealth());yield true;}
|
||||
case FEED -> {
|
||||
var food=player.getFoodData();int cap=fr.koka.sanctuary.progression.ProgressEffects.foodCapacity(food);
|
||||
food.setFoodLevel(cap);food.setSaturation(cap);yield true;
|
||||
}
|
||||
case REPAIR_ITEM -> {player.getMainHandItem().setDamageValue(0);ProvidenceInventory.sync(player);yield true;}
|
||||
case LIGHTNING -> lightning(target);
|
||||
case CURSE -> curse(target);
|
||||
case VENGEANCE -> switch(player.level().getRandom().nextInt(3)) {
|
||||
case 0 -> lightning(target);
|
||||
case 1 -> curse(target);
|
||||
default -> {target.hurtServer(target.level(),target.damageSources().magic(),4);target.getFoodData().setFoodLevel(Math.max(0,target.getFoodData().getFoodLevel()-4));yield true;}
|
||||
};
|
||||
case STEAL -> steal(player,target);
|
||||
case DISARM -> disarm(target);
|
||||
};
|
||||
if(executed) {
|
||||
var recipient=miracle.id().targeted()&&miracle.id()!=MiracleRegistry.Id.TELEPORT_PLAYER?target:player;
|
||||
recipient.level().sendParticles(ParticleTypes.ENCHANT,recipient.getX(),recipient.getY()+1,recipient.getZ(),24,.5,.6,.5,.05);
|
||||
recipient.level().playSound(null,recipient.blockPosition(),SoundEvents.ENCHANTMENT_TABLE_USE,SoundSource.PLAYERS,.7f,.8f);
|
||||
}
|
||||
return executed;
|
||||
}
|
||||
private static boolean lightning(ServerPlayer target) {
|
||||
var bolt=EntityTypes.LIGHTNING_BOLT.create(target.level(),EntitySpawnReason.TRIGGERED);if(bolt==null)return false;
|
||||
bolt.setPos(target.position());bolt.setVisualOnly(true);
|
||||
// Even visualOnly vanilla bolts tick lightning rods and copper on the server.
|
||||
// Send the native transient visual to nearby clients; apply only the chosen player's damage here.
|
||||
var packet=new ClientboundAddEntityPacket(bolt,0,bolt.blockPosition());
|
||||
for(var viewer:target.level().getServer().getPlayerList().getPlayers())
|
||||
if(viewer.level()==target.level()&&viewer.distanceToSqr(target)<128*128)viewer.connection.send(packet);
|
||||
target.hurtServer(target.level(),target.damageSources().lightningBolt(),5);return true;
|
||||
}
|
||||
private static boolean curse(ServerPlayer target) {
|
||||
var effects=List.of(MobEffects.SLOWNESS,MobEffects.WEAKNESS,MobEffects.MINING_FATIGUE,MobEffects.NAUSEA,MobEffects.HUNGER);
|
||||
var effect=effects.get(target.level().getRandom().nextInt(effects.size()));
|
||||
return target.addEffect(new MobEffectInstance(effect,400,0));
|
||||
}
|
||||
private static boolean steal(ServerPlayer receiver,ServerPlayer target) {
|
||||
var candidates=ProvidenceInventory.stealableSlots(receiver,target);if(candidates.isEmpty())return false;
|
||||
int slot=candidates.get(receiver.level().getRandom().nextInt(candidates.size()));
|
||||
var plan=new ProvidenceInventory.Plan(receiver);var stack=target.getInventory().getItem(slot);
|
||||
if(!plan.give(stack.copyWithCount(1))||!plan.valid())return false;
|
||||
var taken=target.getInventory().removeItem(slot,1);if(taken.isEmpty())return false;
|
||||
plan.commit();ProvidenceInventory.sync(target);return true;
|
||||
}
|
||||
private static boolean disarm(ServerPlayer target) {
|
||||
var held=target.getMainHandItem();if(held.isEmpty())return false;
|
||||
target.setItemInHand(InteractionHand.MAIN_HAND,ItemStack.EMPTY);
|
||||
var item=new ItemEntity(target.level(),target.getX(),target.getY()+.5,target.getZ(),held);item.setDefaultPickUpDelay();
|
||||
if(!target.level().addFreshEntity(item)){target.setItemInHand(InteractionHand.MAIN_HAND,held);return false;}
|
||||
ProvidenceInventory.sync(target);return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import com.google.gson.*;
|
||||
import fr.koka.sanctuary.progression.AtomicJson;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
/** Closed executor identities, extensible declarative recognition and policy. No Minecraft dependency. */
|
||||
public record MiracleRegistry(ConceptDictionary dictionary, List<Definition> miracles, int historyEntries,
|
||||
int retentionDays, int prayerIntervalSeconds, int minimumScore, int responseDelaySeconds) {
|
||||
public static final int DEFAULT_RESPONSE_DELAY_SECONDS=5;
|
||||
public enum Id {
|
||||
RECOVER_ITEMS, RETURN_TO_DEATH, RESCUE_PLAYER, CLEAR_WEATHER, RAIN,
|
||||
HEAL, FEED, LIGHTNING, CURSE, VENGEANCE, STEAL, DISARM, TELEPORT_PLAYER, REPAIR_ITEM;
|
||||
public boolean targeted() {return this==LIGHTNING||this==CURSE||this==VENGEANCE||this==STEAL||this==DISARM||this==TELEPORT_PLAYER;}
|
||||
}
|
||||
public record Pattern(List<List<String>> required) { }
|
||||
public record Definition(Id id, boolean enabled, List<Pattern> patterns, List<String> excluded,
|
||||
List<String> conditions, double probability, int playerCooldownSeconds,
|
||||
int globalCooldownSeconds, int clearSeconds) { }
|
||||
private static final int LIMIT = 262144;
|
||||
|
||||
public Definition definition(Id id) { return miracles.stream().filter(m -> m.id() == id).findFirst().orElseThrow(); }
|
||||
|
||||
public static MiracleRegistry load(Path directory) throws Exception {
|
||||
var concepts=read(directory.resolve("concepts.json"),"concepts.json");
|
||||
defaults("concepts.json").entrySet().forEach(e->{if(!concepts.has(e.getKey()))concepts.add(e.getKey(),e.getValue().deepCopy());});
|
||||
// Preserve edited vocabulary and append only the aliases introduced with this catalogue.
|
||||
for(var entry:defaults("concept-additions-beta268.json").entrySet()) {
|
||||
var aliases=concepts.getAsJsonArray(entry.getKey());
|
||||
for(var alias:entry.getValue().getAsJsonArray())if(!aliases.contains(alias))aliases.add(alias.deepCopy());
|
||||
}
|
||||
var config=read(directory.resolve("miracles.json"),"miracles.json");
|
||||
var defaults=defaults("miracles.json").getAsJsonArray("miracles");
|
||||
var entries=config.getAsJsonArray("miracles");var seen=new HashSet<String>();
|
||||
for(var entry:entries) {
|
||||
var o=entry.getAsJsonObject();String id=o.get("id").getAsString();seen.add(id);
|
||||
// These two legacy entries were forced off until an implementation existed.
|
||||
if((id.equals("RECOVER_ITEMS")||id.equals("RESCUE_PLAYER"))&&o.get("conditions").toString().equals("[\"safety_unvalidated\"]")) {
|
||||
var next=java.util.stream.StreamSupport.stream(defaults.spliterator(),false).map(JsonElement::getAsJsonObject)
|
||||
.filter(d->d.get("id").getAsString().equals(id)).findFirst().orElseThrow();
|
||||
for(String key:List.of("conditions","patterns","enabled","probability"))o.add(key,next.get(key).deepCopy());
|
||||
}
|
||||
}
|
||||
for(var entry:defaults)if(!seen.contains(entry.getAsJsonObject().get("id").getAsString()))entries.add(entry.deepCopy());
|
||||
return parse(concepts,config);
|
||||
}
|
||||
|
||||
private static JsonObject defaults(String name)throws Exception {
|
||||
try(var stream=MiracleRegistry.class.getResourceAsStream("/providence/"+name)) {
|
||||
if(stream==null)throw new IllegalStateException("Missing Providence defaults");
|
||||
return AtomicJson.parse(new String(stream.readAllBytes(),StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject read(Path file, String resource) throws Exception {
|
||||
byte[] bytes = AtomicJson.read(file, LIMIT);
|
||||
if (bytes == null) {
|
||||
try (var stream = MiracleRegistry.class.getResourceAsStream("/providence/" + resource)) {
|
||||
if (stream == null) throw new IllegalStateException("Missing Providence defaults");
|
||||
bytes = stream.readAllBytes();
|
||||
}
|
||||
AtomicJson.write(file, null, new String(bytes, StandardCharsets.UTF_8), LIMIT);
|
||||
}
|
||||
return AtomicJson.parse(new String(bytes, StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
}
|
||||
|
||||
public static MiracleRegistry parse(JsonObject concepts, JsonObject config) {
|
||||
var dictionary = new ConceptDictionary(concepts);
|
||||
for (String key : List.of("REQUEST","DIRECT_REQUEST","NEGATION","HYPOTHETICAL"))
|
||||
if (!dictionary.contains(key)) throw new IllegalArgumentException("Missing grammatical concept: "+key);
|
||||
// Additive option: beta.265/266 configurations retain their values and use the new default.
|
||||
if (config.has("responseDelaySeconds"))
|
||||
AtomicJson.fields(config, "schema", "historyEntries", "retentionDays", "prayerIntervalSeconds", "minimumScore", "miracles", "responseDelaySeconds");
|
||||
else AtomicJson.fields(config, "schema", "historyEntries", "retentionDays", "prayerIntervalSeconds", "minimumScore", "miracles");
|
||||
if (integer(config,"schema",1,1) != 1) throw new IllegalArgumentException("Unknown Providence schema");
|
||||
var definitions = new ArrayList<Definition>();
|
||||
var ids = EnumSet.noneOf(Id.class);
|
||||
for (var element : config.getAsJsonArray("miracles")) {
|
||||
var o = element.getAsJsonObject();
|
||||
AtomicJson.fields(o,"id","enabled","patterns","excluded","conditions","probability","playerCooldownSeconds","globalCooldownSeconds","clearSeconds");
|
||||
Id id = Id.valueOf(o.get("id").getAsString());
|
||||
if (!ids.add(id)) throw new IllegalArgumentException("Duplicate miracle");
|
||||
if (!o.get("enabled").isJsonPrimitive() || !o.getAsJsonPrimitive("enabled").isBoolean()) throw new IllegalArgumentException("Expected enabled boolean");
|
||||
boolean enabled = o.get("enabled").getAsBoolean();
|
||||
var patterns = new ArrayList<Pattern>();
|
||||
for (var p : o.getAsJsonArray("patterns")) {
|
||||
var groups = new ArrayList<List<String>>();
|
||||
for (var group : p.getAsJsonArray()) {
|
||||
var names = strings(group);
|
||||
if (names.isEmpty() || names.stream().anyMatch(name -> !dictionary.contains(name))) throw new IllegalArgumentException("Unknown required concept");
|
||||
groups.add(names);
|
||||
}
|
||||
if (groups.size() < 2 || groups.size() > 8) throw new IllegalArgumentException("Miracles require a relation between concepts");
|
||||
patterns.add(new Pattern(List.copyOf(groups)));
|
||||
}
|
||||
if (patterns.isEmpty() || patterns.size() > 16) throw new IllegalArgumentException("Invalid patterns");
|
||||
var excluded = strings(o.get("excluded"));
|
||||
if (excluded.stream().anyMatch(name -> !dictionary.contains(name))) throw new IllegalArgumentException("Unknown excluded concept");
|
||||
var conditions = strings(o.get("conditions"));
|
||||
var expected=conditions(id);
|
||||
if (!Set.copyOf(conditions).equals(expected)) throw new IllegalArgumentException("Required executor safety conditions cannot be removed");
|
||||
var probability = o.getAsJsonPrimitive("probability");
|
||||
if (!probability.isNumber()) throw new IllegalArgumentException("Expected probability number");
|
||||
double chance = probability.getAsDouble();
|
||||
if (!Double.isFinite(chance) || chance < 0 || chance > 1) throw new IllegalArgumentException("Invalid probability");
|
||||
definitions.add(new Definition(id,enabled,List.copyOf(patterns),excluded,conditions,chance,
|
||||
integer(o,"playerCooldownSeconds",0,2592000),integer(o,"globalCooldownSeconds",0,2592000),integer(o,"clearSeconds",1,86400)));
|
||||
}
|
||||
if (ids.size() != Id.values().length) throw new IllegalArgumentException("Missing miracle definition");
|
||||
return new MiracleRegistry(dictionary,List.copyOf(definitions),integer(config,"historyEntries",0,1000),
|
||||
integer(config,"retentionDays",1,365),integer(config,"prayerIntervalSeconds",1,3600),integer(config,"minimumScore",75,100),
|
||||
config.has("responseDelaySeconds")?integer(config,"responseDelaySeconds",1,60):DEFAULT_RESPONSE_DELAY_SECONDS);
|
||||
}
|
||||
|
||||
public static Set<String> conditions(Id id) {
|
||||
return switch(id) {
|
||||
case RECOVER_ITEMS -> Set.of("alive","recoverable_grave");
|
||||
case RETURN_TO_DEATH -> Set.of("alive","last_death","same_dimension","safe_loaded_destination");
|
||||
case RESCUE_PLAYER -> Set.of("alive","safe_loaded_destination");
|
||||
case CLEAR_WEATHER -> Set.of("alive","overworld","weather_active");
|
||||
case RAIN -> Set.of("alive","overworld","not_raining");
|
||||
case HEAL -> Set.of("alive","injured");
|
||||
case FEED -> Set.of("alive","hungry");
|
||||
case LIGHTNING,CURSE,VENGEANCE -> Set.of("alive","target_online");
|
||||
case STEAL -> Set.of("alive","target_online","target_distinct","stealable_item");
|
||||
case DISARM -> Set.of("alive","target_online","held_item");
|
||||
case TELEPORT_PLAYER -> Set.of("alive","target_online","target_distinct","safe_loaded_destination");
|
||||
case REPAIR_ITEM -> Set.of("alive","damaged_held_item");
|
||||
};
|
||||
}
|
||||
|
||||
private static List<String> strings(JsonElement element) {
|
||||
var values = new ArrayList<String>();
|
||||
for (var value : element.getAsJsonArray()) {
|
||||
if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw new IllegalArgumentException("Expected string");
|
||||
values.add(value.getAsString());
|
||||
}
|
||||
return List.copyOf(values);
|
||||
}
|
||||
private static int integer(JsonObject o, String key, int min, int max) {
|
||||
var value = o.getAsJsonPrimitive(key);
|
||||
if (!value.isNumber()) throw new IllegalArgumentException("Expected integer");
|
||||
int n = value.getAsBigDecimal().intValueExact();
|
||||
if (n < min || n > max) throw new IllegalArgumentException("Invalid " + key);
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import java.util.*;
|
||||
import net.minecraft.core.*;
|
||||
import net.minecraft.server.level.*;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import fr.koka.sanctuary.weather.*;
|
||||
|
||||
/** Bounded read-only inspection of the real server world. Never loads or generates a chunk. */
|
||||
public final class MiracleValidator {
|
||||
public record Validation(Map<String,Boolean> conditions, ServerLevel destinationLevel, BlockPos destination) { }
|
||||
private MiracleValidator() { }
|
||||
public static Validation validate(ServerPlayer player, MiracleRegistry.Id id) {return validate(player,id,null);}
|
||||
public static Validation validate(ServerPlayer player, MiracleRegistry.Id id, ServerPlayer target) {
|
||||
var conditions=new LinkedHashMap<String,Boolean>();
|
||||
conditions.put("alive",player.isAlive()&&!player.isSpectator()&&!player.isSleeping()&&!player.isPassenger());
|
||||
var server=player.level().getServer();ServerLevel destinationLevel=player.level();BlockPos destination=null;
|
||||
boolean online=target!=null&&!target.hasDisconnected()&&!target.isRemoved()&&target.isAlive();
|
||||
if(id.targeted())conditions.put("target_online",online);
|
||||
switch(id) {
|
||||
case CLEAR_WEATHER,RAIN -> {
|
||||
conditions.put("overworld",player.level().dimension().equals(Level.OVERWORLD)&&player.level().canHaveWeather());
|
||||
var session=WeatherService.session(server);var weather=player.level().getWeatherData();
|
||||
boolean managed=session!=null&&session.active();
|
||||
if(id==MiracleRegistry.Id.CLEAR_WEATHER)conditions.put("weather_active",weather.isRaining()||weather.isThundering()||managed&&session.state().weather()!=DailyWeather.Kind.CLEAR);
|
||||
else conditions.put("not_raining",!weather.isRaining()&&!(managed&&session.state().weather().raining()));
|
||||
}
|
||||
case RETURN_TO_DEATH -> {
|
||||
var death=player.getLastDeathLocation();conditions.put("last_death",death.isPresent());
|
||||
boolean same=death.isPresent()&&death.get().dimension().equals(player.level().dimension());conditions.put("same_dimension",same);
|
||||
if(same)destination=findSafe(player,player.level(),death.get().pos());
|
||||
conditions.put("safe_loaded_destination",destination!=null);
|
||||
}
|
||||
case RESCUE_PLAYER -> {
|
||||
var spawn=server.getRespawnData();destinationLevel=server.getLevel(spawn.dimension());
|
||||
if(destinationLevel!=null)destination=findSafe(player,destinationLevel,spawn.pos());
|
||||
conditions.put("safe_loaded_destination",destination!=null);
|
||||
}
|
||||
case TELEPORT_PLAYER -> {
|
||||
conditions.put("target_distinct",online&&!target.getUUID().equals(player.getUUID()));
|
||||
if(online){destinationLevel=target.level();destination=findSafe(player,destinationLevel,target.blockPosition());}
|
||||
conditions.put("safe_loaded_destination",destination!=null);
|
||||
}
|
||||
case RECOVER_ITEMS -> conditions.put("recoverable_grave",GraveRecovery.available(player));
|
||||
case HEAL -> conditions.put("injured",player.getHealth()<player.getMaxHealth());
|
||||
case FEED -> conditions.put("hungry",player.getFoodData().getFoodLevel()<fr.koka.sanctuary.progression.ProgressEffects.foodCapacity(player.getFoodData()));
|
||||
case STEAL -> {
|
||||
conditions.put("target_distinct",online&&!target.getUUID().equals(player.getUUID()));
|
||||
conditions.put("stealable_item",online&&!ProvidenceInventory.stealableSlots(player,target).isEmpty());
|
||||
}
|
||||
case DISARM -> conditions.put("held_item",online&&!target.getMainHandItem().isEmpty());
|
||||
case REPAIR_ITEM -> conditions.put("damaged_held_item",player.getMainHandItem().isDamageableItem()&&player.getMainHandItem().isDamaged());
|
||||
default -> { }
|
||||
}
|
||||
return new Validation(Collections.unmodifiableMap(conditions),destinationLevel,destination);
|
||||
}
|
||||
|
||||
public static BlockPos findSafe(ServerPlayer player, ServerLevel level, BlockPos death) {
|
||||
BlockPos best=null; int distance=Integer.MAX_VALUE;
|
||||
for (int dx=-8;dx<=8;dx++) for (int dz=-8;dz<=8;dz++) for (int dy=-6;dy<=6;dy++) {
|
||||
int d=dx*dx+dy*dy+dz*dz;
|
||||
if (d>=distance) continue;
|
||||
var pos=death.offset(dx,dy,dz);
|
||||
if (safe(player,level,pos)) { best=pos.immutable(); distance=d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
public static boolean safe(ServerPlayer player, BlockPos feet) {
|
||||
return safe(player,player.level(),feet);
|
||||
}
|
||||
public static boolean safe(ServerPlayer player, ServerLevel level, BlockPos feet) {
|
||||
if (level.isOutsideBuildHeight(feet.below()) || level.isOutsideBuildHeight(feet.above())) return false;
|
||||
// Also inspect neighbouring blocks: a safe floor beside lava or a cactus is not an arrival.
|
||||
for (int dx=-1;dx<=1;dx++) for (int dz=-1;dz<=1;dz++) {
|
||||
var column=feet.offset(dx,0,dz);
|
||||
if (!level.getWorldBorder().isWithinBounds(column)
|
||||
|| level.getChunkSource().getChunkNow(column.getX()>>4,column.getZ()>>4)==null) return false;
|
||||
for (int dy=-1;dy<=1;dy++) {
|
||||
var state=level.getBlockState(column.offset(0,dy,0)); var block=state.getBlock();
|
||||
if (!state.getFluidState().isEmpty() || block==Blocks.MAGMA_BLOCK || block==Blocks.CACTUS
|
||||
|| block==Blocks.POWDER_SNOW || block==Blocks.CAMPFIRE || block==Blocks.SOUL_CAMPFIRE
|
||||
|| block==Blocks.FIRE || block==Blocks.SOUL_FIRE || block==Blocks.SWEET_BERRY_BUSH
|
||||
|| block==Blocks.WITHER_ROSE || block==Blocks.NETHER_PORTAL || block==Blocks.END_PORTAL) return false;
|
||||
}
|
||||
}
|
||||
if (!level.getBlockState(feet.below()).isFaceSturdy(level,feet.below(),Direction.UP)) return false;
|
||||
var box=player.getDimensions(net.minecraft.world.entity.Pose.STANDING).makeBoundingBox(feet.getX()+.5,feet.getY(),feet.getZ()+.5);
|
||||
return level.noCollision(player,box);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import fr.koka.sanctuary.progression.AtomicJson;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
/** New, isolated schema. Reservations survive crashes; pending miracles are never replayed. */
|
||||
public final class PrayerHistory {
|
||||
private static final int LIMIT = 8 * 1024 * 1024;
|
||||
public record Entry(String id, long time, UUID player, String prayer, PrayerInterpreter.Interpretation interpretation,
|
||||
Map<String,Boolean> conditions, String decision, boolean executed) {
|
||||
public Entry {
|
||||
if (id == null || time < 0 || player == null || prayer == null || prayer.length() > 512
|
||||
|| interpretation == null || conditions == null || decision == null) throw new IllegalArgumentException("Invalid prayer entry");
|
||||
}
|
||||
}
|
||||
public record State(int schema, Map<String,Long> cooldowns, Map<String,Long> lastPrayers,
|
||||
Map<String,Boolean> readable, List<Entry> entries) {
|
||||
public State {
|
||||
if (schema != 1 || cooldowns == null || lastPrayers == null || readable == null || entries == null
|
||||
|| cooldowns.size() > 50000 || lastPrayers.size() > 10000 || readable.size() > 10000 || entries.size() > 1000)
|
||||
throw new IllegalArgumentException("Invalid Providence state");
|
||||
for (var map : List.of(cooldowns,lastPrayers)) for (var e : map.entrySet())
|
||||
if (e.getKey() == null || e.getValue() == null || e.getValue() < 0) throw new IllegalArgumentException("Invalid deadline");
|
||||
readable.forEach((key,value) -> { UUID.fromString(key); if (value == null) throw new IllegalArgumentException("Invalid accessibility choice"); });
|
||||
entries.forEach(Objects::requireNonNull);
|
||||
cooldowns = Map.copyOf(cooldowns); lastPrayers = Map.copyOf(lastPrayers);
|
||||
readable = Map.copyOf(readable); entries = List.copyOf(entries);
|
||||
}
|
||||
}
|
||||
private final Path path;
|
||||
private byte[] bytes;
|
||||
private State state;
|
||||
|
||||
public PrayerHistory(Path path) throws Exception {
|
||||
this.path = path;
|
||||
bytes = AtomicJson.read(path,LIMIT);
|
||||
if (bytes == null) write(new State(1,Map.of(),Map.of(),Map.of(),List.of()));
|
||||
else {
|
||||
var json = AtomicJson.parse(new String(bytes,StandardCharsets.UTF_8));
|
||||
AtomicJson.fields(json,"schema","cooldowns","lastPrayers","readable","entries");
|
||||
state = new Gson().fromJson(json,State.class);
|
||||
}
|
||||
}
|
||||
public State state() { return state; }
|
||||
public boolean readable(UUID player) { return state.readable().getOrDefault(player.toString(),false); }
|
||||
|
||||
public void readable(UUID player, boolean value) throws Exception {
|
||||
var choices = new HashMap<>(state.readable());
|
||||
if (value) choices.put(player.toString(),true); else choices.remove(player.toString());
|
||||
write(new State(1,state.cooldowns(),state.lastPrayers(),choices,state.entries()));
|
||||
}
|
||||
|
||||
public void commit(Entry entry, Map<String,Long> reservations, long now, MiracleRegistry config) throws Exception {
|
||||
var cooldowns = new HashMap<>(state.cooldowns());
|
||||
cooldowns.entrySet().removeIf(e -> e.getValue() <= now);
|
||||
cooldowns.putAll(reservations);
|
||||
var last = new HashMap<>(state.lastPrayers());
|
||||
last.entrySet().removeIf(e -> e.getValue() + 3600000L < now);
|
||||
last.put(entry.player().toString(),now);
|
||||
var entries = new ArrayList<>(state.entries());
|
||||
entries.removeIf(e -> e.id().equals(entry.id()) || e.time() < now - config.retentionDays() * 86400000L);
|
||||
if (config.historyEntries() > 0) entries.add(entry);
|
||||
while (entries.size() > config.historyEntries()) entries.removeFirst();
|
||||
write(new State(1,cooldowns,last,state.readable(),entries));
|
||||
}
|
||||
|
||||
public void prune(long now, MiracleRegistry config) throws Exception {
|
||||
var entries = state.entries().stream().filter(e -> e.time() >= now-config.retentionDays()*86400000L).toList();
|
||||
if (entries.size() > config.historyEntries()) entries = entries.subList(entries.size()-config.historyEntries(),entries.size());
|
||||
var cooldowns = new HashMap<>(state.cooldowns()); cooldowns.entrySet().removeIf(e -> e.getValue() <= now);
|
||||
var last = new HashMap<>(state.lastPrayers()); last.entrySet().removeIf(e -> e.getValue()+3600000L < now);
|
||||
var next = new State(1,cooldowns,last,state.readable(),entries);
|
||||
if (!next.equals(state)) write(next);
|
||||
}
|
||||
private void write(State next) throws Exception {
|
||||
bytes = AtomicJson.write(path,bytes,new Gson().toJson(next)+"\n",LIMIT);
|
||||
state = next;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/** A complete positive clause is required; evidence is never combined across clauses. */
|
||||
public final class PrayerInterpreter {
|
||||
public record Interpretation(MiracleRegistry.Id intention, Map<String,Integer> scores,
|
||||
List<String> ambiguities, Set<String> concepts, boolean negated) { }
|
||||
private final MiracleRegistry registry;
|
||||
public PrayerInterpreter(MiracleRegistry registry) { this.registry = registry; }
|
||||
|
||||
public Interpretation interpret(String text) {
|
||||
if (text.length() > 512) throw new IllegalArgumentException("Prayer exceeds 512 characters");
|
||||
var scores = new LinkedHashMap<String,Integer>();
|
||||
var candidates = EnumSet.noneOf(MiracleRegistry.Id.class);
|
||||
var concepts = new LinkedHashSet<String>();
|
||||
boolean negated = false;
|
||||
// Commas do not separate an invocation from its request. Contrast, punctuation and conjunctions do.
|
||||
for (String clause : text.toLowerCase(Locale.ROOT).split("[.!?;\\n]|\\b(?:mais|but|et|and|puis)\\b")) {
|
||||
var found = new LinkedHashSet<>(registry.dictionary().detect(ConceptDictionary.normalize(clause)));
|
||||
// A request must address Providence, start a request segment, or contain a direct
|
||||
// action/pronoun phrase. "La pluie cesse" and "Le serveur arrête la pluie" are reports.
|
||||
boolean addressed = Arrays.stream(clause.split("[,:]"))
|
||||
.anyMatch(segment -> registry.dictionary().startsWith("REQUEST",ConceptDictionary.normalize(segment))
|
||||
|| registry.dictionary().startsWith("INVOKE",ConceptDictionary.normalize(segment)));
|
||||
if(addressed&&found.contains("INVOKE"))found.add("REQUEST");
|
||||
if (!addressed && !found.contains("DIRECT_REQUEST")) found.remove("REQUEST");
|
||||
concepts.addAll(found);
|
||||
boolean blocked = found.contains("NEGATION") || found.contains("HYPOTHETICAL") || ConceptDictionary.normalize(clause).split(" ").length<2;
|
||||
negated |= found.contains("NEGATION");
|
||||
for (var miracle : registry.miracles()) {
|
||||
int best = 0;
|
||||
boolean complete = false;
|
||||
for (var pattern : miracle.patterns()) {
|
||||
long matched = pattern.required().stream().filter(group -> group.stream().anyMatch(found::contains)).count();
|
||||
int score = (int)(100 * matched / pattern.required().size());
|
||||
best = Math.max(best,score);
|
||||
complete |= matched == pattern.required().size();
|
||||
}
|
||||
boolean excluded = miracle.excluded().stream().anyMatch(found::contains);
|
||||
if (blocked || excluded) best = 0;
|
||||
scores.merge(miracle.id().name(),best,Math::max);
|
||||
if (complete && !blocked && !excluded && best >= registry.minimumScore()) candidates.add(miracle.id());
|
||||
}
|
||||
}
|
||||
var ambiguities = candidates.size() > 1 ? candidates.stream().map(Enum::name).toList() : List.<String>of();
|
||||
return new Interpretation(candidates.size() == 1 ? candidates.iterator().next() : null,
|
||||
Collections.unmodifiableMap(scores),ambiguities,Set.copyOf(concepts),negated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/** Bounded, session-local waiting prayers. Deadlines use server world ticks, never a sleeping thread. */
|
||||
public final class PrayerQueue<T> {
|
||||
public record Waiting<T>(UUID player, T actor, String prayer, long dueTick) { }
|
||||
private final Map<UUID,Waiting<T>> waiting=new LinkedHashMap<>();
|
||||
|
||||
public boolean offer(UUID player,T actor,String prayer,long nowTick,int delaySeconds) {
|
||||
if (player==null || actor==null || prayer==null || prayer.isBlank() || prayer.length()>512
|
||||
|| delaySeconds<1 || delaySeconds>3600) throw new IllegalArgumentException("Invalid waiting prayer");
|
||||
if (waiting.containsKey(player) || waiting.size()>=1024) return false;
|
||||
waiting.put(player,new Waiting<>(player,actor,prayer,nowTick+delaySeconds*20L));
|
||||
return true;
|
||||
}
|
||||
|
||||
public List<Waiting<T>> ready(long nowTick) {
|
||||
var due=new ArrayList<Waiting<T>>();
|
||||
var iterator=waiting.values().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
var prayer=iterator.next();
|
||||
if (nowTick>=prayer.dueTick()) { due.add(prayer); iterator.remove(); }
|
||||
}
|
||||
return List.copyOf(due);
|
||||
}
|
||||
public Optional<Waiting<T>> remove(UUID player) { return Optional.ofNullable(waiting.remove(player)); }
|
||||
public void cancel(UUID player) { remove(player); }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Exact whole names only. Target UUIDs are bound at submission, never guessed from proximity. */
|
||||
public final class PrayerTargets {
|
||||
public record Name(UUID player,Set<String> aliases) { }
|
||||
public record Selection(UUID player,String prayer,boolean explicit,boolean ambiguous) { }
|
||||
private PrayerTargets() { }
|
||||
public static Selection select(String text,List<Name> online) {
|
||||
var explicit=Pattern.compile("@([\\p{L}\\p{N}_]{1,32})(?![\\p{L}\\p{N}_])").matcher(text);
|
||||
var found=new LinkedHashSet<UUID>();boolean hasExplicit=false,missing=false;
|
||||
var masked=new StringBuffer();
|
||||
while(explicit.find()) {
|
||||
hasExplicit=true;String name=explicit.group(1);
|
||||
var matches=online.stream().filter(p->p.aliases().stream().anyMatch(a->a.equalsIgnoreCase(name))).map(Name::player).distinct().toList();
|
||||
if(matches.isEmpty())missing=true;found.addAll(matches);explicit.appendReplacement(masked," joueur ");
|
||||
}
|
||||
explicit.appendTail(masked);
|
||||
if(hasExplicit)return new Selection(!missing&&found.size()==1?found.iterator().next():null,masked.toString(),true,found.size()>1);
|
||||
String result=text;
|
||||
for(var p:online)for(String name:p.aliases()) {
|
||||
if(name.isBlank())continue;
|
||||
var pattern=Pattern.compile("(?iu)(?<![\\p{L}\\p{N}_])"+Pattern.quote(name)+"(?![\\p{L}\\p{N}_])");
|
||||
if(pattern.matcher(text).find()){found.add(p.player());result=pattern.matcher(result).replaceAll(" joueur ");}
|
||||
}
|
||||
return new Selection(found.size()==1?found.iterator().next():null,result,false,found.size()>1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.DoubleSupplier;
|
||||
|
||||
/** Pure decisions and reservations. Delayed work is session-local and never reconstructed from the journal. */
|
||||
public final class ProvidenceEngine {
|
||||
public enum Verdict { GRANTED, DEFERRED, ALTERNATIVE, REFUSED, INCOMPREHENSIBLE, AMBIGUOUS, DISABLED, INELIGIBLE, COOLDOWN, SILENT, EXECUTION_FAILED, CANCELLED }
|
||||
@FunctionalInterface public interface Execution { boolean run() throws Exception; }
|
||||
@FunctionalInterface public interface MiracleExecution { boolean run(MiracleRegistry.Id miracle) throws Exception; }
|
||||
public static final class Deferred {
|
||||
private final PrayerHistory.Entry entry;
|
||||
private final MiracleRegistry.Id miracle;
|
||||
private boolean consumed;
|
||||
private Deferred(PrayerHistory.Entry entry,MiracleRegistry.Id miracle){this.entry=entry;this.miracle=miracle;}
|
||||
public MiracleRegistry.Id miracle(){return miracle;}
|
||||
private void take(){if(consumed)throw new IllegalStateException("Deferred prayer already completed");consumed=true;}
|
||||
}
|
||||
public record Result(Verdict verdict,boolean executed,Deferred deferred) {
|
||||
public Result(Verdict verdict,boolean executed){this(verdict,executed,null);}
|
||||
}
|
||||
private final MiracleRegistry registry;
|
||||
private final PrayerHistory history;
|
||||
public ProvidenceEngine(MiracleRegistry registry,PrayerHistory history){this.registry=registry;this.history=history;}
|
||||
|
||||
/** Existing deterministic decision contract, without special response draws. */
|
||||
public Result pray(UUID player,String prayer,PrayerInterpreter.Interpretation interpretation,Map<String,Boolean> conditions,
|
||||
long now,DoubleSupplier random,Execution execution)throws Exception {
|
||||
return pray(player,prayer,interpretation,conditions,now,random,ProvidenceResponses.IMMEDIATE,Map.of(),id->execution.run());
|
||||
}
|
||||
public Result pray(UUID player,String prayer,PrayerInterpreter.Interpretation interpretation,Map<String,Boolean> conditions,
|
||||
long now,DoubleSupplier random,ProvidenceResponses policy,Map<MiracleRegistry.Id,Map<String,Boolean>> alternatives,MiracleExecution execution)throws Exception {
|
||||
if(now<history.state().lastPrayers().getOrDefault(player.toString(),0L)+registry.prayerIntervalSeconds()*1000L)return new Result(Verdict.SILENT,false);
|
||||
var verdict=Verdict.INCOMPREHENSIBLE;var reservations=new HashMap<String,Long>();
|
||||
var original=interpretation.intention();var applied=original;var evidence=new LinkedHashMap<>(conditions);
|
||||
if(!interpretation.ambiguities().isEmpty())verdict=Verdict.AMBIGUOUS;
|
||||
else if(original!=null) {
|
||||
var miracle=registry.definition(original);
|
||||
if(!miracle.enabled())verdict=Verdict.DISABLED;
|
||||
else if(!eligible(miracle,conditions))verdict=Verdict.INELIGIBLE;
|
||||
else if(cooling(player,miracle,now))verdict=Verdict.COOLDOWN;
|
||||
else {
|
||||
reserve(reservations,player,miracle,now);
|
||||
verdict=draw(random)<miracle.probability()?Verdict.GRANTED:Verdict.REFUSED;
|
||||
}
|
||||
}
|
||||
if(verdict==Verdict.GRANTED&&policy.deferredChance()>0&&draw(random)<policy.deferredChance()) {
|
||||
verdict=Verdict.DEFERRED;
|
||||
reserve(reservations,player,registry.definition(original),now,policy.deferredSeconds());
|
||||
}else if(verdict!=Verdict.GRANTED&&verdict!=Verdict.DEFERRED&&(policy.silenceChance()>0||policy.alternativeChance()>0)) {
|
||||
double response=draw(random);
|
||||
if(response<policy.silenceChance())verdict=Verdict.SILENT;
|
||||
else if(response<policy.silenceChance()+policy.alternativeChance()&&(verdict==Verdict.REFUSED||verdict==Verdict.INELIGIBLE)
|
||||
&&!cooling(player,registry.definition(original),now)) {
|
||||
for(var id:policy.alternatives().getOrDefault(original,List.of())) {
|
||||
var alternative=registry.definition(id);var checked=alternatives.get(id);
|
||||
if(checked==null||!alternative.enabled()||!eligible(alternative,checked)||cooling(player,alternative,now))continue;
|
||||
// Reserve both attempts before the alternative draw, including a refused draw.
|
||||
reserve(reservations,player,registry.definition(original),now);reserve(reservations,player,alternative,now);
|
||||
// Only one alternative candidate receives a probability draw; failures never cascade.
|
||||
if(draw(random)<alternative.probability()) {
|
||||
verdict=Verdict.ALTERNATIVE;applied=id;evidence.putAll(checked);evidence.put("alternative_"+id.name(),true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
var entry=new PrayerHistory.Entry(UUID.randomUUID().toString(),now,player,prayer,interpretation,Map.copyOf(evidence),
|
||||
verdict==Verdict.GRANTED||verdict==Verdict.ALTERNATIVE?"PENDING":verdict.name(),false);
|
||||
history.commit(entry,reservations,now,registry);
|
||||
if(verdict==Verdict.DEFERRED)return new Result(verdict,false,new Deferred(entry,original));
|
||||
if(verdict!=Verdict.GRANTED&&verdict!=Verdict.ALTERNATIVE)return new Result(verdict,false);
|
||||
boolean executed=execution.run(applied);var finalVerdict=executed?verdict:Verdict.EXECUTION_FAILED;
|
||||
history.commit(entry(entry,entry.conditions(),finalVerdict.name(),executed),Map.of(),now,registry);
|
||||
return new Result(finalVerdict,executed);
|
||||
}
|
||||
public Result complete(Deferred deferred,Map<String,Boolean> conditions,long now,Execution execution)throws Exception {
|
||||
deferred.take();var entry=deferred.entry;var miracle=registry.definition(deferred.miracle());
|
||||
var verdict=!miracle.enabled()?Verdict.DISABLED:!eligible(miracle,conditions)?Verdict.INELIGIBLE:Verdict.GRANTED;
|
||||
if(verdict!=Verdict.GRANTED) {
|
||||
history.commit(entry(entry,conditions,verdict.name(),false),Map.of(),now,registry);return new Result(verdict,false);
|
||||
}
|
||||
var reservations=new HashMap<String,Long>();reserve(reservations,entry.player(),miracle,now);
|
||||
history.commit(entry(entry,conditions,"PENDING",false),reservations,now,registry);
|
||||
boolean executed=execution.run();verdict=executed?Verdict.GRANTED:Verdict.EXECUTION_FAILED;
|
||||
history.commit(entry(entry,conditions,verdict.name(),executed),Map.of(),now,registry);return new Result(verdict,executed);
|
||||
}
|
||||
public void cancel(Deferred deferred,long now)throws Exception {
|
||||
deferred.take();history.commit(entry(deferred.entry,deferred.entry.conditions(),"CANCELLED",false),Map.of(),now,registry);
|
||||
}
|
||||
private static PrayerHistory.Entry entry(PrayerHistory.Entry original,Map<String,Boolean> conditions,String verdict,boolean executed) {
|
||||
return new PrayerHistory.Entry(original.id(),original.time(),original.player(),original.prayer(),original.interpretation(),Map.copyOf(conditions),verdict,executed);
|
||||
}
|
||||
private static boolean eligible(MiracleRegistry.Definition miracle,Map<String,Boolean> conditions) {
|
||||
return miracle.conditions().stream().allMatch(c->Boolean.TRUE.equals(conditions.get(c)))&&conditions.values().stream().allMatch(Boolean.TRUE::equals);
|
||||
}
|
||||
private boolean cooling(UUID player,MiracleRegistry.Definition miracle,long now) {
|
||||
return now<history.state().cooldowns().getOrDefault(player+":"+miracle.id(),0L)||now<history.state().cooldowns().getOrDefault("global:"+miracle.id(),0L);
|
||||
}
|
||||
private void reserve(Map<String,Long> reservations,UUID player,MiracleRegistry.Definition miracle,long now) {
|
||||
reserve(reservations,player,miracle,now,0);
|
||||
}
|
||||
private void reserve(Map<String,Long> reservations,UUID player,MiracleRegistry.Definition miracle,long now,int deferredSeconds) {
|
||||
// A zero cooldown stays disabled, including while a different player's miracle is deferred.
|
||||
long individual=now+(miracle.playerCooldownSeconds()>0?deferredSeconds*1000L:0)+miracle.playerCooldownSeconds()*1000L;
|
||||
long global=now+(miracle.globalCooldownSeconds()>0?deferredSeconds*1000L:0)+miracle.globalCooldownSeconds()*1000L;
|
||||
for(var e:Map.of(player+":"+miracle.id(),individual,"global:"+miracle.id(),global).entrySet())
|
||||
reservations.merge(e.getKey(),Math.max(e.getValue(),history.state().cooldowns().getOrDefault(e.getKey(),0L)),Math::max);
|
||||
}
|
||||
private static double draw(DoubleSupplier random) {
|
||||
double value=random.getAsDouble();if(!Double.isFinite(value)||value<0||value>=1)throw new IllegalArgumentException("Invalid probability draw");return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import fr.koka.sanctuary.companion.CompanionInventory;
|
||||
import fr.koka.sanctuary.progression.ProgressEffects;
|
||||
import java.util.*;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Copies are planning data only. Commit writes changed destinations and never restores a source snapshot. */
|
||||
public final class ProvidenceInventory {
|
||||
private ProvidenceInventory() { }
|
||||
public static List<Integer> slots(ServerPlayer p) {
|
||||
return ProgressEffects.get(p)==null?java.util.stream.IntStream.range(0,36).boxed().toList():CompanionInventory.slots(p,true);
|
||||
}
|
||||
public static List<Integer> stealableSlots(ServerPlayer receiver,ServerPlayer target) {
|
||||
if(receiver.getUUID().equals(target.getUUID()))return List.of();
|
||||
return slots(target).stream().filter(i->{var stack=target.getInventory().getItem(i);return !stack.isEmpty()&&new Plan(receiver).give(stack.copyWithCount(1));}).toList();
|
||||
}
|
||||
public static final class Plan {
|
||||
private final ServerPlayer player;
|
||||
private final List<Integer> slots;
|
||||
private final List<ItemStack> before,after;
|
||||
public Plan(ServerPlayer player) {
|
||||
this.player=player;slots=slots(player);
|
||||
before=slots.stream().map(i->player.getInventory().getItem(i).copy()).toList();
|
||||
after=new ArrayList<>(before.stream().map(ItemStack::copy).toList());
|
||||
}
|
||||
public boolean give(ItemStack incoming) {
|
||||
var trial=new ArrayList<>(after.stream().map(ItemStack::copy).toList());var rest=incoming.copy();
|
||||
for(var item:trial)if(!item.isEmpty()&&ItemStack.isSameItemSameComponents(item,rest)) {
|
||||
int n=Math.min(rest.getCount(),Math.max(0,item.getMaxStackSize()-item.getCount()));item.grow(n);rest.shrink(n);
|
||||
}
|
||||
for(int i=0;i<trial.size()&&!rest.isEmpty();i++)if(trial.get(i).isEmpty()) {
|
||||
int n=Math.min(rest.getCount(),rest.getMaxStackSize());trial.set(i,rest.copyWithCount(n));rest.shrink(n);
|
||||
}
|
||||
if(!rest.isEmpty())return false;
|
||||
after.clear();after.addAll(trial);return true;
|
||||
}
|
||||
public boolean valid() {
|
||||
if(!slots.equals(slots(player)))return false;
|
||||
for(int i=0;i<slots.size();i++)if(!ItemStack.matches(before.get(i),after.get(i))
|
||||
&&!ItemStack.matches(before.get(i),player.getInventory().getItem(slots.get(i))))return false;
|
||||
return true;
|
||||
}
|
||||
public void commit() {
|
||||
if(!valid())throw new IllegalStateException("Recovery destination changed");
|
||||
for(int i=0;i<slots.size();i++)if(!ItemStack.matches(before.get(i),after.get(i)))player.getInventory().setItem(slots.get(i),after.get(i));
|
||||
sync(player);
|
||||
}
|
||||
}
|
||||
public static void sync(ServerPlayer p) {p.getInventory().setChanged();p.containerMenu.broadcastChanges();p.inventoryMenu.broadcastChanges();}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import java.util.Locale;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.network.chat.*;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
public final class ProvidenceMessages {
|
||||
private static final FontDescription FONT=new FontDescription.Resource(Identifier.fromNamespaceAndPath("sanctuary","providence"));
|
||||
private ProvidenceMessages() { }
|
||||
public static boolean visible(String key){return !key.equalsIgnoreCase("silent");}
|
||||
public static Component message(String key, boolean readable, int variant) {
|
||||
String verdict=key.toLowerCase(Locale.ROOT);
|
||||
String path="sanctuary.providence."+(readable?"readable.":"sga.")+verdict+"."+Math.floorMod(variant,2);
|
||||
var message=Component.translatable(path);
|
||||
if(!readable)message.withStyle(style -> style.withFont(FONT));
|
||||
ChatFormatting color=switch (verdict) {
|
||||
case "granted", "reloaded" -> ChatFormatting.GREEN;
|
||||
case "deferred" -> ChatFormatting.YELLOW;
|
||||
case "alternative" -> ChatFormatting.LIGHT_PURPLE;
|
||||
case "incomprehensible", "ambiguous", "heard" -> ChatFormatting.GRAY;
|
||||
case "silent" -> ChatFormatting.DARK_GRAY;
|
||||
case "refused", "disabled", "ineligible", "execution_failed", "invalid", "cooldown", "cancelled" -> ChatFormatting.RED;
|
||||
default -> null;
|
||||
};
|
||||
return color==null?message:message.withStyle(color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import com.google.gson.*;
|
||||
import fr.koka.sanctuary.progression.AtomicJson;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
/** Response policy is separate from each miracle's probability and safety conditions. */
|
||||
public record ProvidenceResponses(double deferredChance,double alternativeChance,double silenceChance,
|
||||
int deferredSeconds,Map<MiracleRegistry.Id,List<MiracleRegistry.Id>> alternatives) {
|
||||
public static final ProvidenceResponses IMMEDIATE=new ProvidenceResponses(0,0,0,30,Map.of());
|
||||
public ProvidenceResponses {
|
||||
if(!valid(deferredChance)||!valid(alternativeChance)||!valid(silenceChance)||alternativeChance+silenceChance>1
|
||||
||deferredSeconds<1||deferredSeconds>3600)throw new IllegalArgumentException("Invalid response policy");
|
||||
var copy=new EnumMap<MiracleRegistry.Id,List<MiracleRegistry.Id>>(MiracleRegistry.Id.class);
|
||||
alternatives.forEach((id,values)->{
|
||||
var safe=Set.of(MiracleRegistry.Id.HEAL,MiracleRegistry.Id.FEED,MiracleRegistry.Id.RESCUE_PLAYER,MiracleRegistry.Id.RETURN_TO_DEATH,MiracleRegistry.Id.REPAIR_ITEM);
|
||||
if(values.size()>5||new HashSet<>(values).size()!=values.size()||values.contains(id)||!safe.containsAll(values))throw new IllegalArgumentException("Invalid alternative help");
|
||||
copy.put(id,List.copyOf(values));
|
||||
});
|
||||
alternatives=Collections.unmodifiableMap(copy);
|
||||
}
|
||||
private static boolean valid(double p){return Double.isFinite(p)&&p>=0&&p<=1;}
|
||||
public static ProvidenceResponses load(Path directory)throws Exception {
|
||||
var path=directory.resolve("responses.json");var bytes=AtomicJson.read(path,65536);
|
||||
if(bytes==null)try(var stream=ProvidenceResponses.class.getResourceAsStream("/providence/responses.json")) {
|
||||
if(stream==null)throw new IllegalStateException("Missing response defaults");
|
||||
bytes=AtomicJson.write(path,null,new String(stream.readAllBytes(),StandardCharsets.UTF_8),65536);
|
||||
}
|
||||
return parse(AtomicJson.parse(new String(bytes,StandardCharsets.UTF_8)).getAsJsonObject());
|
||||
}
|
||||
public static ProvidenceResponses parse(JsonObject json) {
|
||||
AtomicJson.fields(json,"schema","deferredChance","alternativeChance","silenceChance","deferredSeconds","alternatives");
|
||||
if(integer(json,"schema")!=1)throw new IllegalArgumentException("Unknown response policy schema");
|
||||
var alternatives=new EnumMap<MiracleRegistry.Id,List<MiracleRegistry.Id>>(MiracleRegistry.Id.class);
|
||||
for(var e:json.getAsJsonObject("alternatives").entrySet()) {
|
||||
var values=new ArrayList<MiracleRegistry.Id>();
|
||||
for(var value:e.getValue().getAsJsonArray())values.add(MiracleRegistry.Id.valueOf(value.getAsString()));
|
||||
alternatives.put(MiracleRegistry.Id.valueOf(e.getKey()),values);
|
||||
}
|
||||
return new ProvidenceResponses(chance(json,"deferredChance"),chance(json,"alternativeChance"),chance(json,"silenceChance"),integer(json,"deferredSeconds"),alternatives);
|
||||
}
|
||||
private static double chance(JsonObject json,String key) {
|
||||
var v=json.getAsJsonPrimitive(key);if(!v.isNumber())throw new IllegalArgumentException("Expected response probability");return v.getAsDouble();
|
||||
}
|
||||
private static int integer(JsonObject json,String key) {
|
||||
var v=json.getAsJsonPrimitive(key);if(!v.isNumber())throw new IllegalArgumentException("Expected response integer");return v.getAsBigDecimal().intValueExact();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import com.mojang.brigadier.arguments.*;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.operator.OperatorService;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.*;
|
||||
import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents;
|
||||
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
|
||||
/** Explicit prayer commands only. No chat listener, network request or inventory interception. */
|
||||
public final class ProvidenceService {
|
||||
public static final Path CONFIG=FabricLoader.getInstance().getConfigDir().resolve("sanctuary/providence");
|
||||
private static final Map<MinecraftServer,Session> SESSIONS=new HashMap<>();
|
||||
private record Request(ServerPlayer player,PrayerTargets.Selection target,ProvidenceEngine.Deferred deferred) {
|
||||
Request(ServerPlayer player,PrayerTargets.Selection target){this(player,target,null);}
|
||||
}
|
||||
private static final class Session {
|
||||
final MiracleRegistry registry; final ProvidenceResponses responses; final PrayerHistory history; boolean healthy=true;
|
||||
final PrayerQueue<Request> waiting;
|
||||
Session(MiracleRegistry registry,ProvidenceResponses responses,PrayerHistory history,PrayerQueue<Request> waiting) {
|
||||
this.registry=registry; this.responses=responses; this.history=history; this.waiting=waiting;
|
||||
}
|
||||
}
|
||||
private ProvidenceService() { }
|
||||
public static void register() {
|
||||
ServerLifecycleEvents.SERVER_STARTED.register(server -> {
|
||||
try { reload(server); } catch (Exception e) { SanctuaryMod.LOGGER.error("Providence initialization refused; existing data preserved",e); }
|
||||
});
|
||||
ServerLifecycleEvents.SERVER_STOPPING.register(server->{
|
||||
var session=SESSIONS.get(server);if(session==null)return;
|
||||
for(var prayer:session.waiting.ready(Long.MAX_VALUE))cancelDeferred(session,prayer.actor());
|
||||
});
|
||||
ServerLifecycleEvents.SERVER_STOPPED.register(server->{SESSIONS.remove(server);GraveRecovery.stopped(server);});
|
||||
ServerPlayConnectionEvents.DISCONNECT.register((handler,server) -> cancel(handler.player));
|
||||
ServerPlayerEvents.AFTER_RESPAWN.register((oldPlayer,newPlayer,alive) -> cancel(oldPlayer));
|
||||
ServerTickEvents.END_SERVER_TICK.register(server -> {
|
||||
var session=SESSIONS.get(server);
|
||||
if (session==null || !session.healthy) return;
|
||||
try {
|
||||
for (var prayer:session.waiting.ready(server.overworld().getGameTime())) {
|
||||
if (!session.healthy) break;
|
||||
var player=prayer.actor().player();
|
||||
if (!player.hasDisconnected() && !player.isRemoved()) resolve(prayer.actor(),prayer.prayer(),session);
|
||||
else cancelDeferred(session,prayer.actor());
|
||||
}
|
||||
if (session.healthy && server.getTickCount()%1200==0) session.history.prune(System.currentTimeMillis(),session.registry);
|
||||
} catch (Exception e) { fail(session,e); }
|
||||
});
|
||||
CommandRegistrationCallback.EVENT.register((dispatcher,context,environment) -> {
|
||||
dispatcher.register(Commands.literal("pray").executes(c -> {
|
||||
var p=c.getSource().getPlayerOrException(); reply(p,"help"); return 0;
|
||||
}).then(Commands.argument("prayer",StringArgumentType.greedyString()).executes(c -> pray(c.getSource().getPlayerOrException(),StringArgumentType.getString(c,"prayer")))));
|
||||
dispatcher.register(Commands.literal("providence")
|
||||
.then(Commands.literal("readable").then(Commands.argument("enabled",BoolArgumentType.bool()).executes(c -> {
|
||||
var p=c.getSource().getPlayerOrException(); var session=SESSIONS.get(p.level().getServer());
|
||||
if (session==null || !session.healthy) { reply(p,"silent"); return 0; }
|
||||
try { session.history.readable(p.getUUID(),BoolArgumentType.getBool(c,"enabled")); reply(p,"accessibility"); return 1; }
|
||||
catch (Exception e) { fail(session,e); reply(p,"silent"); return 0; }
|
||||
})))
|
||||
.then(Commands.literal("reload").requires(OperatorService::canCommand).executes(c -> {
|
||||
try { reload(c.getSource().getServer()); c.getSource().sendSuccess(() -> ProvidenceMessages.message("reloaded",true,0),false); return 1; }
|
||||
catch (Exception e) { c.getSource().sendFailure(ProvidenceMessages.message("invalid",true,0)); SanctuaryMod.LOGGER.error("Providence reload refused; current session preserved",e); return 0; }
|
||||
})));
|
||||
});
|
||||
}
|
||||
public static void reload(MinecraftServer server) throws Exception {
|
||||
var registry=MiracleRegistry.load(CONFIG);
|
||||
var responses=ProvidenceResponses.load(CONFIG);
|
||||
var history=new PrayerHistory(server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-providence.json"));
|
||||
history.prune(System.currentTimeMillis(),registry);
|
||||
var previous=SESSIONS.get(server);
|
||||
SESSIONS.put(server,new Session(registry,responses,history,previous==null?new PrayerQueue<>():previous.waiting));
|
||||
}
|
||||
public static int pray(ServerPlayer player,String text) {
|
||||
var server=player.level().getServer(); var session=SESSIONS.get(server);
|
||||
if (session==null || !session.healthy) { reply(player,"silent"); return 0; }
|
||||
if (text.length()>512 || text.isBlank()) { reply(player,"help"); return 0; }
|
||||
long now=System.currentTimeMillis();
|
||||
// Rate limit before interpretation, destination scans or disk writes.
|
||||
if (now < session.history.state().lastPrayers().getOrDefault(player.getUUID().toString(),0L)+session.registry.prayerIntervalSeconds()*1000L) return 0;
|
||||
var names=server.getPlayerList().getPlayers().stream().map(p->new PrayerTargets.Name(p.getUUID(),
|
||||
new HashSet<>(List.of(p.getGameProfile().name(),p.getName().getString(),p.getDisplayName().getString())))).toList();
|
||||
var request=new Request(player,PrayerTargets.select(text,names));
|
||||
return session.waiting.offer(player.getUUID(),request,text,server.overworld().getGameTime(),session.registry.responseDelaySeconds())?1:0;
|
||||
}
|
||||
private static void resolve(Request request,String text,Session session) {
|
||||
var player=request.player();
|
||||
var server=player.level().getServer();
|
||||
long now=System.currentTimeMillis();
|
||||
try {
|
||||
var selection=request.target();
|
||||
var target=selection.player()==null?null:server.getPlayerList().getPlayer(selection.player());
|
||||
var engine=new ProvidenceEngine(session.registry,session.history);
|
||||
if(request.deferred()!=null) {
|
||||
var id=request.deferred().miracle();
|
||||
var result=engine.complete(request.deferred(),conditions(request,id,target),now,()->MiracleExecutor.execute(player,session.registry.definition(id),target));
|
||||
reply(player,result.verdict().name());return;
|
||||
}
|
||||
var interpreter=new PrayerInterpreter(session.registry);
|
||||
var interpretation=interpreter.interpret(selection.explicit()?selection.prayer():text);
|
||||
if(!selection.explicit()&&(interpretation.intention()==null||interpretation.intention().targeted())) {
|
||||
var named=interpreter.interpret(selection.prayer());
|
||||
if(named.intention()!=null&&named.intention().targeted())interpretation=named;
|
||||
}
|
||||
var recognized=interpretation;
|
||||
var checked=recognized.intention()==null?Map.<String,Boolean>of():conditions(request,recognized.intention(),target);
|
||||
var alternatives=new LinkedHashMap<MiracleRegistry.Id,Map<String,Boolean>>();
|
||||
boolean canHelp=recognized.intention()!=null&&Boolean.TRUE.equals(checked.get("alive"))
|
||||
&&!Boolean.FALSE.equals(checked.get("target_online"))&&!Boolean.FALSE.equals(checked.get("target_unambiguous"))&&!Boolean.FALSE.equals(checked.get("target_supported"));
|
||||
if(canHelp)for(var id:session.responses.alternatives().getOrDefault(recognized.intention(),List.of()))
|
||||
alternatives.put(id,MiracleValidator.validate(player,id).conditions());
|
||||
var result=engine.pray(player.getUUID(),text,interpretation,checked,now,()->server.overworld().getRandom().nextDouble(),session.responses,alternatives,
|
||||
id->MiracleExecutor.execute(player,session.registry.definition(id),id==recognized.intention()?target:null));
|
||||
if(result.deferred()!=null&&!session.waiting.offer(player.getUUID(),new Request(player,selection,result.deferred()),text,server.overworld().getGameTime(),session.responses.deferredSeconds())) {
|
||||
engine.cancel(result.deferred(),now);reply(player,"refused");return;
|
||||
}
|
||||
reply(player,result.verdict().name());
|
||||
} catch (Exception e) { fail(session,e); reply(player,"silent"); }
|
||||
}
|
||||
private static Map<String,Boolean> conditions(Request request,MiracleRegistry.Id id,ServerPlayer target) {
|
||||
var checked=new LinkedHashMap<>(MiracleValidator.validate(request.player(),id,target).conditions());
|
||||
if(id.targeted())checked.put("target_unambiguous",!request.target().ambiguous());
|
||||
else if(request.target().explicit())checked.put("target_supported",false);
|
||||
return checked;
|
||||
}
|
||||
private static void cancelDeferred(Session session,Request request) {
|
||||
if(request.deferred()==null)return;
|
||||
try{new ProvidenceEngine(session.registry,session.history).cancel(request.deferred(),System.currentTimeMillis());}
|
||||
catch(Exception e){fail(session,e);}
|
||||
}
|
||||
private static void cancel(ServerPlayer player) {
|
||||
var session=SESSIONS.get(player.level().getServer());
|
||||
if (session!=null) session.waiting.remove(player.getUUID()).ifPresent(prayer->cancelDeferred(session,prayer.actor()));
|
||||
}
|
||||
private static void reply(ServerPlayer player,String key) {
|
||||
if(!ProvidenceMessages.visible(key))return;
|
||||
var session=SESSIONS.get(player.level().getServer());
|
||||
player.sendSystemMessage(ProvidenceMessages.message(key,session!=null && session.history.readable(player.getUUID()),player.level().getRandom().nextInt(2)));
|
||||
}
|
||||
private static void fail(Session session,Exception error) {
|
||||
session.healthy=false; SanctuaryMod.LOGGER.error("Providence stopped; decision journal preserved",error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import com.google.gson.*;
|
||||
import fr.koka.sanctuary.progression.AtomicJson;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
/** Permanent, durable tomb seals. Never expires or replays an interrupted restitution. */
|
||||
public final class RecoveryClaims {
|
||||
private static final int LIMIT=8*1024*1024;
|
||||
private final Path path;
|
||||
private byte[] bytes;
|
||||
private final Map<String,String> claims=new LinkedHashMap<>();
|
||||
public RecoveryClaims(Path path)throws Exception {
|
||||
this.path=path;bytes=AtomicJson.read(path,LIMIT);
|
||||
if(bytes==null)return;
|
||||
var json=AtomicJson.parse(new String(bytes,StandardCharsets.UTF_8));AtomicJson.fields(json,"schema","claims");
|
||||
var o=json.getAsJsonObject();
|
||||
if(!o.getAsJsonPrimitive("schema").isNumber()||o.get("schema").getAsBigDecimal().intValueExact()!=1)throw new IllegalArgumentException("Unknown recovery contract");
|
||||
for(var e:o.getAsJsonObject("claims").entrySet()) {
|
||||
UUID.fromString(e.getKey());UUID.fromString(e.getValue().getAsString());claims.put(e.getKey(),e.getValue().getAsString());
|
||||
}
|
||||
if(claims.size()>100000)throw new IllegalArgumentException("Recovery ledger full");
|
||||
}
|
||||
public boolean claimed(String id) {return claims.containsKey(id);}
|
||||
public void claim(Collection<String> ids,UUID player)throws Exception {
|
||||
if(ids.isEmpty()||ids.stream().anyMatch(claims::containsKey)||claims.size()+ids.size()>100000)throw new IllegalStateException("Recovery already sealed or ledger full");
|
||||
var next=new LinkedHashMap<>(claims);
|
||||
for(String id:ids){UUID.fromString(id);next.put(id,player.toString());}
|
||||
var json=new JsonObject();json.addProperty("schema",1);json.add("claims",new Gson().toJsonTree(next));
|
||||
bytes=AtomicJson.write(path,bytes,new Gson().toJson(json)+"\n",LIMIT);
|
||||
claims.clear();claims.putAll(next);
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,26 @@ public final class WeatherService {
|
||||
}catch(Exception e){source.sendFailure(label("invalid"));SanctuaryMod.LOGGER.error("Daily weather selection refused; journal preserved",e);return 0;}
|
||||
}
|
||||
public static boolean snow(ServerLevel level){var s=session(level.getServer());return active(level)&&s.state().weather().snow();}
|
||||
/** Providence uses the existing daily contract; the next civil day resumes its normal forecast. */
|
||||
public static boolean providenceClear(ServerLevel level,int seconds)throws Exception{
|
||||
return providenceWeather(level,DailyWeather.Kind.CLEAR,seconds);
|
||||
}
|
||||
public static boolean providenceRain(ServerLevel level,int seconds)throws Exception{
|
||||
return providenceWeather(level,DailyWeather.Kind.RAIN,seconds);
|
||||
}
|
||||
private static boolean providenceWeather(ServerLevel level,DailyWeather.Kind kind,int seconds)throws Exception{
|
||||
if(level!=level.getServer().overworld()||!level.canHaveWeather())return false;
|
||||
var s=session(level.getServer());
|
||||
if(s!=null&&!s.healthy)return false;
|
||||
if(s!=null&&s.active()){
|
||||
s.store.select(RealtimeService.civilTime(level.getServer()).toLocalDate(),kind);
|
||||
nativeDurations(level);apply(level,s);sync(level.getServer(),s);
|
||||
}else{
|
||||
var data=level.getWeatherData();boolean rain=kind==DailyWeather.Kind.RAIN;
|
||||
data.setClearWeatherTime(rain?0:seconds*20);data.setRainTime(rain?seconds*20:0);data.setThunderTime(0);data.setRaining(rain);data.setThundering(false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
public static SeasonalWeather.Season season(MinecraftServer server){var now=RealtimeService.civilTime(server);return SeasonalWeather.day(now.toLocalDate(),now.getZone(),RealtimeService.southernHemisphere(server));}
|
||||
private static DailyWeather.Kind forecast(MinecraftServer server,java.time.LocalDate day,WeatherConfig c){return SeasonalWeather.forecast(server.overworld().getSeed(),day,RealtimeService.civilTime(server).getZone(),RealtimeService.southernHemisphere(server),c);}
|
||||
private static int forecast(CommandSourceStack source){
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"type": "reference",
|
||||
"id": "minecraft:alt"
|
||||
},
|
||||
{
|
||||
"type": "reference",
|
||||
"id": "minecraft:default"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -300,9 +300,17 @@
|
||||
"sanctuary.world_options.expeditions_hint": "Four ancient 512-block expeditions: cold to the north, tropics to the east, canyon to the south and ocean to the west.",
|
||||
"sanctuary.world_options.expeditions002_hint": "512-block islands: boreal forest and ice to the north, tropics to the east, canyon to the south and warm ocean to the west.",
|
||||
"sanctuary.progression.hello_title": "HELLO_WORLD · Sanctuary",
|
||||
"sanctuary.progression.hello_character": "Character",
|
||||
"sanctuary.progression.hello_choose_familiar": "Choose your familiar",
|
||||
"sanctuary.progression.hello_no_familiar": "No selection required",
|
||||
"sanctuary.progression.previous_colors": "Previous colours",
|
||||
"sanctuary.progression.next_colors": "Next colours",
|
||||
"sanctuary.progression.color_page": "Palette %s / %s",
|
||||
"sanctuary.progression.get_code_short": "Code",
|
||||
"sanctuary.progression.title": "Blocodex",
|
||||
"sanctuary.progression.write_bio": "Write your biography",
|
||||
"sanctuary.progression.bio": "Biography",
|
||||
"sanctuary.progression.bio_hint": "A few words about your inhabitant…",
|
||||
"sanctuary.progression.bio_hint": "A few words about you…",
|
||||
"sanctuary.progression.choose_color": "Choose your colour",
|
||||
"sanctuary.progression.cancel": "Cancel",
|
||||
"sanctuary.progression.enter": "Enter Sanctuary",
|
||||
@@ -1283,7 +1291,7 @@
|
||||
"sanctuary.grave.cursor": "Cursor",
|
||||
"sanctuary.starter.version_required": "This Sanctuary version does not support choosing a starter familiar.",
|
||||
"sanctuary.starter.name": "Familiar name",
|
||||
"sanctuary.starter.name_hint": "Optional — otherwise the species name",
|
||||
"sanctuary.starter.name_hint": "Optional — species name",
|
||||
"sanctuary.progression.starter_name_invalid": "Invalid name (32 characters max.).",
|
||||
"sanctuary.progression.starter_name_version_required": "Update the server to name your familiar.",
|
||||
"sanctuary.starter.slots": "Starter familiar",
|
||||
@@ -2887,5 +2895,70 @@
|
||||
"sanctuary.shaders.sunshaft_steps": "Shaft detail",
|
||||
"sanctuary.shaders.sunshaft_steps_value": "Shaft detail: %s / 32 blocks",
|
||||
"sanctuary.shaders.sunshaft_steps_help": "More samples reduce repeated caster silhouettes. Medium: 96, intensity 10%. This setting changes cost even at low intensity.",
|
||||
"sanctuary.shaders.experimental_distance_value": "%s (experimental)"
|
||||
"sanctuary.shaders.experimental_distance_value": "%s (experimental)",
|
||||
"sanctuary.providence.readable.granted.0": "Your prayer crossed the veil.",
|
||||
"sanctuary.providence.sga.granted.0": "your prayer crossed the veil.",
|
||||
"sanctuary.providence.readable.granted.1": "So it shall be.",
|
||||
"sanctuary.providence.sga.granted.1": "so it shall be.",
|
||||
"sanctuary.providence.readable.refused.0": "The sky does not yield.",
|
||||
"sanctuary.providence.sga.refused.0": "the sky does not yield.",
|
||||
"sanctuary.providence.readable.refused.1": "This gift must wait.",
|
||||
"sanctuary.providence.sga.refused.1": "this gift must wait.",
|
||||
"sanctuary.providence.readable.incomprehensible.0": "Your words fade into mist.",
|
||||
"sanctuary.providence.sga.incomprehensible.0": "your words fade into mist.",
|
||||
"sanctuary.providence.readable.incomprehensible.1": "Their meaning remains veiled.",
|
||||
"sanctuary.providence.sga.incomprehensible.1": "their meaning remains veiled.",
|
||||
"sanctuary.providence.readable.ambiguous.0": "Two paths cloud your voice.",
|
||||
"sanctuary.providence.sga.ambiguous.0": "two paths cloud your voice.",
|
||||
"sanctuary.providence.readable.ambiguous.1": "One prayer. One path.",
|
||||
"sanctuary.providence.sga.ambiguous.1": "one prayer. one path.",
|
||||
"sanctuary.providence.readable.disabled.0": "This power still sleeps.",
|
||||
"sanctuary.providence.sga.disabled.0": "this power still sleeps.",
|
||||
"sanctuary.providence.readable.disabled.1": "The seal remains closed.",
|
||||
"sanctuary.providence.sga.disabled.1": "the seal remains closed.",
|
||||
"sanctuary.providence.readable.ineligible.0": "The path cannot be opened.",
|
||||
"sanctuary.providence.sga.ineligible.0": "the path cannot be opened.",
|
||||
"sanctuary.providence.readable.ineligible.1": "The world denies this passage.",
|
||||
"sanctuary.providence.sga.ineligible.1": "the world denies this passage.",
|
||||
"sanctuary.providence.readable.cooldown.0": "Time must finish its work.",
|
||||
"sanctuary.providence.sga.cooldown.0": "time must finish its work.",
|
||||
"sanctuary.providence.readable.cooldown.1": "Await the next sign.",
|
||||
"sanctuary.providence.sga.cooldown.1": "await the next sign.",
|
||||
"sanctuary.providence.readable.silent.0": "Providence remains silent.",
|
||||
"sanctuary.providence.sga.silent.0": "providence remains silent.",
|
||||
"sanctuary.providence.readable.silent.1": "The veil does not stir.",
|
||||
"sanctuary.providence.sga.silent.1": "the veil does not stir.",
|
||||
"sanctuary.providence.readable.execution_failed.0": "The passage has closed.",
|
||||
"sanctuary.providence.sga.execution_failed.0": "the passage has closed.",
|
||||
"sanctuary.providence.readable.execution_failed.1": "The gift cannot take form.",
|
||||
"sanctuary.providence.sga.execution_failed.1": "the gift cannot take form.",
|
||||
"sanctuary.providence.readable.heard.0": "Your prayer is heard.",
|
||||
"sanctuary.providence.sga.heard.0": "your prayer is heard.",
|
||||
"sanctuary.providence.readable.heard.1": "A voice crosses the veil.",
|
||||
"sanctuary.providence.sga.heard.1": "a voice crosses the veil.",
|
||||
"sanctuary.providence.readable.help.0": "Pray with /pray followed by your words (up to 512 characters). Plain text: /providence readable true.",
|
||||
"sanctuary.providence.sga.help.0": "pray with /pray followed by your words (up to 512 characters). plain text: /providence readable true.",
|
||||
"sanctuary.providence.readable.help.1": "Offer your words with /pray. Plain text: /providence readable true.",
|
||||
"sanctuary.providence.sga.help.1": "offer your words with /pray. plain text: /providence readable true.",
|
||||
"sanctuary.providence.readable.accessibility.0": "Providence now speaks in plain letters.",
|
||||
"sanctuary.providence.sga.accessibility.0": "the veil returns to its signs",
|
||||
"sanctuary.providence.readable.accessibility.1": "Your eyes can read the veil.",
|
||||
"sanctuary.providence.sga.accessibility.1": "the veil returns to its signs",
|
||||
"sanctuary.providence.readable.reloaded.0": "Providence: dictionary and rules reloaded.",
|
||||
"sanctuary.providence.sga.reloaded.0": "providence: dictionary and rules reloaded.",
|
||||
"sanctuary.providence.readable.reloaded.1": "Providence: dictionary and rules reloaded.",
|
||||
"sanctuary.providence.sga.reloaded.1": "providence: dictionary and rules reloaded.",
|
||||
"sanctuary.providence.readable.invalid.0": "Providence: reload refused. See the server log.",
|
||||
"sanctuary.providence.sga.invalid.0": "providence: reload refused. see the server log.",
|
||||
"sanctuary.providence.readable.invalid.1": "Providence: reload refused. See the server log.",
|
||||
"sanctuary.providence.sga.invalid.1": "providence: reload refused. see the server log.",
|
||||
"sanctuary.grave.recovery_unavailable": "The recovery ledger is unavailable. This grave remains locked.",
|
||||
"sanctuary.providence.readable.deferred.0": "The gift ripens beyond the veil.",
|
||||
"sanctuary.providence.sga.deferred.0": "the gift ripens beyond the veil.",
|
||||
"sanctuary.providence.readable.deferred.1": "The miracle awaits its hour.",
|
||||
"sanctuary.providence.sga.deferred.1": "the miracle awaits its hour.",
|
||||
"sanctuary.providence.readable.alternative.0": "Another gift answers your voice.",
|
||||
"sanctuary.providence.sga.alternative.0": "another gift answers your voice.",
|
||||
"sanctuary.providence.readable.alternative.1": "Your aid takes another form.",
|
||||
"sanctuary.providence.sga.alternative.1": "your aid takes another form."
|
||||
}
|
||||
|
||||
@@ -300,9 +300,17 @@
|
||||
"sanctuary.world_options.expeditions_hint": "Quatre anciennes expéditions de 512 blocs : froid au nord, tropiques à l’est, canyon au sud et océan à l’ouest.",
|
||||
"sanctuary.world_options.expeditions002_hint": "Îles de 512 blocs : boréal et glaces au nord, tropiques à l’est, canyon au sud et océan chaud à l’ouest.",
|
||||
"sanctuary.progression.hello_title": "HELLO_WORLD · Sanctuary",
|
||||
"sanctuary.progression.hello_character": "Personnage",
|
||||
"sanctuary.progression.hello_choose_familiar": "Choisis ton familier",
|
||||
"sanctuary.progression.hello_no_familiar": "Aucun choix à effectuer",
|
||||
"sanctuary.progression.previous_colors": "Couleurs précédentes",
|
||||
"sanctuary.progression.next_colors": "Couleurs suivantes",
|
||||
"sanctuary.progression.color_page": "Palette %s / %s",
|
||||
"sanctuary.progression.get_code_short": "Code",
|
||||
"sanctuary.progression.title": "Blocodex",
|
||||
"sanctuary.progression.write_bio": "Écris ta biographie",
|
||||
"sanctuary.progression.bio": "Biographie",
|
||||
"sanctuary.progression.bio_hint": "Quelques mots sur ton habitant…",
|
||||
"sanctuary.progression.bio_hint": "Quelques mots sur toi…",
|
||||
"sanctuary.progression.choose_color": "Choisis ta couleur",
|
||||
"sanctuary.progression.cancel": "Annuler",
|
||||
"sanctuary.progression.enter": "Entrer dans Sanctuary",
|
||||
@@ -1283,7 +1291,7 @@
|
||||
"sanctuary.grave.cursor": "Curseur",
|
||||
"sanctuary.starter.version_required": "Cette version de Sanctuary ne prend pas en charge le choix du familier de départ.",
|
||||
"sanctuary.starter.name": "Nom du familier",
|
||||
"sanctuary.starter.name_hint": "Facultatif — nom de l’espèce sinon",
|
||||
"sanctuary.starter.name_hint": "Facultatif — nom de l’espèce",
|
||||
"sanctuary.progression.starter_name_invalid": "Nom invalide (32 caractères max.).",
|
||||
"sanctuary.progression.starter_name_version_required": "Serveur à mettre à jour pour nommer le familier.",
|
||||
"sanctuary.starter.slots": "Familier de départ",
|
||||
@@ -2887,5 +2895,70 @@
|
||||
"sanctuary.shaders.sunshaft_steps": "Détail des rayons",
|
||||
"sanctuary.shaders.sunshaft_steps_value": "Détail des rayons : %s / 32 blocs",
|
||||
"sanctuary.shaders.sunshaft_steps_help": "Plus d’échantillons réduisent les silhouettes répétées. Moyen : 96, intensité 10 %. Ce réglage change le coût, même à faible intensité.",
|
||||
"sanctuary.shaders.experimental_distance_value": "%s (expérimental)"
|
||||
"sanctuary.shaders.experimental_distance_value": "%s (expérimental)",
|
||||
"sanctuary.providence.readable.granted.0": "Ta prière a traversé le voile.",
|
||||
"sanctuary.providence.sga.granted.0": "ta priere a traverse le voile.",
|
||||
"sanctuary.providence.readable.granted.1": "Qu’il en soit ainsi.",
|
||||
"sanctuary.providence.sga.granted.1": "qu'il en soit ainsi.",
|
||||
"sanctuary.providence.readable.refused.0": "Le ciel ne s’incline pas.",
|
||||
"sanctuary.providence.sga.refused.0": "le ciel ne s'incline pas.",
|
||||
"sanctuary.providence.readable.refused.1": "Ce don attendra.",
|
||||
"sanctuary.providence.sga.refused.1": "ce don attendra.",
|
||||
"sanctuary.providence.readable.incomprehensible.0": "Tes mots se perdent dans la brume.",
|
||||
"sanctuary.providence.sga.incomprehensible.0": "tes mots se perdent dans la brume.",
|
||||
"sanctuary.providence.readable.incomprehensible.1": "Le sens demeure voilé.",
|
||||
"sanctuary.providence.sga.incomprehensible.1": "le sens demeure voile.",
|
||||
"sanctuary.providence.readable.ambiguous.0": "Deux chemins brouillent ta voix.",
|
||||
"sanctuary.providence.sga.ambiguous.0": "deux chemins brouillent ta voix.",
|
||||
"sanctuary.providence.readable.ambiguous.1": "Une seule prière. Un seul chemin.",
|
||||
"sanctuary.providence.sga.ambiguous.1": "une seule priere. un seul chemin.",
|
||||
"sanctuary.providence.readable.disabled.0": "Ce pouvoir dort encore.",
|
||||
"sanctuary.providence.sga.disabled.0": "ce pouvoir dort encore.",
|
||||
"sanctuary.providence.readable.disabled.1": "Le sceau demeure fermé.",
|
||||
"sanctuary.providence.sga.disabled.1": "le sceau demeure ferme.",
|
||||
"sanctuary.providence.readable.ineligible.0": "Le chemin ne peut être ouvert.",
|
||||
"sanctuary.providence.sga.ineligible.0": "le chemin ne peut etre ouvert.",
|
||||
"sanctuary.providence.readable.ineligible.1": "Le monde refuse ce passage.",
|
||||
"sanctuary.providence.sga.ineligible.1": "le monde refuse ce passage.",
|
||||
"sanctuary.providence.readable.cooldown.0": "Le temps doit accomplir son œuvre.",
|
||||
"sanctuary.providence.sga.cooldown.0": "le temps doit accomplir son oeuvre.",
|
||||
"sanctuary.providence.readable.cooldown.1": "Attends le prochain signe.",
|
||||
"sanctuary.providence.sga.cooldown.1": "attends le prochain signe.",
|
||||
"sanctuary.providence.readable.silent.0": "Providence demeure silencieuse.",
|
||||
"sanctuary.providence.sga.silent.0": "providence demeure silencieuse.",
|
||||
"sanctuary.providence.readable.silent.1": "Le voile ne frémit pas.",
|
||||
"sanctuary.providence.sga.silent.1": "le voile ne fremit pas.",
|
||||
"sanctuary.providence.readable.execution_failed.0": "Le passage s’est refermé.",
|
||||
"sanctuary.providence.sga.execution_failed.0": "le passage s'est referme.",
|
||||
"sanctuary.providence.readable.execution_failed.1": "Le don ne peut prendre forme.",
|
||||
"sanctuary.providence.sga.execution_failed.1": "le don ne peut prendre forme.",
|
||||
"sanctuary.providence.readable.heard.0": "Ta prière est entendue.",
|
||||
"sanctuary.providence.sga.heard.0": "ta priere est entendue.",
|
||||
"sanctuary.providence.readable.heard.1": "Une voix traverse le voile.",
|
||||
"sanctuary.providence.sga.heard.1": "une voix traverse le voile.",
|
||||
"sanctuary.providence.readable.help.0": "Prie avec /pray suivi de tes mots (512 caractères maximum). Lecture claire : /providence readable true.",
|
||||
"sanctuary.providence.sga.help.0": "prie avec /pray suivi de tes mots (512 caracteres maximum). lecture claire : /providence readable true.",
|
||||
"sanctuary.providence.readable.help.1": "Confie tes mots à /pray. Lecture claire : /providence readable true.",
|
||||
"sanctuary.providence.sga.help.1": "confie tes mots a /pray. lecture claire : /providence readable true.",
|
||||
"sanctuary.providence.readable.accessibility.0": "Providence parle désormais en lettres claires.",
|
||||
"sanctuary.providence.sga.accessibility.0": "le voile retrouve ses signes",
|
||||
"sanctuary.providence.readable.accessibility.1": "Tes yeux peuvent lire le voile.",
|
||||
"sanctuary.providence.sga.accessibility.1": "le voile retrouve ses signes",
|
||||
"sanctuary.providence.readable.reloaded.0": "Providence : dictionnaire et règles rechargés.",
|
||||
"sanctuary.providence.sga.reloaded.0": "providence : dictionnaire et regles recharges.",
|
||||
"sanctuary.providence.readable.reloaded.1": "Providence : dictionnaire et règles rechargés.",
|
||||
"sanctuary.providence.sga.reloaded.1": "providence : dictionnaire et regles recharges.",
|
||||
"sanctuary.providence.readable.invalid.0": "Providence : rechargement refusé. Consultez le journal serveur.",
|
||||
"sanctuary.providence.sga.invalid.0": "providence : rechargement refuse. consultez le journal serveur.",
|
||||
"sanctuary.providence.readable.invalid.1": "Providence : rechargement refusé. Consultez le journal serveur.",
|
||||
"sanctuary.providence.sga.invalid.1": "providence : rechargement refuse. consultez le journal serveur.",
|
||||
"sanctuary.grave.recovery_unavailable": "Le registre de récupération est indisponible. Cette tombe reste verrouillée.",
|
||||
"sanctuary.providence.readable.deferred.0": "Le don mûrit derrière le voile.",
|
||||
"sanctuary.providence.sga.deferred.0": "le don murit derriere le voile.",
|
||||
"sanctuary.providence.readable.deferred.1": "Le miracle attend son heure.",
|
||||
"sanctuary.providence.sga.deferred.1": "le miracle attend son heure.",
|
||||
"sanctuary.providence.readable.alternative.0": "Un autre don répond à ta voix.",
|
||||
"sanctuary.providence.sga.alternative.0": "un autre don repond a ta voix.",
|
||||
"sanctuary.providence.readable.alternative.1": "Ton aide prend une autre forme.",
|
||||
"sanctuary.providence.sga.alternative.1": "ton aide prend une autre forme."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"REQUEST": [
|
||||
"fais pleuvoir",
|
||||
"faites pleuvoir",
|
||||
"declenche",
|
||||
"declenchez",
|
||||
"invoque",
|
||||
"invoquez",
|
||||
"rejoins",
|
||||
"rejoignez"
|
||||
],
|
||||
"RAIN": [
|
||||
"pleuvoir"
|
||||
],
|
||||
"TRAVEL": [
|
||||
"rejoindre",
|
||||
"rejoins",
|
||||
"rejoignez"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
{
|
||||
"REQUEST": [
|
||||
"seigneur",
|
||||
"serveur",
|
||||
"providence",
|
||||
"pitie",
|
||||
"s il vous plait",
|
||||
"svp",
|
||||
"stp",
|
||||
"je veux",
|
||||
"je voudrais",
|
||||
"j aimerais",
|
||||
"pourriez vous",
|
||||
"pouvez vous",
|
||||
"arrete",
|
||||
"arretez",
|
||||
"cesse",
|
||||
"cessez",
|
||||
"stop",
|
||||
"faites",
|
||||
"fais",
|
||||
"ramene",
|
||||
"ramenez",
|
||||
"renvoie",
|
||||
"renvoyez",
|
||||
"teleporte",
|
||||
"teleportez",
|
||||
"tp moi",
|
||||
"rends",
|
||||
"rendez",
|
||||
"redonne",
|
||||
"redonnez",
|
||||
"aide",
|
||||
"aidez",
|
||||
"sauve",
|
||||
"sauvez",
|
||||
"delivre",
|
||||
"delivrez",
|
||||
"please",
|
||||
"lord",
|
||||
"i want",
|
||||
"i wish",
|
||||
"could you",
|
||||
"can you",
|
||||
"bring",
|
||||
"return",
|
||||
"give",
|
||||
"help",
|
||||
"rescue",
|
||||
"clear",
|
||||
"o seigneur",
|
||||
"o serveur",
|
||||
"o providence",
|
||||
"oh lord",
|
||||
"fais pleuvoir",
|
||||
"faites pleuvoir",
|
||||
"declenche",
|
||||
"declenchez",
|
||||
"invoque",
|
||||
"invoquez",
|
||||
"rejoins",
|
||||
"rejoignez"
|
||||
],
|
||||
"NEGATION": [
|
||||
"ne",
|
||||
"n",
|
||||
"pas",
|
||||
"jamais",
|
||||
"sans",
|
||||
"non",
|
||||
"aucun",
|
||||
"not",
|
||||
"never",
|
||||
"no",
|
||||
"don t",
|
||||
"do not",
|
||||
"dont"
|
||||
],
|
||||
"HYPOTHETICAL": [
|
||||
"si",
|
||||
"if",
|
||||
"peut etre",
|
||||
"maybe",
|
||||
"imagine",
|
||||
"imaginons",
|
||||
"il a dit",
|
||||
"elle a dit",
|
||||
"he said",
|
||||
"she said",
|
||||
"citation",
|
||||
"exemple",
|
||||
"example",
|
||||
"pourquoi",
|
||||
"why",
|
||||
"est ce que",
|
||||
"a t il",
|
||||
"j aime",
|
||||
"j adore",
|
||||
"i like",
|
||||
"i love",
|
||||
"je vais",
|
||||
"il va",
|
||||
"elle va",
|
||||
"i will",
|
||||
"he will",
|
||||
"s arrete",
|
||||
"s arretent",
|
||||
"vient de",
|
||||
"a arrete",
|
||||
"stopped",
|
||||
"il demande",
|
||||
"elle demande",
|
||||
"he asks",
|
||||
"she asks",
|
||||
"s il pleut",
|
||||
"s il neige",
|
||||
"s il y a",
|
||||
"s ils",
|
||||
"s elle"
|
||||
],
|
||||
"RAIN": [
|
||||
"pluie",
|
||||
"pleut",
|
||||
"averse",
|
||||
"averses",
|
||||
"orage",
|
||||
"orages",
|
||||
"tempete",
|
||||
"rain",
|
||||
"raining",
|
||||
"storm",
|
||||
"thunder",
|
||||
"thunderstorm",
|
||||
"pleuvoir"
|
||||
],
|
||||
"SUN": [
|
||||
"soleil",
|
||||
"beau temps",
|
||||
"ciel bleu",
|
||||
"sun",
|
||||
"sunshine",
|
||||
"clear skies"
|
||||
],
|
||||
"STOP": [
|
||||
"arreter",
|
||||
"arrete",
|
||||
"arretez",
|
||||
"cesse",
|
||||
"cessez",
|
||||
"cesser",
|
||||
"stop",
|
||||
"stopper",
|
||||
"dissiper",
|
||||
"dissipe",
|
||||
"dissipez",
|
||||
"couper",
|
||||
"coupe",
|
||||
"coupez",
|
||||
"clear",
|
||||
"end"
|
||||
],
|
||||
"BRING": [
|
||||
"revenir",
|
||||
"reviens",
|
||||
"revienne",
|
||||
"retour",
|
||||
"ramene",
|
||||
"ramenez",
|
||||
"ramener",
|
||||
"faites revenir",
|
||||
"fais revenir",
|
||||
"rendre",
|
||||
"rends",
|
||||
"rendez",
|
||||
"bring",
|
||||
"return",
|
||||
"back"
|
||||
],
|
||||
"TRAVEL": [
|
||||
"ramene",
|
||||
"ramenez",
|
||||
"ramener",
|
||||
"renvoie",
|
||||
"renvoyez",
|
||||
"renvoyer",
|
||||
"teleporte",
|
||||
"teleportez",
|
||||
"teleporter",
|
||||
"tp moi",
|
||||
"retourner",
|
||||
"retourne",
|
||||
"revenir",
|
||||
"emmene",
|
||||
"emmenez",
|
||||
"bring me",
|
||||
"return me",
|
||||
"take me",
|
||||
"teleport me",
|
||||
"rejoindre",
|
||||
"rejoins",
|
||||
"rejoignez"
|
||||
],
|
||||
"DEATH": [
|
||||
"mort",
|
||||
"morte",
|
||||
"mourir",
|
||||
"crever",
|
||||
"creve",
|
||||
"decede",
|
||||
"tombe",
|
||||
"cadavre",
|
||||
"corps",
|
||||
"death",
|
||||
"died",
|
||||
"dead",
|
||||
"grave",
|
||||
"corpse"
|
||||
],
|
||||
"INVENTORY": [
|
||||
"stuff",
|
||||
"affaires",
|
||||
"items",
|
||||
"objets",
|
||||
"equipement",
|
||||
"inventaire",
|
||||
"inventory",
|
||||
"gear",
|
||||
"belongings"
|
||||
],
|
||||
"RESTORE": [
|
||||
"rendre",
|
||||
"rends",
|
||||
"rendez",
|
||||
"recuperer",
|
||||
"recupere",
|
||||
"recuperez",
|
||||
"redonner",
|
||||
"redonne",
|
||||
"redonnez",
|
||||
"restituer",
|
||||
"give",
|
||||
"recover",
|
||||
"restore"
|
||||
],
|
||||
"HELP": [
|
||||
"aide",
|
||||
"aidez",
|
||||
"aider",
|
||||
"secours",
|
||||
"sauver",
|
||||
"sauve",
|
||||
"sauvez",
|
||||
"delivrer",
|
||||
"delivre",
|
||||
"delivrez",
|
||||
"help",
|
||||
"rescue",
|
||||
"save"
|
||||
],
|
||||
"TRAPPED": [
|
||||
"coince",
|
||||
"coincee",
|
||||
"bloque",
|
||||
"bloquee",
|
||||
"enferme",
|
||||
"enfermee",
|
||||
"stuck",
|
||||
"trapped"
|
||||
],
|
||||
"DIRECT_REQUEST": [
|
||||
"aide moi",
|
||||
"aidez moi",
|
||||
"ramene moi",
|
||||
"ramenez moi",
|
||||
"renvoie moi",
|
||||
"renvoyez moi",
|
||||
"rends moi",
|
||||
"rendez moi",
|
||||
"redonne moi",
|
||||
"redonnez moi",
|
||||
"teleporte moi",
|
||||
"teleportez moi",
|
||||
"tp moi",
|
||||
"help me",
|
||||
"bring me",
|
||||
"take me",
|
||||
"return me",
|
||||
"give me",
|
||||
"teleport me"
|
||||
],
|
||||
"INVOKE": [
|
||||
"gueris",
|
||||
"guerissez",
|
||||
"soigne",
|
||||
"soignez",
|
||||
"nourris",
|
||||
"nourrissez",
|
||||
"rassasie",
|
||||
"rassasiez",
|
||||
"foudroie",
|
||||
"foudroyez",
|
||||
"frappe",
|
||||
"frappez",
|
||||
"maudis",
|
||||
"maudissez",
|
||||
"maledis",
|
||||
"venge",
|
||||
"vengez",
|
||||
"punis",
|
||||
"punissez",
|
||||
"vole",
|
||||
"volez",
|
||||
"derobe",
|
||||
"derobez",
|
||||
"desarme",
|
||||
"desarmez",
|
||||
"repare",
|
||||
"reparez",
|
||||
"heal",
|
||||
"feed",
|
||||
"strike",
|
||||
"curse",
|
||||
"punish",
|
||||
"steal",
|
||||
"disarm",
|
||||
"repair",
|
||||
"fais pleuvoir",
|
||||
"faites pleuvoir"
|
||||
],
|
||||
"HEALTH": [
|
||||
"gueris",
|
||||
"guerissez",
|
||||
"guerir",
|
||||
"guerison",
|
||||
"soigne",
|
||||
"soignez",
|
||||
"soigner",
|
||||
"sante",
|
||||
"blessures",
|
||||
"pv",
|
||||
"heal",
|
||||
"health",
|
||||
"wounds"
|
||||
],
|
||||
"FOOD": [
|
||||
"nourris",
|
||||
"nourrissez",
|
||||
"nourrir",
|
||||
"nourriture",
|
||||
"faim",
|
||||
"rassasie",
|
||||
"rassasiez",
|
||||
"rassasier",
|
||||
"feed",
|
||||
"food",
|
||||
"hunger"
|
||||
],
|
||||
"LIGHTNING": [
|
||||
"foudre",
|
||||
"eclair",
|
||||
"eclairs",
|
||||
"foudroie",
|
||||
"foudroyez",
|
||||
"foudroyer",
|
||||
"lightning",
|
||||
"thunderbolt"
|
||||
],
|
||||
"CURSE": [
|
||||
"malediction",
|
||||
"maledis",
|
||||
"maudis",
|
||||
"maudissez",
|
||||
"maudire",
|
||||
"curse",
|
||||
"hex"
|
||||
],
|
||||
"PUNISH": [
|
||||
"vengeance",
|
||||
"venge",
|
||||
"vengez",
|
||||
"venger",
|
||||
"punis",
|
||||
"punissez",
|
||||
"punition",
|
||||
"punir",
|
||||
"revenge",
|
||||
"punish"
|
||||
],
|
||||
"STEAL": [
|
||||
"vole",
|
||||
"volez",
|
||||
"voler",
|
||||
"derobe",
|
||||
"derobez",
|
||||
"derober",
|
||||
"steal",
|
||||
"rob"
|
||||
],
|
||||
"DISARM": [
|
||||
"desarme",
|
||||
"desarmez",
|
||||
"desarmer",
|
||||
"desarmement",
|
||||
"disarm"
|
||||
],
|
||||
"REPAIR": [
|
||||
"repare",
|
||||
"reparez",
|
||||
"reparer",
|
||||
"reparation",
|
||||
"repair",
|
||||
"mend"
|
||||
],
|
||||
"SPAWN": [
|
||||
"spawn",
|
||||
"point de depart",
|
||||
"point d apparition",
|
||||
"point de reapparition",
|
||||
"abri",
|
||||
"refuge"
|
||||
],
|
||||
"COMPANY": [
|
||||
"aupres",
|
||||
"pres de",
|
||||
"rejoindre",
|
||||
"rejoins",
|
||||
"rejoignez",
|
||||
"chez",
|
||||
"vers",
|
||||
"with",
|
||||
"beside",
|
||||
"to"
|
||||
],
|
||||
"SUMMON_RAIN": [
|
||||
"pleuvoir",
|
||||
"fais pleuvoir",
|
||||
"faites pleuvoir",
|
||||
"declenche",
|
||||
"declenchez",
|
||||
"declencher",
|
||||
"invoque",
|
||||
"invoquez",
|
||||
"invoke",
|
||||
"summon",
|
||||
"make it rain"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"historyEntries": 500,
|
||||
"retentionDays": 14,
|
||||
"prayerIntervalSeconds": 5,
|
||||
"minimumScore": 90,
|
||||
"miracles": [
|
||||
{
|
||||
"id": "RECOVER_ITEMS",
|
||||
"enabled": true,
|
||||
"patterns": [
|
||||
[
|
||||
[
|
||||
"REQUEST"
|
||||
],
|
||||
[
|
||||
"RESTORE"
|
||||
],
|
||||
[
|
||||
"INVENTORY"
|
||||
]
|
||||
]
|
||||
],
|
||||
"excluded": [],
|
||||
"conditions": [
|
||||
"alive",
|
||||
"recoverable_grave"
|
||||
],
|
||||
"probability": 0.15,
|
||||
"playerCooldownSeconds": 3600,
|
||||
"globalCooldownSeconds": 0,
|
||||
"clearSeconds": 600
|
||||
},
|
||||
{
|
||||
"id": "RETURN_TO_DEATH",
|
||||
"enabled": true,
|
||||
"patterns": [
|
||||
[
|
||||
[
|
||||
"REQUEST"
|
||||
],
|
||||
[
|
||||
"TRAVEL"
|
||||
],
|
||||
[
|
||||
"DEATH"
|
||||
]
|
||||
]
|
||||
],
|
||||
"excluded": [
|
||||
"INVENTORY"
|
||||
],
|
||||
"conditions": [
|
||||
"alive",
|
||||
"last_death",
|
||||
"same_dimension",
|
||||
"safe_loaded_destination"
|
||||
],
|
||||
"probability": 0.5,
|
||||
"playerCooldownSeconds": 900,
|
||||
"globalCooldownSeconds": 0,
|
||||
"clearSeconds": 600
|
||||
},
|
||||
{
|
||||
"id": "RESCUE_PLAYER",
|
||||
"enabled": true,
|
||||
"patterns": [
|
||||
[
|
||||
[
|
||||
"REQUEST"
|
||||
],
|
||||
[
|
||||
"HELP"
|
||||
],
|
||||
[
|
||||
"TRAPPED"
|
||||
]
|
||||
],
|
||||
[
|
||||
[
|
||||
"REQUEST"
|
||||
],
|
||||
[
|
||||
"TRAVEL"
|
||||
],
|
||||
[
|
||||
"SPAWN"
|
||||
]
|
||||
]
|
||||
],
|
||||
"excluded": [],
|
||||
"conditions": [
|
||||
"alive",
|
||||
"safe_loaded_destination"
|
||||
],
|
||||
"probability": 0.5,
|
||||
"playerCooldownSeconds": 900,
|
||||
"globalCooldownSeconds": 0,
|
||||
"clearSeconds": 600
|
||||
},
|
||||
{
|
||||
"id": "CLEAR_WEATHER",
|
||||
"enabled": true,
|
||||
"patterns": [
|
||||
[
|
||||
[
|
||||
"REQUEST"
|
||||
],
|
||||
[
|
||||
"STOP"
|
||||
],
|
||||
[
|
||||
"RAIN"
|
||||
]
|
||||
],
|
||||
[
|
||||
[
|
||||
"REQUEST"
|
||||
],
|
||||
[
|
||||
"BRING"
|
||||
],
|
||||
[
|
||||
"SUN"
|
||||
]
|
||||
]
|
||||
],
|
||||
"excluded": [],
|
||||
"conditions": [
|
||||
"alive",
|
||||
"overworld",
|
||||
"weather_active"
|
||||
],
|
||||
"probability": 0.35,
|
||||
"playerCooldownSeconds": 1800,
|
||||
"globalCooldownSeconds": 600,
|
||||
"clearSeconds": 600
|
||||
},
|
||||
{
|
||||
"id": "RAIN",
|
||||
"enabled": true,
|
||||
"patterns": [
|
||||
[
|
||||
[
|
||||
"REQUEST"
|
||||
],
|
||||
[
|
||||
"SUMMON_RAIN"
|
||||
],
|
||||
[
|
||||
"RAIN"
|
||||
]
|
||||
]
|
||||
],
|
||||
"excluded": [
|
||||
"STOP",
|
||||
"SUN"
|
||||
],
|
||||
"conditions": [
|
||||
"alive",
|
||||
"overworld",
|
||||
"not_raining"
|
||||
],
|
||||
"probability": 0.35,
|
||||
"playerCooldownSeconds": 1800,
|
||||
"globalCooldownSeconds": 600,
|
||||
"clearSeconds": 600
|
||||
},
|
||||
{
|
||||
"id": "HEAL",
|
||||
"enabled": true,
|
||||
"patterns": [
|
||||
[
|
||||
[
|
||||
"REQUEST"
|
||||
],
|
||||
[
|
||||
"HEALTH"
|
||||
]
|
||||
]
|
||||
],
|
||||
"excluded": [],
|
||||
"conditions": [
|
||||
"alive",
|
||||
"injured"
|
||||
],
|
||||
"probability": 0.5,
|
||||
"playerCooldownSeconds": 300,
|
||||
"globalCooldownSeconds": 0,
|
||||
"clearSeconds": 600
|
||||
},
|
||||
{
|
||||
"id": "FEED",
|
||||
"enabled": true,
|
||||
"patterns": [
|
||||
[
|
||||
[
|
||||
"REQUEST"
|
||||
],
|
||||
[
|
||||
"FOOD"
|
||||
]
|
||||
]
|
||||
],
|
||||
"excluded": [],
|
||||
"conditions": [
|
||||
"alive",
|
||||
"hungry"
|
||||
],
|
||||
"probability": 0.5,
|
||||
"playerCooldownSeconds": 300,
|
||||
"globalCooldownSeconds": 0,
|
||||
"clearSeconds": 600
|
||||
},
|
||||
{
|
||||
"id": "LIGHTNING",
|
||||
"enabled": true,
|
||||
"patterns": [
|
||||
[
|
||||
[
|
||||
"REQUEST"
|
||||
],
|
||||
[
|
||||
"LIGHTNING"
|
||||
]
|
||||
]
|
||||
],
|
||||
"excluded": [],
|
||||
"conditions": [
|
||||
"alive",
|
||||
"target_online"
|
||||
],
|
||||
"probability": 0.25,
|
||||
"playerCooldownSeconds": 900,
|
||||
"globalCooldownSeconds": 0,
|
||||
"clearSeconds": 600
|
||||
},
|
||||
{
|
||||
"id": "CURSE",
|
||||
"enabled": true,
|
||||
"patterns": [
|
||||
[
|
||||
[
|
||||
"REQUEST"
|
||||
],
|
||||
[
|
||||
"CURSE"
|
||||
]
|
||||
]
|
||||
],
|
||||
"excluded": [],
|
||||
"conditions": [
|
||||
"alive",
|
||||
"target_online"
|
||||
],
|
||||
"probability": 0.25,
|
||||
"playerCooldownSeconds": 900,
|
||||
"globalCooldownSeconds": 0,
|
||||
"clearSeconds": 600
|
||||
},
|
||||
{
|
||||
"id": "VENGEANCE",
|
||||
"enabled": true,
|
||||
"patterns": [
|
||||
[
|
||||
[
|
||||
"REQUEST"
|
||||
],
|
||||
[
|
||||
"PUNISH"
|
||||
]
|
||||
]
|
||||
],
|
||||
"excluded": [],
|
||||
"conditions": [
|
||||
"alive",
|
||||
"target_online"
|
||||
],
|
||||
"probability": 0.25,
|
||||
"playerCooldownSeconds": 900,
|
||||
"globalCooldownSeconds": 0,
|
||||
"clearSeconds": 600
|
||||
},
|
||||
{
|
||||
"id": "STEAL",
|
||||
"enabled": true,
|
||||
"patterns": [
|
||||
[
|
||||
[
|
||||
"REQUEST"
|
||||
],
|
||||
[
|
||||
"STEAL"
|
||||
]
|
||||
]
|
||||
],
|
||||
"excluded": [],
|
||||
"conditions": [
|
||||
"alive",
|
||||
"target_online",
|
||||
"target_distinct",
|
||||
"stealable_item"
|
||||
],
|
||||
"probability": 0.25,
|
||||
"playerCooldownSeconds": 900,
|
||||
"globalCooldownSeconds": 0,
|
||||
"clearSeconds": 600
|
||||
},
|
||||
{
|
||||
"id": "DISARM",
|
||||
"enabled": true,
|
||||
"patterns": [
|
||||
[
|
||||
[
|
||||
"REQUEST"
|
||||
],
|
||||
[
|
||||
"DISARM"
|
||||
]
|
||||
]
|
||||
],
|
||||
"excluded": [],
|
||||
"conditions": [
|
||||
"alive",
|
||||
"target_online",
|
||||
"held_item"
|
||||
],
|
||||
"probability": 0.35,
|
||||
"playerCooldownSeconds": 600,
|
||||
"globalCooldownSeconds": 0,
|
||||
"clearSeconds": 600
|
||||
},
|
||||
{
|
||||
"id": "TELEPORT_PLAYER",
|
||||
"enabled": true,
|
||||
"patterns": [
|
||||
[
|
||||
[
|
||||
"REQUEST"
|
||||
],
|
||||
[
|
||||
"TRAVEL"
|
||||
],
|
||||
[
|
||||
"COMPANY"
|
||||
]
|
||||
]
|
||||
],
|
||||
"excluded": [
|
||||
"DEATH",
|
||||
"SPAWN"
|
||||
],
|
||||
"conditions": [
|
||||
"alive",
|
||||
"target_online",
|
||||
"target_distinct",
|
||||
"safe_loaded_destination"
|
||||
],
|
||||
"probability": 0.5,
|
||||
"playerCooldownSeconds": 300,
|
||||
"globalCooldownSeconds": 0,
|
||||
"clearSeconds": 600
|
||||
},
|
||||
{
|
||||
"id": "REPAIR_ITEM",
|
||||
"enabled": true,
|
||||
"patterns": [
|
||||
[
|
||||
[
|
||||
"REQUEST"
|
||||
],
|
||||
[
|
||||
"REPAIR"
|
||||
]
|
||||
]
|
||||
],
|
||||
"excluded": [],
|
||||
"conditions": [
|
||||
"alive",
|
||||
"damaged_held_item"
|
||||
],
|
||||
"probability": 0.5,
|
||||
"playerCooldownSeconds": 300,
|
||||
"globalCooldownSeconds": 0,
|
||||
"clearSeconds": 600
|
||||
}
|
||||
],
|
||||
"responseDelaySeconds": 5
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"deferredChance": 0.2,
|
||||
"alternativeChance": 0.25,
|
||||
"silenceChance": 0.15,
|
||||
"deferredSeconds": 30,
|
||||
"alternatives": {
|
||||
"RECOVER_ITEMS": [
|
||||
"RETURN_TO_DEATH",
|
||||
"RESCUE_PLAYER"
|
||||
],
|
||||
"RETURN_TO_DEATH": [
|
||||
"RESCUE_PLAYER"
|
||||
],
|
||||
"RESCUE_PLAYER": [
|
||||
"HEAL",
|
||||
"FEED"
|
||||
],
|
||||
"CLEAR_WEATHER": [
|
||||
"HEAL",
|
||||
"FEED"
|
||||
],
|
||||
"RAIN": [
|
||||
"HEAL",
|
||||
"FEED"
|
||||
],
|
||||
"HEAL": [
|
||||
"FEED"
|
||||
],
|
||||
"FEED": [
|
||||
"HEAL"
|
||||
],
|
||||
"LIGHTNING": [
|
||||
"HEAL",
|
||||
"FEED"
|
||||
],
|
||||
"CURSE": [
|
||||
"HEAL",
|
||||
"FEED"
|
||||
],
|
||||
"VENGEANCE": [
|
||||
"HEAL",
|
||||
"FEED"
|
||||
],
|
||||
"STEAL": [
|
||||
"HEAL",
|
||||
"FEED"
|
||||
],
|
||||
"DISARM": [
|
||||
"HEAL",
|
||||
"FEED"
|
||||
],
|
||||
"TELEPORT_PLAYER": [
|
||||
"HEAL",
|
||||
"FEED"
|
||||
],
|
||||
"REPAIR_ITEM": [
|
||||
"HEAL",
|
||||
"FEED"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import com.google.gson.*;
|
||||
import fr.koka.sanctuary.progression.AtomicJson;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** Pure executable contracts: no Minecraft bootstrap, server, network or graphics. */
|
||||
public final class Providence265Smoke {
|
||||
private static int checks;
|
||||
public static void main(String[] args) throws Exception {
|
||||
var directory=Files.createTempDirectory("providence265-");
|
||||
try {
|
||||
var registry=MiracleRegistry.load(directory);
|
||||
var interpreter=new PrayerInterpreter(registry);
|
||||
var catalogue=Map.ofEntries(
|
||||
Map.entry("ramenez moi au spawn",MiracleRegistry.Id.RESCUE_PLAYER),Map.entry("faites pleuvoir",MiracleRegistry.Id.RAIN),
|
||||
Map.entry("gueris moi",MiracleRegistry.Id.HEAL),Map.entry("nourris moi",MiracleRegistry.Id.FEED),
|
||||
Map.entry("foudroie joueur",MiracleRegistry.Id.LIGHTNING),Map.entry("maudis joueur",MiracleRegistry.Id.CURSE),
|
||||
Map.entry("punis joueur",MiracleRegistry.Id.VENGEANCE),Map.entry("vole un objet a joueur",MiracleRegistry.Id.STEAL),
|
||||
Map.entry("desarme joueur",MiracleRegistry.Id.DISARM),Map.entry("teleporte moi pres de joueur",MiracleRegistry.Id.TELEPORT_PLAYER),
|
||||
Map.entry("repare mon outil",MiracleRegistry.Id.REPAIR_ITEM),Map.entry("please make it rain",MiracleRegistry.Id.RAIN),
|
||||
Map.entry("heal me",MiracleRegistry.Id.HEAL),Map.entry("feed me",MiracleRegistry.Id.FEED),
|
||||
Map.entry("curse player",MiracleRegistry.Id.CURSE),Map.entry("please teleport me to player",MiracleRegistry.Id.TELEPORT_PLAYER));
|
||||
require(registry.miracles().size()==14,"Fourteen distinct miracle definitions are available");
|
||||
for(var example:catalogue.entrySet()) {
|
||||
intent(interpreter,example.getKey(),example.getValue());
|
||||
intent(interpreter,"ne "+example.getKey()+" pas",null);
|
||||
}
|
||||
for(String word:List.of("gueris","nourris","foudroie","vole","repare","foudre","vengeance"))intent(interpreter,word,null);
|
||||
require(interpreter.interpret("gueris moi et nourris moi").ambiguities().size()==2,"Two new prayers remain ambiguous");
|
||||
var alice=UUID.randomUUID();var bob=UUID.randomUUID();
|
||||
var names=List.of(new PrayerTargets.Name(alice,Set.of("Alice","Pluie")),new PrayerTargets.Name(bob,Set.of("Bob")));
|
||||
require(PrayerTargets.select("foudroie @Alice",names).player().equals(alice),"Explicit account targets bind exact UUIDs");
|
||||
require(PrayerTargets.select("maudis Alice",names).player().equals(alice),"Exact whole names also resolve");
|
||||
require(PrayerTargets.select("maudis Malice",names).player()==null,"Partial names never select another player");
|
||||
require(PrayerTargets.select("foudroie @Nobody",names).player()==null,"Unknown names never fall back to a random player");
|
||||
require(PrayerTargets.select("foudroie @Alice et @Bob",names).ambiguous(),"Multiple named players are ambiguous");
|
||||
require(PrayerTargets.select("foudroie @"+"A".repeat(33),List.of(new PrayerTargets.Name(alice,Set.of("A".repeat(32))))).player()==null,"Oversized mentions never select the prefix of a different name");
|
||||
var masked=PrayerTargets.select("foudroie @Pluie",names);
|
||||
intent(interpreter,masked.prayer(),MiracleRegistry.Id.LIGHTNING);
|
||||
var duplicates=List.of(new PrayerTargets.Name(alice,Set.of("Twin")),new PrayerTargets.Name(bob,Set.of("Twin")));
|
||||
require(PrayerTargets.select("maudis Twin",duplicates).ambiguous(),"Shared plain-name aliases never choose the first player silently");
|
||||
var claimsPath=directory.resolve("claims/recoveries.json");var claimId=UUID.randomUUID().toString();
|
||||
var claims=new RecoveryClaims(claimsPath);claims.claim(List.of(claimId),alice);
|
||||
require(new RecoveryClaims(claimsPath).claimed(claimId),"Consumed grave identity is durable across reload");
|
||||
rejects(()->claims.claim(List.of(claimId),bob),"The same grave cannot be claimed twice");
|
||||
var staleClaims=new RecoveryClaims(claimsPath);claims.claim(List.of(UUID.randomUUID().toString()),alice);
|
||||
rejects(()->staleClaims.claim(List.of(UUID.randomUUID().toString()),bob),"Conflicting recovery writers fail before a grant");
|
||||
Files.writeString(claimsPath,"{corrupt-seal");
|
||||
rejects(()->new RecoveryClaims(claimsPath),"Corrupt recovery seals are never reset");
|
||||
require(Files.readString(claimsPath).equals("{corrupt-seal"),"Corrupt recovery evidence is preserved");
|
||||
for (String text:List.of("Ô seigneur, j'en ai marre de cet orage, faites revenir le soleil !",
|
||||
"Ô serveur, faites revenir le soleil", "Pitié arrêtez la pluie", "SVP dissipez cet orage",
|
||||
"Fais revenir le beau temps", "Please stop the storm", "Lord bring back the sun"))
|
||||
intent(interpreter,text,MiracleRegistry.Id.CLEAR_WEATHER);
|
||||
for (String text:List.of("Pitié, ramenez-moi à l'endroit où je suis mort", "TP moi a ma tombe stp",
|
||||
"Seigneur téléportez moi près de mon cadavre", "Please take me to my grave"))
|
||||
intent(interpreter,text,MiracleRegistry.Id.RETURN_TO_DEATH);
|
||||
intent(interpreter,"Seigneur, rendez-moi mon stuff, je suis mort !",MiracleRegistry.Id.RECOVER_ITEMS);
|
||||
intent(interpreter,"Je suis coincé dans une grotte, aidez-moi",MiracleRegistry.Id.RESCUE_PLAYER);
|
||||
for (String text:List.of("J'aime cette pluie, ne l'arrêtez pas", "Ne ramenez pas le soleil",
|
||||
"N'arrêtez jamais cet orage", "Please do not stop the rain", "pluie", "le soleil et la mort",
|
||||
"La pluie s'arrête", "Seigneur je vais arrêter la pluie", "Si vous arrêtez la pluie",
|
||||
"Il a dit : arrêtez la pluie", "rendez moi compte", "Je parle de mort", "le soleil revient",
|
||||
"ramenez ma maison", "ramenez mon stuff je suis mort", "Please stop the train",
|
||||
"S'il pleut, arrêtez", "Le serveur arrête la pluie", "La pluie cesse", "Le serveur fait revenir le soleil", "arrêtez ! la pluie", "je veux du soleil")) intent(interpreter,text,null);
|
||||
var ambiguous=interpreter.interpret("Arrêtez la pluie et ramenez moi à ma tombe");
|
||||
require(ambiguous.intention()==null && ambiguous.ambiguities().size()==2,"Two complete intentions are ambiguous");
|
||||
require(interpreter.interpret("Pitié arrêtez la pluie").equals(interpreter.interpret("Pitié arrêtez la pluie")),"Deterministic scores and concepts");
|
||||
require(interpreter.interpret("pluie").scores().get("CLEAR_WEATHER")<registry.minimumScore(),"Isolated noun stays below threshold");
|
||||
require(interpreter.interpret("Ne ramenez pas le soleil").negated(),"Negation is retained in evidence");
|
||||
var concepts=JsonParser.parseString(Files.readString(directory.resolve("concepts.json"))).getAsJsonObject();
|
||||
concepts.getAsJsonArray("RAIN").add("drache"); Files.writeString(directory.resolve("concepts.json"),new Gson().toJson(concepts));
|
||||
intent(new PrayerInterpreter(MiracleRegistry.load(directory)),"Pitié cessez cette drache",MiracleRegistry.Id.CLEAR_WEATHER);
|
||||
|
||||
var config=JsonParser.parseString(Files.readString(directory.resolve("miracles.json"))).getAsJsonObject();
|
||||
config.remove("responseDelaySeconds");
|
||||
var legacy=new Gson().toJson(config);Files.writeString(directory.resolve("miracles.json"),legacy);
|
||||
require(MiracleRegistry.load(directory).responseDelaySeconds()==5,"Legacy configuration defaults to a five-second reply delay");
|
||||
require(Files.readString(directory.resolve("miracles.json")).equals(legacy),"Legacy settings are never rewritten");
|
||||
for(double delay:new double[]{0,61,1.5}) {
|
||||
config.addProperty("responseDelaySeconds",delay);
|
||||
rejects(() -> MiracleRegistry.parse(concepts,config),"Invalid response delay rejected");
|
||||
}
|
||||
config.addProperty("responseDelaySeconds",5);
|
||||
var oldDirectory=directory.resolve("old-four");Files.createDirectories(oldDirectory);
|
||||
var oldConfig=config.deepCopy();var oldEntries=new JsonArray();
|
||||
for(var entry:oldConfig.getAsJsonArray("miracles")) {
|
||||
var o=entry.getAsJsonObject();String id=o.get("id").getAsString();
|
||||
if(!Set.of("RECOVER_ITEMS","RETURN_TO_DEATH","RESCUE_PLAYER","CLEAR_WEATHER").contains(id))continue;
|
||||
if(id.equals("RECOVER_ITEMS")||id.equals("RESCUE_PLAYER")) {
|
||||
o.add("conditions",JsonParser.parseString("[\"safety_unvalidated\"]"));o.addProperty("enabled",false);o.addProperty("probability",0);
|
||||
}
|
||||
if(id.equals("RETURN_TO_DEATH"))o.addProperty("enabled",false);
|
||||
if(id.equals("CLEAR_WEATHER")){o.addProperty("probability",.12);o.addProperty("playerCooldownSeconds",777);}
|
||||
oldEntries.add(o);
|
||||
}
|
||||
oldConfig.add("miracles",oldEntries);var oldConcepts=concepts.deepCopy();
|
||||
for(String key:List.of("INVOKE","HEALTH","FOOD","LIGHTNING","CURSE","PUNISH","STEAL","DISARM","REPAIR","SPAWN","COMPANY","SUMMON_RAIN"))oldConcepts.remove(key);
|
||||
for(String value:List.of("fais pleuvoir","faites pleuvoir","declenche","declenchez","invoque","invoquez","rejoins","rejoignez"))oldConcepts.getAsJsonArray("REQUEST").remove(new JsonPrimitive(value));
|
||||
oldConcepts.getAsJsonArray("RAIN").remove(new JsonPrimitive("pleuvoir"));
|
||||
for(String value:List.of("rejoindre","rejoins","rejoignez"))oldConcepts.getAsJsonArray("TRAVEL").remove(new JsonPrimitive(value));
|
||||
String oldJson=oldConfig.toString(),oldVocabulary=oldConcepts.toString();
|
||||
Files.writeString(oldDirectory.resolve("miracles.json"),oldJson);Files.writeString(oldDirectory.resolve("concepts.json"),oldVocabulary);
|
||||
var upgraded=MiracleRegistry.load(oldDirectory);
|
||||
require(upgraded.miracles().size()==14,"Existing four-prayer configurations gain missing definitions");
|
||||
require(upgraded.definition(MiracleRegistry.Id.RECOVER_ITEMS).enabled()&&upgraded.definition(MiracleRegistry.Id.RESCUE_PLAYER).enabled(),"Previously unimplemented placeholders now use validated defaults");
|
||||
require(!upgraded.definition(MiracleRegistry.Id.RETURN_TO_DEATH).enabled(),"Validated administrator-disabled definitions stay disabled");
|
||||
require(upgraded.definition(MiracleRegistry.Id.CLEAR_WEATHER).probability()==.12&&upgraded.definition(MiracleRegistry.Id.CLEAR_WEATHER).playerCooldownSeconds()==777,"Existing chance and cooldown choices survive upgrade");
|
||||
require(Files.readString(oldDirectory.resolve("miracles.json")).equals(oldJson)&&Files.readString(oldDirectory.resolve("concepts.json")).equals(oldVocabulary),"Configuration upgrades never rewrite edited source files");
|
||||
intent(new PrayerInterpreter(upgraded),"gueris moi",MiracleRegistry.Id.HEAL);
|
||||
intent(new PrayerInterpreter(upgraded),"faites pleuvoir",MiracleRegistry.Id.RAIN);
|
||||
intent(new PrayerInterpreter(upgraded),"declenche la pluie",MiracleRegistry.Id.RAIN);
|
||||
intent(new PrayerInterpreter(upgraded),"rejoins joueur",MiracleRegistry.Id.TELEPORT_PLAYER);
|
||||
var queue=new PrayerQueue<String>();var first=UUID.randomUUID();var second=UUID.randomUUID();
|
||||
require(queue.offer(first,"connection A","first prayer",100,5),"First prayer is queued");
|
||||
require(!queue.offer(first,"connection A","replacement prayer",150,1),"Spam cannot replace a waiting prayer or shorten its delay");
|
||||
require(queue.ready(199).isEmpty(),"No response can be due before the deadline");
|
||||
var due=queue.ready(200);
|
||||
require(due.size()==1 && due.getFirst().prayer().equals("first prayer"),"Original request becomes due at its unchanged deadline");
|
||||
require(queue.ready(201).isEmpty(),"A response is dispatched only once");
|
||||
queue.offer(first,"connection A","one",300,1);queue.offer(second,"connection B","two",300,2);
|
||||
require(queue.ready(320).getFirst().player().equals(first),"Players wait independently");
|
||||
queue.cancel(second);require(queue.ready(400).isEmpty(),"Disconnect cancellation prevents later dispatch");
|
||||
require(queue.offer(second,"new connection","fresh request",500,1),"A new connection may start a fresh wait after cancellation");
|
||||
require(queue.ready(520).getFirst().actor().equals("new connection"),"Cancelled requests never retain the previous player object");
|
||||
boolean capacityAccepted=true;
|
||||
for(int i=0;i<1024;i++)capacityAccepted&=queue.offer(UUID.randomUUID(),"actor","bounded",600,1);
|
||||
require(capacityAccepted,"Bounded queue accepts available capacity");
|
||||
require(!queue.offer(UUID.randomUUID(),"actor","overflow",600,1),"Waiting prayers have a global capacity limit");
|
||||
var disabledConfig=config.deepCopy();
|
||||
disabledConfig.getAsJsonArray("miracles").get(0).getAsJsonObject().addProperty("enabled",false);
|
||||
var disabledRegistry=MiracleRegistry.parse(concepts,disabledConfig);
|
||||
var unsafeConfig=config.deepCopy();
|
||||
unsafeConfig.getAsJsonArray("miracles").get(0).getAsJsonObject().add("conditions",JsonParser.parseString("[\"alive\"]"));
|
||||
rejects(() -> MiracleRegistry.parse(concepts,unsafeConfig),"Recovery safety conditions cannot be removed");
|
||||
config.addProperty("prayerIntervalSeconds",1.5);
|
||||
rejects(() -> MiracleRegistry.parse(concepts,config),"Fractional delays are invalid");
|
||||
|
||||
var path=directory.resolve("world/providence.json"); var history=new PrayerHistory(path);
|
||||
var engine=new ProvidenceEngine(registry,history); UUID player=UUID.randomUUID();
|
||||
var prayer="Pitié arrêtez la pluie"; var interpretation=interpreter.interpret(prayer);
|
||||
var eligible=Map.of("alive",true,"overworld",true,"weather_active",true);
|
||||
long now=1800000000000L; var executions=new AtomicInteger();
|
||||
var refused=engine.pray(player,prayer,interpretation,eligible,now,()->.99,()->{executions.incrementAndGet();return true;});
|
||||
require(refused.verdict()==ProvidenceEngine.Verdict.REFUSED && executions.get()==0,"Probability refusal never executes");
|
||||
require(history.state().entries().getLast().conditions().equals(eligible),"Examined conditions persisted");
|
||||
history.readable(player,true);
|
||||
history=new PrayerHistory(path); engine=new ProvidenceEngine(registry,history);
|
||||
require(history.readable(player),"Accessibility survives restart");
|
||||
var cooldown=engine.pray(player,prayer,interpretation,eligible,now+6000,()->0,()->{executions.incrementAndGet();return true;});
|
||||
require(cooldown.verdict()==ProvidenceEngine.Verdict.COOLDOWN && executions.get()==0,"Refusal cooldown persists across restart");
|
||||
var global=engine.pray(UUID.randomUUID(),prayer,interpretation,eligible,now+6000,()->0,()->true);
|
||||
require(global.verdict()==ProvidenceEngine.Verdict.COOLDOWN,"Weather cooldown applies to other players");
|
||||
long later=now+1800001;
|
||||
var granted=engine.pray(player,prayer,interpretation,eligible,later,()->0,()->{executions.incrementAndGet();return true;});
|
||||
require(granted.executed() && executions.get()==1 && history.state().entries().getLast().executed(),"Granted effect recorded once");
|
||||
var spam=engine.pray(player,prayer,interpretation,eligible,later+1,()->0,()->true);
|
||||
require(spam.verdict()==ProvidenceEngine.Verdict.SILENT,"Fast prayers stay silent");
|
||||
var ineligible=engine.pray(UUID.randomUUID(),prayer,interpretation,Map.of("alive",false),later+6000,()->0,()->true);
|
||||
require(ineligible.verdict()==ProvidenceEngine.Verdict.INELIGIBLE,"Missing and failed conditions refuse");
|
||||
var disabled=new ProvidenceEngine(disabledRegistry,history).pray(UUID.randomUUID(),"rendez mon stuff",interpreter.interpret("rendez mon stuff"),Map.of(),later+6000,()->0,()->true);
|
||||
require(disabled.verdict()==ProvidenceEngine.Verdict.DISABLED,"Explicitly disabled miracles stay disabled");
|
||||
var unclear=engine.pray(UUID.randomUUID(),"bonjour",interpreter.interpret("bonjour"),Map.of(),later+6000,()->0,()->true);
|
||||
require(unclear.verdict()==ProvidenceEngine.Verdict.INCOMPREHENSIBLE,"Unknown prayers are journalled without execution");
|
||||
var amb=engine.pray(UUID.randomUUID(),"two paths",ambiguous,Map.of(),later+6000,()->0,()->true);
|
||||
require(amb.verdict()==ProvidenceEngine.Verdict.AMBIGUOUS,"Ambiguous prayers never execute");
|
||||
|
||||
var newPlayer=UUID.randomUUID(); var activeEngine=engine;
|
||||
rejects(() -> activeEngine.pray(newPlayer,prayer,interpretation,eligible,later+1800001,()->0,()->{throw new IllegalStateException("simulated crash");}),"Execution failure propagates");
|
||||
var restarted=new PrayerHistory(path);
|
||||
require(restarted.state().entries().getLast().decision().equals("PENDING") && !restarted.state().entries().getLast().executed(),"Interrupted effect never claims success or replays");
|
||||
require(new ProvidenceEngine(registry,restarted).pray(newPlayer,prayer,interpretation,eligible,later+1806001,()->0,()->true).verdict()==ProvidenceEngine.Verdict.COOLDOWN,"Interrupted attempt keeps reservation");
|
||||
restarted.prune(now+20*86400000L,registry);
|
||||
require(restarted.state().entries().isEmpty() && restarted.state().cooldowns().isEmpty(),"Retention and expired cooldowns are pruned");
|
||||
var boundedConfig=new MiracleRegistry(registry.dictionary(),registry.miracles(),2,14,5,90,registry.responseDelaySeconds());
|
||||
var bounded=new PrayerHistory(directory.resolve("world/bounded.json"));
|
||||
for(int i=0;i<3;i++) new ProvidenceEngine(boundedConfig,bounded).pray(UUID.randomUUID(),"bonjour "+i,interpreter.interpret("bonjour"),Map.of(),now+i,()->0,()->true);
|
||||
require(bounded.state().entries().size()==2 && bounded.state().entries().getFirst().prayer().equals("bonjour 1"),"Entry cap keeps only the newest prayers");
|
||||
var privateConfig=new MiracleRegistry(registry.dictionary(),registry.miracles(),0,14,5,90,registry.responseDelaySeconds());
|
||||
var privatePath=directory.resolve("world/private.json");var privateHistory=new PrayerHistory(privatePath);
|
||||
var privatePlayer=UUID.randomUUID();
|
||||
require(new ProvidenceEngine(privateConfig,privateHistory).pray(privatePlayer,prayer,interpretation,eligible,now,()->0,()->true).executed(),"Disabling text history still allows miracles");
|
||||
require(privateHistory.state().entries().isEmpty() && !Files.readString(privatePath).contains(prayer),"Zero retention records no prayer text");
|
||||
require(!new PrayerHistory(privatePath).state().cooldowns().isEmpty(),"Zero text retention preserves cooldowns");
|
||||
require(!new PrayerHistory(directory.resolve("other-world/state.json")).readable(player),"World histories are isolated");
|
||||
rejects(() -> interpreter.interpret("a".repeat(513)),"Oversized prayer rejected");
|
||||
var external=new PrayerHistory(path); Files.writeString(path,"{external-change");
|
||||
rejects(() -> external.readable(player,false),"External replacement refuses writes");
|
||||
require(Files.readString(path).equals("{external-change"),"External data preserved");
|
||||
rejects(() -> new PrayerHistory(path),"Corrupt state refuses initialization");
|
||||
require(Files.readString(path).equals("{external-change"),"Corrupt state never reset");
|
||||
System.out.println("PROVIDENCE265_PURE_PASS checks="+checks);
|
||||
} finally {
|
||||
try(var files=Files.walk(directory)) { for(var file:files.sorted(Comparator.reverseOrder()).toList()) Files.delete(file); }
|
||||
}
|
||||
}
|
||||
private static void intent(PrayerInterpreter interpreter,String text,MiracleRegistry.Id expected) {
|
||||
require(interpreter.interpret(text).intention()==expected,"Interpretation: "+text+" -> "+expected);
|
||||
}
|
||||
private static void require(boolean value,String message) { checks++; if(!value) throw new AssertionError(message); }
|
||||
@FunctionalInterface private interface Checked { void run() throws Exception; }
|
||||
private static void rejects(Checked action,String message) throws Exception {
|
||||
boolean refused=false; try { action.run(); } catch(Exception expected) { refused=true; } require(refused,message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package fr.koka.sanctuary.providence;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.DoubleSupplier;
|
||||
|
||||
/** Response outcomes and delayed grants without Minecraft bootstrap or a clock thread. */
|
||||
public final class Providence269Smoke {
|
||||
private static int checks;
|
||||
private static final long NOW=1800000000000L;
|
||||
private static final Map<String,Boolean> INJURED=Map.of("alive",true,"injured",true),HUNGRY=Map.of("alive",true,"hungry",true);
|
||||
public static void main(String[] args)throws Exception {
|
||||
var directory=Files.createTempDirectory("providence269-");
|
||||
try {
|
||||
var defaults=MiracleRegistry.load(directory);var policy=ProvidenceResponses.load(directory);
|
||||
require(policy.deferredChance()==.2&&policy.deferredSeconds()==30,"Existing installations gain the default response policy");
|
||||
var original=Files.readString(directory.resolve("responses.json"));ProvidenceResponses.load(directory);
|
||||
require(original.equals(Files.readString(directory.resolve("responses.json"))),"Existing response configuration is not rewritten");
|
||||
for(double p:new double[]{-1,1.01,Double.NaN})rejects(()->new ProvidenceResponses(p,0,0,30,Map.of()),"Invalid probability refused");
|
||||
rejects(()->new ProvidenceResponses(0,.7,.4,30,Map.of()),"Overlapping refusal response ranges refused");
|
||||
for(int delay:new int[]{0,3601})rejects(()->new ProvidenceResponses(0,0,0,delay,Map.of()),"Invalid deferred delay refused");
|
||||
rejects(()->new ProvidenceResponses(0,1,0,30,Map.of(MiracleRegistry.Id.HEAL,List.of(MiracleRegistry.Id.HEAL))),"An alternative differs from the request");
|
||||
rejects(()->new ProvidenceResponses(0,1,0,30,Map.of(MiracleRegistry.Id.HEAL,List.of(MiracleRegistry.Id.LIGHTNING))),"Alternative help cannot introduce an unsolicited hostile target");
|
||||
var json=JsonParser.parseString(original).getAsJsonObject();json.addProperty("deferredSeconds",1.5);
|
||||
rejects(()->ProvidenceResponses.parse(json),"Fractional delay refused");json.addProperty("deferredSeconds",30);json.addProperty("extra",true);
|
||||
rejects(()->ProvidenceResponses.parse(json),"Unknown policy fields refused");
|
||||
var registry=registry(defaults,1,1);var interpreter=new PrayerInterpreter(registry);var heal=interpreter.interpret("gueris moi");
|
||||
var count=new AtomicInteger();var player=UUID.randomUUID();var history=new PrayerHistory(directory.resolve("world/history.json"));var engine=new ProvidenceEngine(registry,history);
|
||||
var result=engine.pray(player,"gueris moi",heal,INJURED,NOW,()->0,ProvidenceResponses.IMMEDIATE,Map.of(),id->{count.incrementAndGet();return true;});
|
||||
require(result.verdict()==ProvidenceEngine.Verdict.GRANTED&&result.executed()&&count.get()==1,"Exaucee executes once before its verdict");
|
||||
var delayedPlayer=UUID.randomUUID();var deferredPolicy=new ProvidenceResponses(1,0,0,30,Map.of());
|
||||
result=engine.pray(delayedPlayer,"gueris moi",heal,INJURED,NOW,()->0,deferredPolicy,Map.of(),id->{count.incrementAndGet();return true;});
|
||||
require(result.verdict()==ProvidenceEngine.Verdict.DEFERRED&&!result.executed()&&count.get()==1,"Differee commits without executing");
|
||||
var pending=result.deferred();require(pending!=null&&history.state().entries().getLast().decision().equals("DEFERRED"),"The deferred verdict is durable and distinguishable from PENDING");
|
||||
require(history.state().cooldowns().get(delayedPlayer+":HEAL")>=NOW+330000,"Deferred attempt reserves cooldown beyond its deadline");
|
||||
require(history.state().cooldowns().get("global:HEAL")<=NOW,"Deferral never enables a zero global cooldown for other players");
|
||||
var queue=new PrayerQueue<ProvidenceEngine.Deferred>();queue.offer(delayedPlayer,pending,"gueris moi",100,30);
|
||||
require(queue.ready(699).isEmpty(),"Deferred miracle cannot execute before its later deadline");
|
||||
require(!queue.offer(delayedPlayer,pending,"replacement",200,1),"Deferred job keeps the same per-player queue slot");
|
||||
var jobs=queue.ready(700);require(jobs.size()==1&&queue.ready(701).isEmpty(),"Deferred job is dispatched once");
|
||||
history=new PrayerHistory(directory.resolve("world/history.json"));engine=new ProvidenceEngine(registry,history);
|
||||
var completed=engine.complete(jobs.getFirst().actor(),INJURED,NOW+30000,()->{count.incrementAndGet();return true;});
|
||||
require(completed.executed()&&completed.verdict()==ProvidenceEngine.Verdict.GRANTED&&count.get()==2,"Reloaded history completes the same session job without a new draw");
|
||||
require(history.state().entries().stream().filter(e->e.player().equals(delayedPlayer)).count()==1&&history.state().entries().getLast().executed(),"Deferred completion updates the original journal entry");
|
||||
var restarted=engine;rejects(()->restarted.complete(pending,INJURED,NOW+60000,()->{count.incrementAndGet();return true;}),"The same reservation cannot execute twice across engine instances");
|
||||
require(count.get()==2,"A repeated completion has no effect");
|
||||
var missing=engine.pray(UUID.randomUUID(),"gueris moi",heal,INJURED,NOW+60000,()->0,deferredPolicy,Map.of(),id->true).deferred();
|
||||
require(!engine.complete(missing,Map.of("alive",true,"injured",false),NOW+90000,()->{count.incrementAndGet();return true;}).executed()&&count.get()==2,"Eligibility is rechecked at the deferred effect time");
|
||||
var cancelled=engine.pray(UUID.randomUUID(),"gueris moi",heal,INJURED,NOW+120000,()->0,deferredPolicy,Map.of(),id->true).deferred();
|
||||
engine.cancel(cancelled,NOW+120001);require(history.state().entries().getLast().decision().equals("CANCELLED"),"Cancelled jobs leave no executed claim");
|
||||
var current=engine;rejects(()->current.complete(cancelled,INJURED,NOW+150000,()->true),"Cancelled deferral cannot be revived");
|
||||
var noHistoryRegistry=new MiracleRegistry(registry.dictionary(),registry.miracles(),0,registry.retentionDays(),registry.prayerIntervalSeconds(),registry.minimumScore(),registry.responseDelaySeconds());
|
||||
var noHistory=new ProvidenceEngine(noHistoryRegistry,new PrayerHistory(directory.resolve("no-text.json")));
|
||||
var noText=noHistory.pray(UUID.randomUUID(),"gueris moi",heal,INJURED,NOW,()->0,deferredPolicy,Map.of(),id->true).deferred();
|
||||
require(noHistory.complete(noText,INJURED,NOW+30000,()->true).executed(),"Deferral works when text history is disabled");
|
||||
var refusedRegistry=registry(defaults,0,1);var fallback=new ProvidenceResponses(0,1,0,30,Map.of(MiracleRegistry.Id.HEAL,List.of(MiracleRegistry.Id.FEED)));
|
||||
var altHistory=new PrayerHistory(directory.resolve("alternatives.json"));var altEngine=new ProvidenceEngine(refusedRegistry,altHistory);var ids=new ArrayList<MiracleRegistry.Id>();var altPlayer=UUID.randomUUID();
|
||||
result=altEngine.pray(altPlayer,"gueris moi",heal,INJURED,NOW,()->0,fallback,Map.of(MiracleRegistry.Id.FEED,HUNGRY),id->{ids.add(id);return true;});
|
||||
require(result.verdict()==ProvidenceEngine.Verdict.ALTERNATIVE&&result.executed()&&ids.equals(List.of(MiracleRegistry.Id.FEED)),"Alternative executes different eligible help only");
|
||||
require(altHistory.state().entries().getLast().interpretation().intention()==MiracleRegistry.Id.HEAL&&altHistory.state().entries().getLast().conditions().get("alternative_FEED"),"Journal retains requested intention and actual alternative identity");
|
||||
require(altHistory.state().cooldowns().containsKey(altPlayer+":HEAL")&&altHistory.state().cooldowns().containsKey(altPlayer+":FEED"),"Both requested and alternative cooldowns are reserved");
|
||||
result=altEngine.pray(altPlayer,"gueris moi",heal,INJURED,NOW+6000,()->0,fallback,Map.of(MiracleRegistry.Id.FEED,HUNGRY),id->{ids.add(id);return true;});
|
||||
require(result.verdict()==ProvidenceEngine.Verdict.COOLDOWN&&ids.size()==1,"Alternative cannot bypass the requested cooldown");
|
||||
var anotherHelp=new ProvidenceResponses(0,1,0,30,Map.of(MiracleRegistry.Id.HEAL,List.of(MiracleRegistry.Id.RESCUE_PLAYER)));
|
||||
result=altEngine.pray(altPlayer,"gueris moi",heal,Map.of("alive",true,"injured",false),NOW+12000,()->0,anotherHelp,Map.of(MiracleRegistry.Id.RESCUE_PLAYER,Map.of("alive",true,"safe_loaded_destination",true)),id->{ids.add(id);return true;});
|
||||
require(!result.executed()&&ids.size()==1,"A changed need cannot bypass an already cooling request through a different alternative");
|
||||
var repair=interpreter.interpret("repare mon outil");
|
||||
var coolingAlternative=new ProvidenceResponses(0,1,0,30,Map.of(MiracleRegistry.Id.REPAIR_ITEM,List.of(MiracleRegistry.Id.FEED)));
|
||||
result=altEngine.pray(altPlayer,"repare mon outil",repair,Map.of("alive",true,"damaged_held_item",false),NOW+18000,()->0,coolingAlternative,Map.of(MiracleRegistry.Id.FEED,HUNGRY),id->{ids.add(id);return true;});
|
||||
require(!result.executed()&&ids.size()==1,"A different requested miracle cannot bypass the alternative's own cooldown");
|
||||
result=altEngine.pray(UUID.randomUUID(),"gueris moi",heal,INJURED,NOW,()->0,fallback,Map.of(),id->{ids.add(id);return true;});
|
||||
require(result.verdict()==ProvidenceEngine.Verdict.REFUSED&&!result.executed()&&ids.size()==1,"Unavailable alternative help is never invented");
|
||||
var failedAlternativeHistory=new PrayerHistory(directory.resolve("refuse-all.json"));
|
||||
var refuseAll=new ProvidenceEngine(registry(defaults,0,0),failedAlternativeHistory);var attempted=UUID.randomUUID();
|
||||
result=refuseAll.pray(attempted,"gueris moi",heal,Map.of("alive",true,"injured",false),NOW,()->0,fallback,Map.of(MiracleRegistry.Id.FEED,HUNGRY),id->{ids.add(id);return true;});
|
||||
require(!result.executed()&&ids.size()==1,"Alternative respects its own zero probability");
|
||||
require(failedAlternativeHistory.state().cooldowns().containsKey(attempted+":FEED"),"A refused alternative draw also reserves its cooldown");
|
||||
var silent=new ProvidenceResponses(0,0,1,30,Map.of());var silenceHistory=new PrayerHistory(directory.resolve("silence.json"));
|
||||
result=new ProvidenceEngine(refusedRegistry,silenceHistory).pray(UUID.randomUUID(),"gueris moi",heal,INJURED,NOW,()->0,silent,Map.of(),id->{count.incrementAndGet();return true;});
|
||||
require(result.verdict()==ProvidenceEngine.Verdict.SILENT&&!result.executed()&&count.get()==2,"Silence has no miracle");
|
||||
require(silenceHistory.state().entries().getLast().decision().equals("SILENT"),"Silence is still journalled without a visible message");
|
||||
var unknown=interpreter.interpret("bonjour");
|
||||
result=engine.pray(UUID.randomUUID(),"bonjour",unknown,Map.of(),NOW+180000,()->0,ProvidenceResponses.IMMEDIATE,Map.of(),id->true);
|
||||
require(result.verdict()==ProvidenceEngine.Verdict.INCOMPREHENSIBLE&&!result.executed(),"Incomprise never manufactures an intention");
|
||||
var toDisable=engine.pray(UUID.randomUUID(),"gueris moi",heal,INJURED,NOW+200000,()->0,deferredPolicy,Map.of(),id->true).deferred();
|
||||
var disabledDefinitions=registry.miracles().stream().map(d->new MiracleRegistry.Definition(d.id(),d.id()!=MiracleRegistry.Id.HEAL&&d.enabled(),d.patterns(),d.excluded(),d.conditions(),d.probability(),d.playerCooldownSeconds(),d.globalCooldownSeconds(),d.clearSeconds())).toList();
|
||||
var disabledRegistry=new MiracleRegistry(registry.dictionary(),disabledDefinitions,registry.historyEntries(),registry.retentionDays(),registry.prayerIntervalSeconds(),registry.minimumScore(),registry.responseDelaySeconds());
|
||||
require(new ProvidenceEngine(disabledRegistry,history).complete(toDisable,INJURED,NOW+230000,()->{count.incrementAndGet();return true;}).verdict()==ProvidenceEngine.Verdict.DISABLED&&count.get()==2,"A miracle disabled by reload cannot execute its earlier deferral");
|
||||
var failure=engine.pray(UUID.randomUUID(),"gueris moi",heal,INJURED,NOW+240000,()->0,deferredPolicy,Map.of(),id->true).deferred();
|
||||
var failureEngine=engine;rejects(()->failureEngine.complete(failure,INJURED,NOW+270000,()->{throw new IllegalStateException("interruption");}),"Interrupted deferred effect propagates");
|
||||
require(history.state().entries().getLast().decision().equals("PENDING")&&!history.state().entries().getLast().executed(),"Interrupted effect never claims a success or replays");
|
||||
Files.writeString(directory.resolve("responses.json"),"{invalid-policy");rejects(()->ProvidenceResponses.load(directory),"Corrupt response configuration is preserved");
|
||||
require(Files.readString(directory.resolve("responses.json")).equals("{invalid-policy"),"Corrupt policy is never reset");
|
||||
System.out.println("PROVIDENCE269_PURE_PASS checks="+checks);
|
||||
}finally{try(var files=Files.walk(directory)){for(var file:files.sorted(Comparator.reverseOrder()).toList())Files.delete(file);}}
|
||||
}
|
||||
private static MiracleRegistry registry(MiracleRegistry base,double heal,double feed) {
|
||||
var defs=base.miracles().stream().map(d->new MiracleRegistry.Definition(d.id(),d.enabled(),d.patterns(),d.excluded(),d.conditions(),d.id()==MiracleRegistry.Id.HEAL?heal:d.id()==MiracleRegistry.Id.FEED?feed:d.probability(),d.playerCooldownSeconds(),d.globalCooldownSeconds(),d.clearSeconds())).toList();
|
||||
return new MiracleRegistry(base.dictionary(),defs,base.historyEntries(),base.retentionDays(),base.prayerIntervalSeconds(),base.minimumScore(),base.responseDelaySeconds());
|
||||
}
|
||||
private static void require(boolean value,String message){checks++;if(!value)throw new AssertionError(message);}
|
||||
@FunctionalInterface private interface Checked{void run()throws Exception;}
|
||||
private static void rejects(Checked action,String message)throws Exception {boolean rejected=false;try{action.run();}catch(Exception expected){rejected=true;}require(rejected,message);}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "beta.261"
|
||||
version = "beta.269"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
Reference in New Issue
Block a user