Compare commits

..
Author SHA1 Message Date
koka c51e611f4f Add Providence prayers and six coloured outcomes (beta.269)
Build Sanctuary / build (push) Canceled after 0s
Implement fourteen server-authoritative prayers with delayed replies, deferred miracles, alternative aid and verdict colours in SGA and readable text. Document configuration and validation for beta.265 through beta.269.

Validation: check build assemblePack, 191 assertions, 22 server GameTests and Vulkan client checks.
2026-10-24 10:02:45 +02:00
koka f4d3dad1ca Align Hello World previews and centre onboarding controls (beta.264)
Build Sanctuary / build (push) Canceled after 0s
2026-10-16 23:28:55 +02:00
koka 653a063b15 Show native baby familiar previews in Hello World (beta.263) 2026-10-16 07:23:17 +02:00
koka 9d3bc0cee2 Redesign Hello World with native hotbar and 3D previews (beta.262) 2026-10-16 03:58:47 +02:00
53 changed files with 4655 additions and 68 deletions
+81
View File
@@ -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) :
+30
View File
@@ -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
+59
View File
@@ -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.
+74
View File
@@ -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
```
+96
View File
@@ -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.
+191
View File
@@ -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
```
+53
View File
@@ -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.
+103
View File
@@ -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`.
+190
View File
@@ -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`.
+175
View File
@@ -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
View File
@@ -9,8 +9,8 @@ loom_version=1.17.20
fabric_api_version=0.160.5+26.3
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
mod_version=beta.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
+22 -1
View File
@@ -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') }
@@ -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){
@@ -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);}
}
@@ -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);}
}
@@ -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);
@@ -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); }
}
@@ -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);
@@ -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;}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.261"
version = "beta.269"
pack-format = "packwiz:1.1.0"
[index]