Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7979f33028 | ||
|
|
460c4706d3 | ||
|
|
ad960ef9bc | ||
|
|
835e6116e6 | ||
|
|
aadff6fd5e | ||
|
|
705fe9c1a0 | ||
|
|
dbb9e3428d | ||
|
|
9a429c40a1 | ||
|
|
905b3bcfa1 | ||
|
|
0c55a37562 | ||
|
|
8deba7cfb4 | ||
|
|
8de569bbf1 |
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## beta.144 — Code d’accès à la création
|
||||
|
||||
- Hello World demande le code `SANC-XXXX-XXXX` quand le serveur a l’URL et le jeton du site.
|
||||
- Le client ne contacte pas le site. Le serveur vérifie, puis consomme le code au moment de créer l’habitant.
|
||||
- Le lien Discord est enregistré à part. Une création interrompue après `redeemed` peut se terminer sans nouveau code si le compte est encore connu.
|
||||
- [Contrat](docs/access-code-beta144.md).
|
||||
|
||||
## beta.110 — Atelier d’argile inclus
|
||||
|
||||
- Une seule livraison réunit l’atelier d’argile, les statuaires et l’import GLB de beta.106 avec les œufs et la neige saisonnière de beta.107 à beta.109.
|
||||
|
||||
@@ -1,5 +1,52 @@
|
||||
# Sanctuary
|
||||
|
||||
[Dernière release : beta.166](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.166) ·
|
||||
[Historique des publications beta.144–166](docs/publications-beta144-166.md).
|
||||
Les releases sont distinctes du canal packwiz, qui reste inchangé lors de ce rattrapage.
|
||||
|
||||
## beta.166 — inscription depuis le site
|
||||
|
||||
Accueil avec code récupéré sur le web et reprise après coupure.
|
||||
[Contrat, migration et vérifications](docs/inscription-web-beta166.md).
|
||||
|
||||
## Vérification du socle beta.165
|
||||
|
||||
Les 23 échecs historiques ont été corrigés dans les scénarios de test :
|
||||
252/252 GameTests passent, ainsi que `check build`. Code de jeu inchangé.
|
||||
[Actualisation et preuves](docs/actualisation-tests-beta165.md).
|
||||
|
||||
## beta.165 — article Gazette et conversation
|
||||
|
||||
Grande photo et texte dans un même conteneur à gauche, auteur dessous, réponses
|
||||
à droite ou sous l’article sur petit écran.
|
||||
[Résultat et vérifications](docs/gazette-scene-beta165.md) ·
|
||||
[Audit des 23 échecs connus](docs/audit-echecs-beta165.md).
|
||||
|
||||
## beta.164 — calendrier et intendance
|
||||
|
||||
Date et heure réunies à gauche ; titre serveur aligné sur la carte et les panneaux.
|
||||
[Résultat et vérifications](docs/pause-calendar-beta164.md).
|
||||
|
||||
## beta.163 — menu pause encadré
|
||||
|
||||
Calendrier, intendance et horloge en en-tête ; deux groupes de navigation,
|
||||
séparateurs natifs et reprise centrée dans le footer.
|
||||
[Résultat et vérifications](docs/pause-frame-beta163.md).
|
||||
|
||||
## beta.162 — tableau en scène et conversation
|
||||
|
||||
Portrait de l’auteur tourné vers la souris, bulle aux couleurs de l’annonce,
|
||||
demande à gauche et réponses à droite. Retour/Actualiser restent en haut ;
|
||||
la conversation passe dessous sur les GUI étroits.
|
||||
[Résultat et vérifications](docs/notice-scene-beta162.md).
|
||||
|
||||
## beta.161 — correctifs de séance et site vivant
|
||||
|
||||
Liste des joueurs sur U, suivi des demandes expliqué, intendance compacte et
|
||||
compteurs réels partagés avec le site. Site public en lecture seule, Gazette
|
||||
en accordéon avec aperçus compacts.
|
||||
[Contrat et vérifications](docs/session-fixes-beta161.md).
|
||||
|
||||
## beta.160 — laboratoire de test en duo
|
||||
|
||||
Shader désactivé sur une configuration neuve, serveur local léger, personnages
|
||||
@@ -7,38 +54,38 @@ de test optionnels et séances de captures horodatées.
|
||||
[Mode d’emploi et vérifications](docs/duo-lab-beta160.md) ·
|
||||
[Récap Discord 130–160](docs/discord-beta130-160.md).
|
||||
|
||||
## beta.159 — recherche communautaire et galerie adaptable (pack local)
|
||||
## beta.159 — recherche communautaire et galerie adaptable
|
||||
|
||||
Recherche par joueur, titre ou texte, historique Serveur en lecture seule,
|
||||
année dans les dates et galerie adaptée au GUI avec grand aperçu.
|
||||
Repères « ! » et infobulles sur la carte pour les demandes suivies géolocalisées.
|
||||
[Résultat et vérifications](docs/community-search-beta159.md).
|
||||
|
||||
## beta.158 — cartes communautaires et demandes suivies (pack local)
|
||||
## beta.158 — cartes communautaires et demandes suivies
|
||||
|
||||
Grilles à deux colonnes, articles photo/texte, annonces colorées avec visages,
|
||||
coffres descriptifs et suivi persistant fichier/MariaDB. Sélecteur photo compact.
|
||||
[Contrat et vérifications](docs/community-cards-beta158.md).
|
||||
|
||||
## beta.157 — photo obligatoire dans la Gazette (pack local)
|
||||
## beta.157 — photo obligatoire dans la Gazette
|
||||
|
||||
Galerie de captures défilante, aperçu et assignation à un article. Publication
|
||||
avec photo obligatoire, stockage fichier ou MariaDB partagé avec le site.
|
||||
[Contrat de migration et vérifications](docs/gazette-photos-beta157.md).
|
||||
|
||||
## beta.156 — reprendre la partie sous la colonne communautaire (pack local)
|
||||
## beta.156 — reprendre la partie sous la colonne communautaire
|
||||
|
||||
Le bouton Reprendre la partie est aligné sous la Gazette et le tableau
|
||||
d’affichage, en bas à droite. [Vérifications](docs/pause-resume-beta156.md).
|
||||
|
||||
## beta.155 — nouvelle structure du menu pause (pack local)
|
||||
## beta.155 — nouvelle structure du menu pause
|
||||
|
||||
Navigation Sanctuary à gauche, carte centrale, Gazette et demandes défilantes
|
||||
à droite. Date, intendance et heure en en-tête ; options et sortie en bas de la colonne
|
||||
gauche, reprise de la partie en bas au centre.
|
||||
[Contrat et vérifications](docs/pause-redesign-beta155.md).
|
||||
|
||||
## beta.154 — Gazette et tableau communautaire (pack local)
|
||||
## beta.154 — Gazette et tableau communautaire
|
||||
|
||||
Panneaux gauche/droite du menu pause, intendance, publication, réponses,
|
||||
modification et modération. Stockage fichier par défaut ; MariaDB optionnelle
|
||||
@@ -46,39 +93,39 @@ commune avec le site. Shaders beta.151 conservés.
|
||||
[Contrat et configuration](docs/community-contract-v1.md) ·
|
||||
[Vérifications et limites](docs/community-beta154.md).
|
||||
|
||||
## beta.151 — Nether, reflets des blocs et SSR (pack local)
|
||||
## beta.151 — Nether, reflets des blocs et SSR
|
||||
|
||||
Synchronisation de l’inventaire au changement de dimension, retour de l’intensité
|
||||
spéculaire des blocs solides à son niveau antérieur et bois mat avec relief.
|
||||
SSR activé par défaut à 20 %, choix sauvegardés conservés.
|
||||
[Contrat et vérifications](docs/nether-pbr-beta151.md).
|
||||
|
||||
## beta.150 — Terre mate et reflets du coucher de soleil (pack local)
|
||||
## beta.150 — Terre mate et reflets du coucher de soleil
|
||||
|
||||
Terre mate avec relief conservé. Le soleil et son reflet se colorent ensemble
|
||||
en suivant la couleur et la transition du coucher de soleil natif de Minecraft.
|
||||
Les accents lumineux suivent les normales animées des vaguelettes et leur
|
||||
orientation vers le soleil et la caméra. [Contrat et vérifications](docs/matte-dirt-beta150.md).
|
||||
|
||||
## beta.149 — Distances PBR / SSR et feuillage (pack local)
|
||||
## beta.149 — Distances PBR / SSR et feuillage
|
||||
|
||||
Deux distances séparées, 64 blocs par défaut chacune, jusqu’à 256 blocs.
|
||||
Option renommée « SSR » et suppression du voile spéculaire sur le feuillage.
|
||||
[Contrat et vérifications](docs/independent-distances-beta149.md).
|
||||
|
||||
## beta.148 — PBR 50 % et soleil diffus (pack local)
|
||||
## beta.148 — PBR 50 % et soleil diffus
|
||||
|
||||
PBR à 50 %, reflet solaire plus doux avec fusion par texel préservant
|
||||
la texture de l’eau. SSR à 20 %, portée commune jusqu’à 256 blocs.
|
||||
[Contrat et vérifications](docs/pbr-soft-sun-beta148.md).
|
||||
|
||||
## beta.147 — PBR proche, lune et portée 256 (pack local)
|
||||
## beta.147 — PBR proche, lune et portée 256
|
||||
|
||||
Correction du scintillement à très courte distance, reflet lunaire adouci
|
||||
et distance commune PBR/SSR jusqu’à 256 blocs.
|
||||
[Contrat et vérifications](docs/pbr-close-range-beta147.md).
|
||||
|
||||
## beta.146 — Reflets et transitions de l’eau (pack local)
|
||||
## beta.146 — Reflets et transitions de l’eau
|
||||
|
||||
Correction du masquage des personnages en F5 et sélection stricte des surfaces PBR.
|
||||
PBR à 80 % et SSR à 20 % par défaut, distance commune PBR/SSR
|
||||
@@ -2414,7 +2461,7 @@ Depuis beta.090, la cible est **Minecraft 26.3 finale**, publiée le 15 septembr
|
||||
| Fabric API | 0.160.5+26.3 |
|
||||
| Fabric Loom | 1.17.20 |
|
||||
| Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 |
|
||||
| Sanctuary / pack | beta.151 |
|
||||
| Sanctuary / pack | beta.166 |
|
||||
|
||||
Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise
|
||||
uniquement la bibliothèque standard et repère aussi une installation Python
|
||||
@@ -2443,7 +2490,7 @@ décrits dans [Validation](docs/testing.md).
|
||||
|
||||
Résultats :
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-beta.151.jar` : mod à installer avec
|
||||
- `mods/sanctuary/build/libs/sanctuary-beta.166.jar` : mod à installer avec
|
||||
Fabric API sur la version Minecraft indiquée.
|
||||
- `build/packwiz/` : pack de développement complet, avec le mod construit et
|
||||
l'index vérifié. Voir [Installation packwiz](packwiz/README.md).
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# beta.144 — Code d’accès à la création
|
||||
|
||||
Document historique de la PR de Chris. Le contrat de reprise ci-dessous est
|
||||
remplacé par [beta.166](inscription-web-beta166.md), notamment pour les codes
|
||||
consommés et les déconnexions.
|
||||
|
||||
Le site accepte la candidature, whitelist le pseudo et envoie un code `SANC-XXXX-XXXX`.
|
||||
Hello World demande ce code avant de créer le personnage. Le client ne parle pas
|
||||
au site. Seul le serveur appelle l’API, avec le jeton `SANCTUARY_API_TOKEN`.
|
||||
|
||||
Sans ces réglages, Hello World reste celui de beta.143 : biographie, couleur et
|
||||
familier, sans code. Dès que l’URL et le jeton sont présents, un nouveau
|
||||
personnage exige un code reconnu. Un habitant déjà enregistré entre sans écran.
|
||||
|
||||
## Réglage serveur
|
||||
|
||||
Variables d’environnement, ou fichier `config/sanctuary/access.json` si une
|
||||
variable manque :
|
||||
|
||||
```json
|
||||
{"appUrl":"https://exemple.sanctuary","token":"..."}
|
||||
```
|
||||
|
||||
`appUrl` est l’origine du site, sans barre finale. Le serveur appelle
|
||||
`POST {appUrl}/api/v1/access-codes/verify` puis `redeem`. Le jeton n’est pas
|
||||
écrit dans les logs, le client ou le pack. Une URL ou un jeton illisible ferme
|
||||
la création : aucun personnage n’est inventé hors ligne.
|
||||
|
||||
## Parcours
|
||||
|
||||
1. La whitelist laisse passer le pseudo. L’écran s’ouvre tant que l’UUID n’a pas
|
||||
d’habitant.
|
||||
2. Le joueur saisit le code. L’affichage du champ et de l’exemple utilise
|
||||
l’alphabet galactique standard (`minecraft:alt`, la police de la table
|
||||
d’enchantement). La valeur envoyée reste le texte tapé. Un format complet
|
||||
déclenche `verify`, pas chaque frappe. « Code reconnu » n’écrit rien.
|
||||
3. Confirmer envoie le code de la session, la biographie, la couleur et le
|
||||
familier. Le pseudo envoyé au site est celui de la session.
|
||||
4. `redeem` répond `redeemed` avec `discord_id` : le lien est enregistré, puis
|
||||
l’habitant est créé. Les connexions suivantes sautent l’écran.
|
||||
5. Erreur, site injoignable ou jeton refusé : le joueur reste sur Hello World.
|
||||
|
||||
Une déconnexion avant `redeemed` ne consomme pas le code. Si le site a répondu
|
||||
`redeemed` et que l’écriture de l’habitant est interrompue, le lien Discord
|
||||
reste dans `data/sanctuary-access.json` (`pending`, schéma 1, graine du monde).
|
||||
La confirmation suivante termine le personnage sans rappeler `redeem`.
|
||||
|
||||
Si le code est déjà consommé et que le site ne renvoie plus `discord_id`, le
|
||||
joueur reste bloqué avec un message de reprise. Le site doit alors renvoyer
|
||||
`minecraft_username` et `discord_id` pour le même pseudo. Le mod accepte cette
|
||||
réponse, que `valid` soit vrai ou faux, et finit la création.
|
||||
|
||||
Le registre des habitants ne change pas. Le Discord est un fichier à part.
|
||||
Un fichier illisible est conservé et refuse l’accueil.
|
||||
|
||||
## Limites
|
||||
|
||||
Le gel dans le monde n’est pas ajouté : Hello World reste avant l’entrée, comme
|
||||
aujourd’hui. Inventaire, commandes et dimensions ne sont pas accessibles tant
|
||||
que l’habitant n’existe pas. La whitelist RCON reste celle du site. Un pseudo
|
||||
ajouté à la main, sans code, voit l’écran sans pouvoir le valider.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Actualisation des tests — socle beta.165
|
||||
|
||||
Suite à l’audit des 23 échecs. Changements limités aux GameTests et à leur
|
||||
préparation ; aucun changement des règles, des données de production ou de
|
||||
format de monde. Pas de nouvelle version binaire : le laboratoire reste beta.165.
|
||||
|
||||
## Scénarios corrigés
|
||||
|
||||
- Placement : joueur à portée réelle, sans occuper la cellule visée. Les compteurs,
|
||||
événements, empreintes et les huit diamants de la tombe restent vérifiés.
|
||||
- Inventaires : données d’ouverture explicites pour l’atelier d’argile et le
|
||||
multibloc, avec maintien de la boucle sur toutes les entrées du registre.
|
||||
- Familiers : achat de l’accès puis commande serveur de mode travail ; le helper
|
||||
vérifie aussi que le mode combat n’accorde pas les anciens passifs. Les bonus,
|
||||
consommation de ressources et annulations restent vérifiés.
|
||||
- Ancienne invulnérabilité : test des coups amicaux sans dégâts, du K.-O. sur
|
||||
dégât létal et de la conservation de l’œuf. Le poisson terrestre utilise son
|
||||
profil actuel et garde sa vérification de transition aquatique.
|
||||
- Permissions : accès public à l’introduction, restrictions opérateur sur names
|
||||
et community admin. Catalogue recompte les 67 documents, 2042 recettes,
|
||||
1780 items, 1374 blocs et 1937 identifiants distincts. Hauteur actuelle 640.
|
||||
- Dragon : cycle natif tickNonPassenger (commonTick + tick), compteur d’entité
|
||||
contrôlé, déplacement/altitude/collision conservés.
|
||||
- Dalle/coffre : visée à portée du dessus réel, sans collision du joueur avec la
|
||||
surface à bâtir. Les assertions de fusion, collisions et contenu restent.
|
||||
- Hydrologie : les ouvertures OUTLET doivent être des coupes sans source ni
|
||||
sédiment, appartenant à un déversement terminal déclaré. Les autres cellules
|
||||
conservent les contrôles de support naturel.
|
||||
|
||||
## Diagnostics indépendants
|
||||
|
||||
- [Construction](audit-construction-beta165.md)
|
||||
- [Dragon](audit-dragon-beta165.md)
|
||||
- [Berges](audit-berges-beta165.md)
|
||||
|
||||
## Validation
|
||||
|
||||
Premier rejeu ciblé : **81/81 tests obligatoires passent** en 1 min 45 s.
|
||||
Journal : `build/test-refresh-focused.log`.
|
||||
Groupes : inventory, inventoryflow, companions, refonte, accessories, graves,
|
||||
collections, progression, demeure. Ce rejeu valide notamment les nouvelles
|
||||
assertions atteintes et le déplacement du dragon. Construction et hydrologie
|
||||
sont réservées au rejeu complet suivant.
|
||||
|
||||
`./gradlew check build --continue -PsanctuaryQuickTests=true` : **BUILD SUCCESSFUL**
|
||||
en 7 min 11 s. **252/252 GameTests obligatoires réussis**, zéro échec, puis
|
||||
les autres contrôles de `check` terminent avec succès.
|
||||
Journal : `build/test-refresh-full.log`.
|
||||
|
||||
Les quatre cas analysés par les agents passent dans ce rejeu, sans modification
|
||||
production. Le patch supplémentaire de parois OUTLET proposé à titre conditionnel
|
||||
n’a pas été appliqué : aucune assertion correspondante n’a échoué.
|
||||
Ce résultat porte sur la suite configurée ci-dessus ; aucune stabilité statistique
|
||||
sur plusieurs graines, plateformes ou répétitions n’est revendiquée.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Audit du support naturel des berges — beta.165
|
||||
|
||||
## Verdict
|
||||
|
||||
L’échec initial est une **attente de test antérieure aux ouvertures de berges
|
||||
alpha.30**, avec une confiance très forte. La cellule signalée n’est pas une
|
||||
terrasse sèche avec sédiments : c’est la deuxième ouverture `Kind.OUTLET` du
|
||||
plan régional. Son absence de fondation est intentionnelle. Aucune modification
|
||||
de génération n’est recommandée pour faire passer cette assertion.
|
||||
|
||||
Ce diagnostic ne démontre pas que le reste du test passe : celui-ci échoue dès
|
||||
la préparation, avant sa vérification des blocs après décoration et ticks.
|
||||
Aucun monde, chunk, fichier de production ou test partagé n’a été modifié pour
|
||||
cet audit. Aucun serveur ni JVM supplémentaire n’a été lancé.
|
||||
|
||||
## Preuves et chaîne de traitement
|
||||
|
||||
1. `build/beta165-check-build.log:962` signale, au tick 0 :
|
||||
`Cell[x=-141, z=-86, waterY=-1, bedY=238, carveTop=241, material=STONE,
|
||||
featureId=15307446929, sedimentDepth=0]`.
|
||||
2. `PopulationHydrologyRuntime.regionPlan` construit un plan classique admis,
|
||||
puis applique `BankOutlets30.openBanks` pour les réglages `unified_5/10/20`.
|
||||
La densité vient du même `finalDensity` et du même `RandomState` que le
|
||||
diagnostic, via un mémo de signe. Les coordonnées locales sont translatées
|
||||
avec l’origine régionale. Dans la région `(0,0)`, cette translation est nulle.
|
||||
3. `BankOutlets30.java:30–45` cherche le vide à 1–5 blocs d’un bassin et crée
|
||||
des cellules sèches de profondeur de sédiments zéro, classées `OUTLET`.
|
||||
Ces cellules n’ajoutent ni roche ni eau : elles décrivent la coupe de la berge.
|
||||
4. Recalcul indépendant des opérations entières 64 bits en Python, sans moteur
|
||||
Minecraft : pour graine 0 et région `(0,0)`, la graine régionale non signée vaut
|
||||
`12661893618221475390`; la base des identifiants de sorties vaut
|
||||
`15307446928`. L’identifiant en échec vaut exactement base + 1, donc la
|
||||
deuxième sortie (index 1). Ce calcul renforce l’identification par
|
||||
`sedimentDepth=0`, au lieu de la supposer depuis le nom du test.
|
||||
5. `PopulationHydrologyRuntime.apply` exclut explicitement `plan.isOutletCell`
|
||||
de la validation des deux couches de support. Sa boucle de sédiments ne fait
|
||||
aucune écriture avec profondeur zéro ; la boucle de coupe supprime les blocs
|
||||
entre `bedY+1` et `carveTop`. L’eau se propage ensuite par les ticks vanilla.
|
||||
6. `PopulationDiagnostics.checkOwnership:333–344` applique au contraire la
|
||||
densité positive à **toutes** les cellules, donc impose ici une fondation à
|
||||
Y237 et Y238. C’est précisément la contrainte absente du contrat des sorties.
|
||||
7. Le contrat publié dans `docs/generation-alpha30.md`, section Hydrologie,
|
||||
autorise une chute dans le vide et précise l’absence de fondation ou de
|
||||
colonne d’eau artificielle. `River30Smoke.java:23–24` vérifie déjà profondeur
|
||||
zéro et absence de nouvelle source. Ce smoke a passé dans le journal beta.165
|
||||
(lignes 453–454).
|
||||
|
||||
La documentation Java de `PopulationHydrology.Cell:53` décrit encore seulement
|
||||
les cellules sédimentaires : elle mérite une clarification future pour le cas
|
||||
OUTLET, mais ne prévaut pas sur le contrat alpha.30 ni son implémentation dédiée.
|
||||
|
||||
## Adaptation ciblée proposée
|
||||
|
||||
Le patch préparé dans `build/audit-berges.patch` ne change que
|
||||
`PopulationDiagnostics.checkOwnership`. Il conserve les assertions de région
|
||||
et d’appartenance de toutes les cellules. Pour une cellule **explicitement
|
||||
classée OUTLET**, il exige :
|
||||
|
||||
- aucune source d’eau, aucun sédiment, un intervalle de coupe positif ;
|
||||
- un déversement terminal déclaré avec le même identifiant.
|
||||
|
||||
Les autres cellules conservent intégralement l’assertion de densité naturelle des
|
||||
sédiments et des deux couches de support. Ne pas remplacer ce contrôle par une
|
||||
exception générale pour toutes les cellules sèches ou de profondeur zéro.
|
||||
|
||||
Le patch reste isolé et non appliqué pour intégration par l’agent principal.
|
||||
|
||||
## Reproduction et validation minimales
|
||||
|
||||
Le défaut de contrat se reproduit sans monde avec le témoin existant
|
||||
`River30Smoke` : la côte synthétique est solide à x≤4 ; la brèche atteint x=5,
|
||||
qui est du vide. Exiger un support positif sous cette cellule ferait échouer
|
||||
une ouverture intentionnelle que le smoke valide.
|
||||
|
||||
Pour confirmer le témoin réel, rejouer le GameTest
|
||||
`UnifiedWorldGameTests.retainedHydrologySurvivesNativeDecoration` après application
|
||||
ciblée, dans le **monde jetable de tests**, graine 0, population 10, diamètre 724.
|
||||
Conserver les vérifications de blocs FULL, de sédiments, de coquille et de ticks.
|
||||
La commande et l’ordonnancement seront assurés par l’agent principal pour ne pas
|
||||
saturer les 8 Go du Mac. Aucun passage de ce test n’est revendiqué ici.
|
||||
|
||||
## Points à surveiller après la première correction
|
||||
|
||||
- `verifyFinishedShell:460` impose sédiments 3–5 à ses cellules sélectionnées.
|
||||
La sélection normale porte sur lac/étang/terrasse et exclut les OUTLET ; son
|
||||
fallback prend la première feature. Si une ouverture est sélectionnée à
|
||||
l’avenir, elle nécessitera son propre contrôle de coupe, sans support imposé.
|
||||
- Le contrôle de paroi humide reconnaît `isSpillOpening`, qui ne contient que
|
||||
la position terminale. Une ouverture de trois blocs de large peut également
|
||||
créer une paroi ouverte avant ce point. Si cette assertion échoue ensuite,
|
||||
vérifier le voxel contre une cellule OUTLET déclarée et son intervalle exact
|
||||
de coupe ; ne pas désactiver le contrôle de toutes les parois.
|
||||
- Le test complet n’a pas encore atteint ses contrôles finaux avec ce patch.
|
||||
Les éventuels nouveaux échecs doivent être diagnostiqués séparément.
|
||||
- Les plafonds historiques à Y384 restent présents dans certaines inspections
|
||||
naturelles alors que la dimension atteint Y640. Ils ne causent pas l’échec
|
||||
ici à Y237/238, et ne sont pas modifiés dans ce patch ciblé.
|
||||
|
||||
## Fichiers examinés
|
||||
|
||||
- `mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/BankOutlets30.java`
|
||||
- `mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationHydrology.java`
|
||||
- `mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationHydrologyRuntime.java`
|
||||
- `mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandDensity.java`
|
||||
- `mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/IslandCapacity.java`
|
||||
- `mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java`
|
||||
- `mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/River30WorldGameTests.java`
|
||||
- `mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/River30Smoke.java`
|
||||
|
||||
## Complément : contrôle précis de la paroi ouverte
|
||||
|
||||
Un second patch non appliqué, `build/audit-berges-shell.patch`, est disponible
|
||||
**uniquement si l’exécution atteint un échec de paroi correspondant à une
|
||||
ouverture réelle**. Il ajoute un cas au contrôle des parois de
|
||||
`verifyFinishedShell`, après ses exceptions existantes pour l’eau planifiée et
|
||||
le voxel terminal du spill.
|
||||
|
||||
Le voxel doit appartenir au plan de sa propre position X/Z, à une cellule
|
||||
explicitement `OUTLET`, sans eau planifiée ni sédiments, associée par son
|
||||
identifiant à un spill terminal. Son Y doit être **strictement supérieur à
|
||||
bedY et inférieur ou égal à carveTop**. Le bloc final doit alors être de l’air
|
||||
ou de l’eau vanilla ; un bloc solide ou de la lave fait toujours échouer le test.
|
||||
Les couches sous la coupe, les voisins latéraux hors emprise, les autres types
|
||||
de cellules et les voxels au-dessus de la coupe conservent leur contrôle normal.
|
||||
|
||||
Le patch parcourt `cellsAt` plutôt que de se fier seulement à `cellAt(x,y,z)` :
|
||||
ce dernier inclut aussi les couches de support dans sa sélection, ce qui aurait
|
||||
créé une exception trop large. Les assertions sur les sédiments sélectionnés
|
||||
restent inchangées. La suite en cours doit décider si ce patch est nécessaire ;
|
||||
aucun résultat d’exécution n’est anticipé.
|
||||
|
||||
## Résultat de l’intégration
|
||||
|
||||
Le patch proposé a ensuite été intégré aux tests par l’agent principal.
|
||||
Le rejeu complet `build/test-refresh-full.log` termine avec 252/252 GameTests
|
||||
réussis et `check build` réussi. Aucun code de production ni monde existant modifié.
|
||||
Voir [la validation consolidée](actualisation-tests-beta165.md).
|
||||
@@ -0,0 +1,98 @@
|
||||
# Audit ciblé — construction groupée sur dalle et coffre
|
||||
|
||||
Audit du 24 septembre 2026, sources beta.165. Aucun changement de production,
|
||||
aucun monde lancé ou modifié, aucune nouvelle exécution de GameTest dans cet audit.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Les deux refus initiaux ont une explication commune de **fixture hors portée**, avec
|
||||
une confiance forte : le rayon de visée s’arrête à 3,5 blocs avant la surface réelle.
|
||||
Ils ne démontrent ni une fusion de dalle cassée, ni une altération du contenu du coffre.
|
||||
Les assertions suivantes restent à rejouer ; elles ne sont pas déclarées réussies.
|
||||
|
||||
Le contrat beta.009 utilisait la portée native, tandis que le contrat actuel
|
||||
[beta.081](build-reach-beta081.md) fixe le rang 1 à 3,5 blocs. Le même point de vue
|
||||
atteint encore un cube plein, mais pas les surfaces plus basses.
|
||||
|
||||
## Chaîne de refus
|
||||
|
||||
- `Building009GameTests.java:107–113` : plan 3×3 de dalles basses ; joueur au rang 1 ;
|
||||
`aim` place ses pieds en `(0,5 ; 1 ; −2,5)` par rapport à l’origine, puis vise
|
||||
`(0,5 ; 0,5 ; 0,5)`.
|
||||
- `Building009GameTests.java:165–170` : coffre central et pierre autour ; même position,
|
||||
mais `aim` conserve la cible d’un cube plein `(0,5 ; 1 ; 0,5)`.
|
||||
- `BuildReach.java:13–21` et `BuildingLimits.java:6` : portée 3,5 ; le contrôle préalable
|
||||
de proximité utilise l’AABB du **bloc entier**, pas la forme de la dalle/coffre.
|
||||
- `BuildSelection.java:40–44` : `player.pick(3.5, 1, false)` doit réellement toucher
|
||||
le bloc d’origine. Un MISS ou un autre bloc donne `null`.
|
||||
- `BuildingSession.java:26–30` : ce `null` empêche de créer la session, avant tout
|
||||
placement, fusion ou usage du coffre.
|
||||
- Le journal existant `build/beta165-check-build.log:1105–1138` confirme les refus
|
||||
à `start`, lignes 113 et 170 des tests, au tick 0.
|
||||
|
||||
## Géométrie vérifiée
|
||||
|
||||
Les constantes natives ont été inspectées par `javap -c -p` dans le JAR local exact
|
||||
Minecraft 26.3 : `Avatar` définit les yeux debout à 1,62 ; `SlabBlock` utilise
|
||||
`column(16,0,8)` pour la dalle basse ; `ChestBlock` utilise `column(14,0,14)` pour
|
||||
le coffre simple. Traces dans `build/audit-construction-avatar-bytecode.txt` et
|
||||
`build/audit-construction-native-bytecode.txt`. Aucun serveur/JVM Minecraft lancé.
|
||||
|
||||
Les yeux sont donc en **E=(0,5 ; 2,62 ; −2,5)**. Calculs Python indépendants du jeu :
|
||||
|
||||
| Surface et visée actuelle | Premier impact géométrique prévu | Distance yeux-impact |
|
||||
|---|---|---:|
|
||||
| Cube plein témoin | `(0,5 ; 1 ; 0,5)` | 3,409457 |
|
||||
| Dalle basse, visée corrigée sur son dessus | `(0,5 ; 0,5 ; 0,5)` | **3,673472** |
|
||||
| Coffre, visée restée au dessus d’un cube plein | `(0,5 ; 0,875 ; 0,731481)` | **3,672533** |
|
||||
|
||||
Le coffre occupe horizontalement `[1/16 ; 15/16]`, donc ce point est bien dans son
|
||||
dessus. Les cubes de pierre voisins ne coupent pas ce rayon avant lui : à `y=1`,
|
||||
le rayon est déjà au centre de la cellule d’origine. Les autres dalles basses ne
|
||||
coupent pas davantage le rayon avant `y=0,5`. Le précontrôle AABB entier passe,
|
||||
avec une distance minimale de 2,978993 : il ne garantit pas que le rayon atteigne
|
||||
la forme réelle.
|
||||
|
||||
L’écart d’environ 0,173 bloc dépasse largement les arrondis float de la direction
|
||||
et de la hauteur des yeux. Ces calculs expliquent le refus dans les deux fixtures ;
|
||||
une trace native reste utile pour confirmer explicitement le MISS en exécution.
|
||||
|
||||
## Correction recommandée, limitée aux tests
|
||||
|
||||
Patch proposé, **non appliqué** : `build/audit-construction.patch`.
|
||||
|
||||
Le helper dédié `aimInsetTop` rapproche les pieds à `z=−1,5`, conserve `y=1` et
|
||||
vise le centre du dessus réel (`y=0,5` pour la dalle, `14/16` pour le coffre).
|
||||
Les distances deviennent respectivement **2,914515** et **2,654247** blocs.
|
||||
Le joueur reste hors du plan 3×3 (son bord proche est vers `z=−1,2`, le plan
|
||||
commence à `z=−1`) afin de ne pas exclure une pose par sa propre collision.
|
||||
Une assertion explicite vérifie que le rayon atteint la face UP avant le démarrage.
|
||||
|
||||
Toutes les assertions métier existantes restent : neuf consommations/fusion,
|
||||
dalles de plafond, exclusion de la vache, trois diamants conservés, menu fermé,
|
||||
refus des objets à placement particulier. Aucun changement de portée de production,
|
||||
aucune suppression de test, aucun passage en créatif pour masquer le problème.
|
||||
|
||||
## Protocole ciblé restant
|
||||
|
||||
1. Relire/appliquer le patch de tests sur une branche de correction coordonnée.
|
||||
2. Rejouer la famille `building` dans un monde GameTest jetable, avec le filtre
|
||||
du dépôt `-PsanctuaryFocusedTests=building`, après libération de la mémoire du
|
||||
laboratoire. Ne pas exécuter en parallèle plusieurs serveurs sur le Mac 8 Go.
|
||||
3. Si un refus subsiste, tracer yeux, portée, `player.pick(...)`, bloc/face/distance,
|
||||
`BuildSelection.aimed`, puis nombre de cibles : ne pas augmenter arbitrairement
|
||||
la portée ou neutraliser une validation.
|
||||
4. Confirmer les assertions suivantes : une fois la première barrière levée, des
|
||||
défauts secondaires peuvent devenir visibles. Garder un scénario séparé de refus
|
||||
hors portée à la limite, sans le confondre avec fusion ou conservation d’objets.
|
||||
|
||||
Risque du patch faible et limité à la géométrie de test. Risque de modifier la
|
||||
production maintenant inutilement élevé : cela modifierait le contrat de portée
|
||||
pour contourner un scénario préparé avec l’ancien contrat.
|
||||
|
||||
## Résultat de l’intégration
|
||||
|
||||
Le patch proposé a ensuite été intégré aux tests par l’agent principal.
|
||||
Le rejeu complet `build/test-refresh-full.log` termine avec 252/252 GameTests
|
||||
réussis et `check build` réussi. Aucun code de production ni monde existant modifié.
|
||||
Voir [la validation consolidée](actualisation-tests-beta165.md).
|
||||
@@ -0,0 +1,79 @@
|
||||
# Audit dragon — beta.165
|
||||
|
||||
## Verdict
|
||||
|
||||
L’échec de `Companion019GameTests.creatureProfilesActuallyMove` est expliqué par
|
||||
une simulation incomplète du cycle natif de l’entité. Il ne démontre pas un
|
||||
blocage du dragon en jeu. Aucune correction de locomotion ne se justifie avant
|
||||
le rejeu du scénario avec son horloge d’entité effective.
|
||||
|
||||
## Chaîne causale confirmée
|
||||
|
||||
1. [Le test](../mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Companion019GameTests.java)
|
||||
appelle `pet.tick()` 120 fois immédiatement, au tick GameTest 0. Le journal
|
||||
`build/beta165-check-build.log` confirme l’assertion de déplacement échouée au tick 0.
|
||||
2. Dans Minecraft **26.3**, `ServerLevel.tickNonPassenger(Entity)` appelle
|
||||
**`Entity.commonTick()` puis `Entity.tick()`**. L’incrément `tickCount++` se trouve
|
||||
dans `commonTick`, avec la mise à jour des anciennes positions et du délai
|
||||
d’invulnérabilité ; il ne se trouve pas dans `Entity.tick()` ou `baseTick()`.
|
||||
Vérification directe du bytecode du JAR natif local par `javap -c -p` :
|
||||
`build/audit-dragon-serverlevel.txt` (méthode `tickNonPassenger`),
|
||||
`build/audit-dragon-entity.txt` (méthode `commonTick`).
|
||||
3. Au premier tick, [FamiliarEntity.configureMotion](../mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/FamiliarEntity.java)
|
||||
choisit `FlyingPathNavigation`, `FlyingMoveControl`, désactive la gravité et
|
||||
appelle `stopRoaming()`.
|
||||
4. [FamiliarRoam.reset](../mods/sanctuary/src/main/java/fr/koka/sanctuary/familiar/FamiliarRoam.java)
|
||||
fixe `nextChoice = pet.tickCount + 20`. L’errance lit **pet.tickCount**, pas
|
||||
`level.getGameTime()`. Les 120 appels directs laissent donc `now = 0` et
|
||||
`nextChoice = 20`. La condition `if(now < nextChoice) return` interdit toute
|
||||
première destination.
|
||||
5. Le familier apparaît déjà près du joueur, à environ +2 blocs en Y grâce à
|
||||
`teleportNearOwner`. Il n’atteint pas le seuil de retour vers le propriétaire
|
||||
qui pourrait contourner cette attente. Aucun déplacement vers une destination
|
||||
n’est donc engagé dans ce scénario.
|
||||
|
||||
L’hypothèse initiale d’une simple dépendance au temps du monde était trop vague :
|
||||
la cause directe est l’absence de **commonTick**, et donc le compteur d’entité figé.
|
||||
|
||||
## Correction de test recommandée
|
||||
|
||||
Pour ce test synchrone de locomotion, remplacer les appels de simulation par
|
||||
`h.getLevel().tickNonPassenger(pet)` ; cela reproduit le préambule natif complet,
|
||||
contrairement à un simple `pet.tickCount++`. Conserver les assertions sur le profil,
|
||||
le déplacement supérieur à un bloc, l’altitude et l’absence de collision.
|
||||
Ajouter une assertion du nombre de ticks réellement avancés afin d’éviter une
|
||||
régression de la fixture. Rejouer les vérifications Ghast et Wither, masquées
|
||||
jusqu’ici par l’échec dragon.
|
||||
|
||||
Le correctif proposé est disponible dans `build/audit-dragon.patch`, sans avoir
|
||||
été appliqué aux sources par cet audit. Cette boucle reste synchrone : le temps
|
||||
du monde, les hooks serveur et le propriétaire ne progressent pas. Elle vérifie
|
||||
le cycle de locomotion de l’entité, pas une séance complète de suivi en jeu.
|
||||
|
||||
Un scénario supplémentaire de suivi en ticks réels est utile si ce premier rejeu
|
||||
échoue : propriétaire déplacé sans dépasser le seuil de téléportation, personnage
|
||||
et graine d’errance fixés, trajet effectivement parcouru contrôlé. Il doit vivre
|
||||
dans un monde de test jetable, jamais dans la sauvegarde du laboratoire.
|
||||
|
||||
## Aléa et limites
|
||||
|
||||
Le tempérament provient du UUID du lien, créé aléatoirement. Les rayons d’errance
|
||||
sont 2,5 / 4 / 5 blocs et les pauses 100–139 / 25–64 / 50–89 ticks. Ces pauses
|
||||
sont intentionnelles : ne pas imposer un mouvement à chaque tick. Pour une
|
||||
reproduction déterministe, fixer la graine du générateur de l’entité et le
|
||||
tempérament dans les données du test, ou tester explicitement les trois.
|
||||
|
||||
Les destinations nécessitent des chunks chargés, une autorisation de mouvement,
|
||||
un volume libre et un chemin accessible. Aucun de ces refus n’a été démontré
|
||||
ici : l’ancien scénario s’arrêtait **avant** leur évaluation.
|
||||
|
||||
Audit statique et lecture du bytecode terminés. À ce stade, aucun serveur de test
|
||||
supplémentaire lancé, aucune source de jeu modifiée, aucune sauvegarde touchée.
|
||||
Le passage effectif du test corrigé reste à confirmer dans l’exécution coordonnée.
|
||||
|
||||
## Résultat de l’intégration
|
||||
|
||||
Le patch proposé a ensuite été intégré aux tests par l’agent principal.
|
||||
Le rejeu complet `build/test-refresh-full.log` termine avec 252/252 GameTests
|
||||
réussis et `check build` réussi. Aucun code de production ni monde existant modifié.
|
||||
Voir [la validation consolidée](actualisation-tests-beta165.md).
|
||||
@@ -0,0 +1,100 @@
|
||||
# Audit des 23 échecs GameTest — socle beta.165
|
||||
|
||||
Audit initial du 24 septembre 2026, code `dbb9e34` (tag beta.165).
|
||||
|
||||
Suite : [actualisation des tests et validation](actualisation-tests-beta165.md).
|
||||
|
||||
## Portée et méthode
|
||||
|
||||
Lecture des 23 messages du dernier journal `build/beta165-check-build.log`, des
|
||||
méthodes de test et des chemins de production concernés. La liste a été comparée
|
||||
à beta.164 : mêmes 23 identifiants. Recompte local des JSON de collections.
|
||||
Aucun test désactivé, aucune correction de code, aucune modification du monde,
|
||||
aucun nouveau lancement Minecraft pendant cet audit. Le laboratoire reste disponible.
|
||||
|
||||
Il s’agit d’un diagnostic statique étayé par l’exécution précédente, pas d’une
|
||||
preuve que les tests passeront après adaptation. Un échec à la première assertion
|
||||
masque potentiellement des problèmes dans la suite de la même méthode.
|
||||
|
||||
## Conclusion
|
||||
|
||||
**19 échecs ont une explication étayée dans le scénario de test ou un ancien
|
||||
contrat ; 4 nécessitent encore une reproduction ciblée.** Ce ne sont donc pas
|
||||
23 bugs joueurs démontrés. Inversement, la stabilité de la liste ne prouve pas
|
||||
l’absence de bugs. Les objectifs des tests restent majoritairement pertinents.
|
||||
|
||||
| Famille | Nombre | Lecture principale |
|
||||
| --- | ---: | --- |
|
||||
| Placement simple / empreinte / tombe | 5 | Positions de test hors portée |
|
||||
| Menus d’inventaire | 3 | Constructeur incompatible avec les menus étendus |
|
||||
| Bonus de familiers | 6 | Mode travail absent des fixtures |
|
||||
| Invulnérabilité et poisson familier | 2 | Anciennes règles remplacées par le système de combat |
|
||||
| Collections, permissions, hauteur du monde | 3 | Attentes historiques à remettre à jour |
|
||||
| Construction dalle/coffre | 2 | Raycast et portée à instrumenter |
|
||||
| Déplacement du dragon | 1 | Ticks réels et navigation à reproduire |
|
||||
| Hydrologie | 1 | Contrat de support naturel à examiner |
|
||||
|
||||
## Inventaire exhaustif
|
||||
|
||||
| Test | Verdict | Preuve et limite | Suite pertinente |
|
||||
| --- | --- | --- | --- |
|
||||
| [BlockKnowledgeGameTests.placementCountsConfirmedStatesAndRejectsFailure](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/BlockKnowledgeGameTests.java:44) | Test mal positionné — forte confiance | Joueur en (5,5 ; 3 ; 5,5), support en (1 ; 1 ; 1) : la distance horizontale seule dépasse 5,65 blocs. Le mixin de placement applique la portée réelle (vanilla ou progression), avant les assertions métier. | Rapprocher le joueur et viser la face réelle ; conserver les assertions de compteurs, événements et empreinte. Ajouter/conserver un cas hors portée refusé. |
|
||||
| [BlockKnowledgeGameTests.listenersReceiveUpdatedProgressionAndStockChanges](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/BlockKnowledgeGameTests.java:220) | Test mal positionné — forte confiance | Joueur en (5,5 ; 3 ; 5,5), support en (1 ; 1 ; 1) : la distance horizontale seule dépasse 5,65 blocs. Le mixin de placement applique la portée réelle (vanilla ou progression), avant les assertions métier. | Rapprocher le joueur et viser la face réelle ; conserver les assertions de compteurs, événements et empreinte. Ajouter/conserver un cas hors portée refusé. |
|
||||
| [MaterialActivityGameTests.nativeActionsProduceDatedDeltasWhileObservationsProduceNone](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/MaterialActivityGameTests.java:39) | Test mal positionné — forte confiance | Joueur en (5,5 ; 3 ; 5,5), support en (1 ; 1 ; 1) : la distance horizontale seule dépasse 5,65 blocs. Le mixin de placement applique la portée réelle (vanilla ou progression), avant les assertions métier. | Rapprocher le joueur et viser la face réelle ; conserver les assertions de compteurs, événements et empreinte. Ajouter/conserver un cas hors portée refusé. |
|
||||
| [Demeure006GameTests.confirmedGesturesAndPersonalAtlasBoundary](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Demeure006GameTests.java:20) | Test mal positionné — forte confiance | Joueur en (5,5 ; 3 ; 5,5), support en (1 ; 1 ; 1) : la distance horizontale seule dépasse 5,65 blocs. Le mixin de placement applique la portée réelle (vanilla ou progression), avant les assertions métier. | Rapprocher le joueur et viser la face réelle ; conserver les assertions de compteurs, événements et empreinte. Ajouter/conserver un cas hors portée refusé. |
|
||||
| [GravesFood038GameTests.ordinaryPlacementAndCreativeBreakPreserveGrave](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/GravesFood038GameTests.java:120) | Test mal positionné — forte confiance | Joueur novice à 3 blocs horizontalement du clic, yeux au-dessus : distance supérieure aux 3 blocs de portée initiale. Le test échoue avant la conservation des composants et la casse créative. | Remettre le clic à portée, puis vérifier impérativement les 8 diamants après pose et casse ; le résultat actuel ne prouve aucune perte d’objets. |
|
||||
| [Inventory010GameTests.machineQuickMovesAndMounts](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Inventory010GameTests.java:77) | Initialisation de test incompatible — certaine | Boucle sur tout BuiltInRegistries.MENU et appel create(id, inventory). Des menus Sanctuary sont ExtendedMenuType et exigent des données supplémentaires ; exception avant les comparaisons. | Séparer menus vanilla et menus étendus ; fournir les données requises aux menus Sanctuary, garder les tests de conservation et de shift-clic. |
|
||||
| [Inventory010GameTests.everyNativeContainerKeepsItsIndices](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Inventory010GameTests.java:58) | Initialisation de test incompatible — certaine | Boucle sur tout BuiltInRegistries.MENU et appel create(id, inventory). Des menus Sanctuary sont ExtendedMenuType et exigent des données supplémentaires ; exception avant les comparaisons. | Séparer menus vanilla et menus étendus ; fournir les données requises aux menus Sanctuary, garder les tests de conservation et de shift-clic. |
|
||||
| [Inventory012GameTests.nativeAndExtraRowsHaveIdenticalMachinePriorities](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Inventory012GameTests.java:38) | Initialisation de test incompatible — certaine | Même boucle sur tous les menus et même constructeur sans données réseau. | Même adaptation commune ; comparer les destinations et reliquats des lignes natives et supplémentaires. |
|
||||
| [Companion019GameTests.switchingEggRemovesPassiveAndActive](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Companion019GameTests.java:45) | Contrat du scénario périmé — forte confiance | L’attribut de chute du chat n’est pas actif dans le mode de combat par défaut. Le helper equip règle l’attunement, mais FamiliarBattle.prepare initialise workMode=false ; CompanionService.passive renvoie alors null. | Préparer explicitement le mode travail par le chemin prévu, puis tester aussi l’absence de bonus en mode combat. Rejouer le reste du scénario : ses assertions suivantes restent non validées. |
|
||||
| [Companion019GameTests.poisonDurationAndMilkUseNativeHooks](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Companion019GameTests.java:53) | Contrat du scénario périmé — forte confiance | La réduction de poison est attendue sans sélectionner le mode travail. Le helper equip règle l’attunement, mais FamiliarBattle.prepare initialise workMode=false ; CompanionService.passive renvoie alors null. | Préparer explicitement le mode travail par le chemin prévu, puis tester aussi l’absence de bonus en mode combat. Rejouer le reste du scénario : ses assertions suivantes restent non validées. |
|
||||
| [Companion019GameTests.cropCyclesStopAfterOwnerLeaves](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Companion019GameTests.java:152) | Contrat du scénario périmé — forte confiance | La préparation BEE n’active pas le mode travail nécessaire au bonus de culture. Le helper equip règle l’attunement, mais FamiliarBattle.prepare initialise workMode=false ; CompanionService.passive renvoie alors null. | Préparer explicitement le mode travail par le chemin prévu, puis tester aussi l’absence de bonus en mode combat. Rejouer le reste du scénario : ses assertions suivantes restent non validées. |
|
||||
| [Companion019GameTests.furnaceBonusConsumesFuelAndKeepsOneOutput](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Companion019GameTests.java:163) | Contrat du scénario périmé — forte confiance | La préparation BLAZE n’active pas le mode travail nécessaire au bonus de cuisson. Le helper equip règle l’attunement, mais FamiliarBattle.prepare initialise workMode=false ; CompanionService.passive renvoie alors null. | Préparer explicitement le mode travail par le chemin prévu, puis tester aussi l’absence de bonus en mode combat. Rejouer le reste du scénario : ses assertions suivantes restent non validées. |
|
||||
| [Companion032GameTests.axolotlFoodDurationAndGolemKnockbackUseNativeHooks](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Companion032GameTests.java:77) | Contrat du scénario périmé — forte confiance | Le bonus de durée de consommation est attendu hors du mode autorisant les anciens passifs. Le helper equip règle l’attunement, mais FamiliarBattle.prepare initialise workMode=false ; CompanionService.passive renvoie alors null. | Préparer explicitement le mode travail par le chemin prévu, puis tester aussi l’absence de bonus en mode combat. Rejouer le reste du scénario : ses assertions suivantes restent non validées. |
|
||||
| [GravesFood038GameTests.familiarSaturationPreviewUsesActualServerPassive](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/GravesFood038GameTests.java:130) | Contrat du scénario périmé — forte confiance | L’assertion passive(p)==species échoue avant de comparer l’aperçu et la consommation. Le helper equip règle l’attunement, mais FamiliarBattle.prepare initialise workMode=false ; CompanionService.passive renvoie alors null. | Préparer explicitement le mode travail par le chemin prévu, puis tester aussi l’absence de bonus en mode combat. Rejouer le reste du scénario : ses assertions suivantes restent non validées. |
|
||||
| [Accessory018GameTests.familiarIsHarmlessAndEscapesWalls](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Accessory018GameTests.java:82) | Ancien contrat invulnérable — forte confiance | Le test exige que genericKill ne cause aucun dégât. hurtServer délègue désormais à FamiliarBattle.hurt ; le système possède santé et K.-O. depuis beta.054. | Remplacer l’invulnérabilité universelle par les règles actuelles : dégâts autorisés, coups amicaux, K.-O., œuf préservé. Conserver les vérifications de collisions, sortie de mur et non-duplication. |
|
||||
| [Companion019GameTests.aquaticPetFlopsAndThenSwims](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Companion019GameTests.java:113) | Ancien contrat de déplacement — forte confiance | Le test exige une vitesse ≤ 0,06 sur terre. configureMotion réserve le mode poisson ralenti aux familiers aquatiques sans profil de combat actif ; sinon la vitesse vient du profil. | Tester séparément le profil actuel sur terre et la transition dans l’eau. Ne pas rétablir automatiquement l’ancien poisson ralenti. |
|
||||
| [Companion019GameTests.creatureProfilesActuallyMove](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Companion019GameTests.java:104) | À isoler — navigation | Le profil aérien est reconnu ; c’est le déplacement > 1 et l’altitude > propriétaire + 0,5 qui échouent. Le test appelle pet.tick 120 fois sans faire avancer normalement le monde ; l’errance actuelle dépend de destinations sûres, du pathfinding et comporte des pauses. | Reproduire dans un monde de test avec ticks réels, graine/personnalité fixées, propriétaire déplacé, cible et chemin enregistrés. Si l’immobilité persiste, corriger la navigation. |
|
||||
| [Collections035GameTests.exhaustiveCatalogueMatchesLoadedVanilla](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Collections035GameTests.java:17) | Constantes périmées — certaine | Le dernier comptage attend 1658 items / 1286 blocs / 1815 identifiants distincts. Recompte des 67 JSON : 1780 / 1374 / 1937. Les 2042 recettes uniques sont toujours présentes ; le test a déjà passé les contrôles précédents des identifiants et recettes. | Comparer la couverture aux registres et au contrat de collections ; documenter les nombres actuels, éviter qu’un total historique soit l’unique preuve d’exhaustivité. |
|
||||
| [Progression003GameTests.serverCustomNameConfiguration](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Progression003GameTests.java:120) | Permission testée au mauvais niveau — certaine | Le test exige que toute la racine /sanctuary soit inaccessible aux joueurs. intro et community link sont maintenant publics ; names et community admin portent leur propre condition opérateur. | Tester un joueur ordinaire et un opérateur sur chaque sous-commande sensible. Vérifier names reload en particulier ; ne pas rebloquer toute la racine. |
|
||||
| [UnifiedWorldGameTests.populationTerrainAndNaturalSpawnRemainPresent](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/UnifiedWorldGameTests.java:56) | Hauteur historique périmée — certaine | Attend minY=0 et hauteur=384. La dimension Sanctuary actuelle déclare hauteur et logical_height=640, minY=0 ; le test client de création attend lui aussi 640. | Actualiser le contrat de hauteur, puis rejouer les assertions suivantes sur le spawn naturel. Ne pas modifier la dimension ni les mondes existants. |
|
||||
| [Building009GameTests.slabMergingCeilingAndEntityCollisions](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Building009GameTests.java:107) | À isoler — construction groupée | Échec au premier start (ligne 113), avant fusion des dalles. Le fixture vise le centre d’une dalle basse depuis une position conçue pour un cube plein ; la portée au rang 1 est 3,5 et le point visé sur la dalle est plus éloigné. | Journaliser hit réel, bloc touché, distance, portée et raison de refus ; reproduire à portée courte puis à la limite. Conserver fusion, plafond et exclusion des entités. |
|
||||
| [Building009GameTests.containerSupportsAndUnsupportedItems](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Building009GameTests.java:165) | À isoler — construction groupée | Échec au premier start sur coffre (ligne 170), avant toute vérification du contenu. Le coffre n’a pas la collision d’un cube plein ; le raycast peut toucher le support voisin ou dépasser la portée. | Même diagnostic que la dalle ; vérifier que le coffre ne s’ouvre pas et garde ses 3 diamants. Ne pas assimiler l’échec à une corruption d’inventaire. |
|
||||
| [UnifiedWorldGameTests.retainedHydrologySurvivesNativeDecoration](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/UnifiedWorldGameTests.java:39) | À isoler — génération, priorité haute | Échec dans PopulationDiagnostics.checkOwnership sur la densité naturelle du support à x=-141, z=-86, bedY=238, sedimentDepth=0, waterY=-1 (terrasse sèche). Ce contrôle survient avant la vérification finale des blocs décorés ; son nom ne prouve donc pas une fuite d’eau en jeu. | Comparer l’admission de cette cellule et la densité réellement utilisée, puis les blocs finaux dans un monde jetable, graine 0 / 10 joueurs / diamètre 724. Le contrat Cell annonce un support naturel : ne pas supprimer cette assertion sans explication. |
|
||||
|
||||
## Priorités proposées
|
||||
|
||||
1. **Remettre les tests en mesure de vérifier leur sujet** : placements à portée,
|
||||
construction correcte des menus, sous-commandes protégées, inventaire de
|
||||
collections et hauteur 640. Changements de tests ciblés, sans affaiblir leurs
|
||||
vérifications métier. Les tombes et transferts d’inventaire sont prioritaires
|
||||
parce qu’ils protègent les objets des joueurs.
|
||||
2. **Isoler dalle/coffre et hydrologie** : les deux premiers concernent une action
|
||||
courante ; le dernier touche la génération et exige un monde jetable, jamais
|
||||
une régénération de la sauvegarde de test actuelle. Aucun changement de
|
||||
génération n’est autorisé implicitement par cet audit.
|
||||
3. **Actualiser les scénarios familiers** selon le mode travail/combat, puis
|
||||
reproduire le vol du dragon avec une horloge de monde réelle. Garder les
|
||||
contrôles d’anti-duplication, de ressources consommées et d’annulation.
|
||||
4. Rejouer les familles corrigées, puis la suite complète. Toute nouvelle
|
||||
assertion atteinte doit être réévaluée ; ne pas annoncer « 19 réglés » avant cela.
|
||||
|
||||
La correction des fixtures et constantes paraît contenue. Les quatre cas à
|
||||
reproduire ne permettent pas encore une estimation fiable. Aucune suppression de
|
||||
fonctionnalité ni suppression de test n’est recommandée à ce stade.
|
||||
|
||||
## Points d’entrée dans le code
|
||||
|
||||
- [BlockPlacementReachMixin](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/BlockPlacementReachMixin.java:14)
|
||||
- [BuildingLimits](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/building/BuildingLimits.java:6)
|
||||
- [CompanionService](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/companion/CompanionService.java:71)
|
||||
- [FamiliarBattle](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/familiar/FamiliarBattle.java:125)
|
||||
- [FamiliarEntity](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/FamiliarEntity.java:262)
|
||||
- [FamiliarRoam](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/familiar/FamiliarRoam.java:16)
|
||||
- [Statuary](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/Statuary.java:39)
|
||||
- [Multiblocks](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/Multiblocks.java:36)
|
||||
- [ProgressionService](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/progression/ProgressionService.java:178)
|
||||
- [PopulationDiagnostics](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java:333)
|
||||
- [PopulationHydrology](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationHydrology.java:56)
|
||||
|
||||
Contrat fonctionnel des familiers : [beta.054](familiar-combat-beta054.md).
|
||||
@@ -0,0 +1,26 @@
|
||||
# beta.165 — Gazette en article et conversation
|
||||
|
||||
Photo en tête, titre et description dessous dans le même conteneur sombre.
|
||||
Auteur, visage, date et actions sous le conteneur. Réponses à droite avec
|
||||
défilement indépendant et saisie fixe ; sur GUI étroit, réponses sous l’article.
|
||||
Retour et Actualiser restent en haut, comme pour le tableau.
|
||||
|
||||
Interface cliente uniquement, sans changement de stockage ni de monde.
|
||||
Test client Vulkan réussi en FR/EN aux échelles GUI 2, 3 et 4 :
|
||||
position du panneau de réponses, repli sous l’article, défilement disponible,
|
||||
brouillon conservé après redimensionnement et réponse envoyée puis relue.
|
||||
Le scénario existant du tableau et celui du menu pause passent également.
|
||||
Captures conservées dans `build/qa-beta165/` (image synthétique de test).
|
||||
Inspection visuelle FR GUI 2 et GUI 4 effectuée.
|
||||
Journal : `build/beta165-client.log`.
|
||||
|
||||
`check build assemblePack --continue` exécuté : mêmes 23 GameTests en échec
|
||||
qu’en beta.164, aucun nouvel échec. Comparaison enregistrée dans
|
||||
`build/session165-server-failures.json`. La vérification générale reste en échec.
|
||||
Assemblage séparé réussi avec `build assemblePack :sanctuary-test:exportDuoLaunch
|
||||
-x :sanctuary:check` après cette vérification.
|
||||
|
||||
JAR local : `mods/sanctuary/build/libs/sanctuary-beta.165.jar`.
|
||||
SHA-256 : `03a954caf68089862b1d237dcf1ebaa501e3ce3cfedb1f5184e9412e73fd711d`.
|
||||
Anciens JAR conservés ; aucune publication distante.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# beta.166 — inscription web et reprise de l’accueil
|
||||
|
||||
Intégration de la PR #1 de Chris (`8de569b`), avec son historique conservé par
|
||||
le merge `ad960ef`, sur beta.165 et ses 252 GameTests actualisés.
|
||||
|
||||
## Parcours
|
||||
|
||||
Le joueur ouvre son dossier `/candidature` sur le site, se connecte à Discord,
|
||||
dépose sa candidature et récupère le code après acceptation/parrainage. Le
|
||||
bouton **Récupérer mon code** de Hello World ouvre ce dossier, sur le site
|
||||
configuré par le serveur, avec la confirmation de lien native de Minecraft.
|
||||
Le code reste obligatoire avant la création du personnage. Gazette et tableau
|
||||
restent en consultation seule sur le site.
|
||||
|
||||
Sans configuration d’accès, le mod autonome garde l’accueil sans code.
|
||||
Avec une configuration partielle/invalide, l’accueil reste fermé. Un habitant
|
||||
déjà arrivé n’a pas à présenter de code ni à connaître le nouveau payload d’accès.
|
||||
Les autres contrôles de compatibilité Sanctuary restent appliqués.
|
||||
|
||||
## Contrat API de reprise
|
||||
|
||||
Les POST authentifiés `/api/v1/access-codes/verify` et `/redeem` reçoivent :
|
||||
`code`, `minecraft_username` et `admission_id` (UUID). Le serveur calcule cette
|
||||
admission à partir d’un identifiant de monde persistant et de l’UUID du joueur ;
|
||||
le client ne la choisit jamais. Un autre joueur ou un autre monde possède une
|
||||
admission différente, même à graine identique.
|
||||
|
||||
La consommation en base conserve `used_at` et `admission_id` dans la même
|
||||
transaction verrouillée. Une répétition de la même admission et du même pseudo
|
||||
retourne `valid: true, reason: resumed`, le pseudo, le Discord et l’admission.
|
||||
`verify` accepte aussi cette reprise ; le bouton de confirmation redevient
|
||||
accessible. Une autre admission retourne `already_used` sans identité. Un pseudo
|
||||
incorrect ou un code révoqué reste refusé. Les codes anciennement consommés sans
|
||||
reçu restent refusés ; leur récupération nécessite une réémission par le staff.
|
||||
|
||||
Le mod exige une réponse positive portant son admission exacte. Il n’accepte
|
||||
plus `already_used` sur la seule présence d’un pseudo et d’un Discord. Une
|
||||
réponse positive arrivée après déconnexion est quand même enregistrée, mais ne
|
||||
crée pas le personnage hors connexion. Si la réponse est entièrement perdue,
|
||||
le même code est vérifiable et consommable à nouveau au prochain accueil.
|
||||
Le reçu local sauvegardé permet de terminer une création déjà autorisée.
|
||||
Les erreurs réseau et de jeton proposent une nouvelle vérification.
|
||||
|
||||
## Migration et activation
|
||||
|
||||
1. Sauvegarder la base web et les fichiers de progression du serveur.
|
||||
2. Appliquer la migration additive web `add_admission_receipt_to_access_codes_table`
|
||||
(colonne UUID nullable, aucune donnée supprimée), puis déployer le service API.
|
||||
3. Installer beta.166 sur serveur et clients des nouveaux arrivants.
|
||||
4. Configurer `SANCTUARY_APP_URL` et `SANCTUARY_API_TOKEN`, ou
|
||||
`config/sanctuary/access.json` (`appUrl`, `token`). Le jeton reste serveur.
|
||||
Utiliser HTTPS hors du labo local et un serveur Minecraft en online-mode pour
|
||||
authentifier les UUID ; le labo offline ne prouve pas l’identité d’un compte.
|
||||
|
||||
Dans le monde, `data/sanctuary-admission-id` est créé atomiquement avant tout
|
||||
appel API et doit être sauvegardé avec `data/sanctuary-access.json`. Aucun code
|
||||
brut ni jeton n’est enregistré dans ces fichiers. Leur corruption ferme l’accès
|
||||
et conserve les preuves. Les formats habitants/starter et les chunks restent
|
||||
inchangés. Ne pas supprimer l’identifiant d’admission pour « réparer » un accès.
|
||||
|
||||
Retour arrière : conserver la colonne web et les reçus, désactiver explicitement
|
||||
l’accès si l’on revient à un ancien mod (cela réouvre l’accueil sans code).
|
||||
Ne pas exécuter le rollback de migration après consommation : il supprimerait
|
||||
les reçus nécessaires à la reprise. Pas de déploiement public implicite.
|
||||
|
||||
## Laboratoire
|
||||
|
||||
Le monde beta.160 existant est conservé. La reprise de l’introduction utilise
|
||||
un nouveau monde de développement ; les réglages graphiques et anciens JAR sont
|
||||
conservés. `scripts/duo_lab.py server --intro` joue la vraie introduction sur le
|
||||
monde plat ; sans cette option, le profil de test continue de la sauter.
|
||||
|
||||
## Vérifications
|
||||
|
||||
- `check build assemblePack` réussit (10 min 12 s), avec **252/252 GameTests**.
|
||||
Trace : `build/access166-full.log`.
|
||||
- Smoke du contrat et du ledger : reprise, mauvais compte, mauvais reçu, absence
|
||||
de reçu, isolation entre mondes de même graine, persistance et corruption.
|
||||
- `access166Interop` réussit contre la vraie API PHP/MariaDB locale : réponse de
|
||||
consommation ignorée, nouveau ledger, nouvelle vérification et confirmation,
|
||||
refus d’un autre joueur et d’une autre admission (`build/access166-interop.log`).
|
||||
- Test client Vulkan : vrai handshake Hello World, endpoint HTTP contrôlé qui
|
||||
consomme puis répond 503, nouvelle vérification, confirmation et un seul reçu
|
||||
bound. Ce test n’utilise pas Discord et ne prétend pas vérifier OAuth.
|
||||
- Site : 12 tests inscription/candidature (68 assertions), puis 31 tests ciblés
|
||||
inscription/redirection Discord/communauté (258 assertions), tous réussis.
|
||||
Pint passe. Suite web entière : 74 réussis, 1 ignoré et 1 échec préexistant
|
||||
`PlayerModerationTest::test_an_administrator_can_open_a_player_profile`
|
||||
(ancien libellé « Nommer admin »). Reproduit dans un instantané de HEAD avant
|
||||
modification ; pas une régression de l’inscription.
|
||||
- Base locale sauvegardée avant la migration, puis migration additive appliquée.
|
||||
Le vrai OAuth Discord reste à valider avec les identifiants de l’application ;
|
||||
aucun contournement d’authentification ou compte de démonstration pour le joueur
|
||||
n’est installé. Un compte API jetable distinct sert uniquement à l’interop.
|
||||
- Aucun déploiement sur sanctuary-minecraft.net, aucun push/fusion distante,
|
||||
aucune modification de l’ancienne sauvegarde du labo ni des JAR archivés.
|
||||
|
||||
Après la passe complète, le retour immédiat de la vérification en cache a été
|
||||
ajusté pour qu’une nouvelle tentative ne reste pas sans réponse pendant la
|
||||
limite d’une seconde. Le scénario client exerce précisément cette reprise.
|
||||
`check build assemblePack` a ensuite réussi avec les 6 GameTests de progression
|
||||
ciblés (`build/access166-final.log`). La disposition compacte a été resserrée
|
||||
pour garder tous les contrôles dans un GUI de 240 pixels de haut ; ses captures
|
||||
FR/EN et assertions de limites sont vérifiées par le scénario client.
|
||||
|
||||
Le correctif web est enregistré dans `f36cc36`. Les identifiants OAuth fournis
|
||||
par l’utilisateur sont chargés depuis le `.env` privé et la redirection réelle
|
||||
vers Discord fonctionne avec le callback local. Le retour authentifié reste
|
||||
à confirmer après la connexion/autorisation de l’utilisateur.
|
||||
|
||||
La livraison finale et les captures compactes passent dans
|
||||
`build/access166-delivery.log` : scénario client Vulkan FR/EN et assemblage du
|
||||
pack. Cette dernière commande exclut `:sanctuary:check` pour éviter une troisième
|
||||
exécution générale après les deux contrôles réussis ci-dessus ; elle ne remplace
|
||||
pas ces résultats. La seule modification de jeu depuis le contrôle ciblé est
|
||||
la disposition compacte, couverte par ce dernier test client.
|
||||
|
||||
Le labo reste arrêté, configuré pour `duo-registration-166`. L'ancien
|
||||
`duo-flat-160` n'est pas modifié. Lancement prévu après connexion au dossier web :
|
||||
`python3 scripts/duo_lab.py server --memory 768 --intro`, puis le client habituel.
|
||||
@@ -0,0 +1,66 @@
|
||||
# beta.162 — scène du tableau et conversation
|
||||
|
||||
Ticket issu du retour de séance du 24 septembre : conserver Retour/Actualiser en
|
||||
haut, montrer l’auteur avec son skin orienté vers la souris et sa demande dans
|
||||
une bulle colorée, puis les réponses à droite (dessous sur GUI étroit).
|
||||
|
||||
Modification client uniquement, sans migration, écriture dans les mondes ni
|
||||
changement des contrats fichier/MariaDB. Réponses, modération et suivi gardent
|
||||
leurs validations serveur. Le portrait est un rendu GUI, pas une entité.
|
||||
|
||||
## Présentation
|
||||
|
||||
- Retour et Actualiser restent hors de la zone défilante.
|
||||
- Le portrait utilise le skin de la connexion de l’auteur s’il est présent,
|
||||
sinon le cache de profils Minecraft asynchrone. Le skin natif de repli apparaît
|
||||
pendant le chargement ou si le profil ne peut pas être résolu (notamment les
|
||||
comptes fictifs/hors ligne). Aucun téléchargement synchrone dans le rendu.
|
||||
- Une bulle persistante encadrée montre catégorie/état, titre puis texte complet.
|
||||
Information : jaune clair sur fond sombre jaune ; les autres catégories gardent
|
||||
leurs couleurs. Les cartes de liste et du menu pause suivent aussi ce jaune.
|
||||
- Le portrait tourne doucement dans des angles bornés selon la position de la
|
||||
souris, sans capturer ses mouvements et sans clic requis.
|
||||
- Demande et discussion défilent séparément si l’espace le permet ; le champ de
|
||||
réponse et son bouton restent en bas de la discussion. Sur GUI étroit/bas,
|
||||
un défilement unique garde les réponses sous la scène.
|
||||
- Matériaux, lieu, récompense, suivi, visages des réponses et modération sont
|
||||
conservés. Les détails d’une demande vide ne sont plus affichés artificiellement.
|
||||
- Aucune animation de parole ni synchronisation vocale : « parle » désigne ici
|
||||
la mise en scène visuelle avec la bulle.
|
||||
|
||||
## Vérification
|
||||
|
||||
- Compilation Java 25 / Minecraft 26.3 réussie.
|
||||
- Parcours natif Vulkan : `NOTICE162_SCENE_PASS`, puis
|
||||
`SEARCH159_CLIENT_PASS mode=file`, build réussi en 1 min 59 s.
|
||||
- FR/EN, GUI 2/3/4 : scène, passage en colonne unique, boutons de retour au-dessus,
|
||||
réponse au-dessus du footer en deux colonnes, angles du portrait bornés et
|
||||
changement d’orientation avec la souris.
|
||||
- Brouillon conservé pendant les redimensionnements ; publication d’une réponse
|
||||
et lecture de son contenu après rechargement serveur vérifiées.
|
||||
- Régressions communautaires : photos, recherche, suivi/désabonnement, marqueur
|
||||
de carte et infobulle, restrictions de l’Intendance toujours validés.
|
||||
- Captures relues : `build/qa-beta162/0042_notice162-scene-fr_fr-2.png` et
|
||||
`0043_notice162-scene-fr_fr-3.png`. Conversation : `0048_notice162-conversation.png`.
|
||||
- `./gradlew check build assemblePack` lancé : 252 GameTests terminés, 229 réussis
|
||||
et 23 échecs, exactement les mêmes qu’en beta.161 (aucun ajouté/retiré).
|
||||
Liste comparée : `build/session162-server-failures.json`.
|
||||
- Contrôles restants et assemblage avec exclusion ponctuelle de `runGameTest` :
|
||||
réussis en 3 min 16 s (`check build assemblePack -x :sanctuary:runGameTest`).
|
||||
Aucune assertion désactivée dans les sources. Log : `build/beta162-assembly.log`.
|
||||
|
||||
Aucune modification de persistance : cette passe native utilise le mode fichier ;
|
||||
le laboratoire MariaDB partagé a été relancé avec la même interface, KokaLab
|
||||
connecté sur le monde existant `duo-flat-160`, Alice/Bob recréés, shader désactivé. Les figurants
|
||||
et profils hors ligne peuvent afficher un skin Minecraft de repli.
|
||||
|
||||
## Livraison locale
|
||||
|
||||
JAR : `mods/sanctuary/build/libs/sanctuary-beta.162.jar`.
|
||||
Pack assemblé : `build/packwiz`.
|
||||
SHA-256 : `35c5d82eba110ee2150f73baf6341efc90f87971965a3b4dd2bcd04add0a59ff`.
|
||||
|
||||
Branche `codex/notice-scene-beta162`, tag local `beta.162`. Anciens JAR conservés.
|
||||
Aucune publication distante ni synchronisation de Prism dans cette livraison.
|
||||
Les 23 échecs préexistants restent ouverts ; les essais graphiques ne démontrent
|
||||
pas la résolution réseau d’un skin officiel absent dans ce laboratoire hors ligne.
|
||||
@@ -1,5 +1,13 @@
|
||||
# Distribution packwiz et Prism
|
||||
|
||||
## Rattrapage des releases du 24 septembre 2026
|
||||
|
||||
Les binaires conservés des beta.145–151 et beta.154–166 sont publiés dans Gitea.
|
||||
La beta.144 existante reste intacte ; les 152–153 demeurent des chantiers non livrés.
|
||||
[Inventaire, empreintes et limites](publications-beta144-166.md).
|
||||
Cette publication ne modifie ni le canal packwiz ni les installations Prism.
|
||||
|
||||
|
||||
Sanctuary Beta utilise une seule instance Prism, synchronisée avant chaque
|
||||
lancement par packwiz. Le canal reste à l'adresse :
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# beta.164 — calendrier compact et intendance alignée
|
||||
|
||||
Calendrier à la largeur de la navigation : jour agrandi et heure sur la première
|
||||
ligne, mois et année ensemble dessous. Le titre serveur est aligné à gauche
|
||||
et couvre la carte ainsi que les panneaux Gazette/Tableau.
|
||||
|
||||
Interface cliente uniquement. Aucun changement de stockage ou de monde.
|
||||
|
||||
Vérification client Vulkan réussie : FR/EN aux échelles GUI 2, 3 et 4.
|
||||
Le test contrôle la largeur du calendrier, l’heure dans son libellé accessible,
|
||||
les limites de l’intendance et les interactions communautaires existantes.
|
||||
Captures conservées dans `build/qa-beta164/` et journal dans
|
||||
`build/beta164-client-retry.log`. Inspection visuelle FR GUI 4 et EN GUI 2 effectuée.
|
||||
Le premier test a détecté que StringWidget redimensionnait le calendrier lors
|
||||
du changement de texte ; la largeur de navigation est maintenant réappliquée.
|
||||
|
||||
`check build assemblePack --continue` exécuté : les mêmes 23 GameTests qu’en
|
||||
beta.163 échouent, aucun nouvel échec (comparaison enregistrée dans
|
||||
`build/session164-server-failures.json`). La suite complète reste en échec.
|
||||
L’assemblage séparé `build assemblePack :sanctuary-test:exportDuoLaunch
|
||||
-x :sanctuary:check` réussit après ce contrôle.
|
||||
|
||||
Anciennes archives conservées ; aucune publication distante.
|
||||
JAR : `mods/sanctuary/build/libs/sanctuary-beta.164.jar`.
|
||||
SHA-256 : `5bdfb097004a7932a075fbce9013556266f76ddd491ca21a24e12609c8600e53`.
|
||||
@@ -0,0 +1,27 @@
|
||||
# beta.163 — cadre du menu pause
|
||||
|
||||
Retour de séance : Découverte/Progression séparés de Habitant/Factions/Combat,
|
||||
bouton Monde retiré, séparateurs natifs de Progression, reprise centrée dans le
|
||||
footer, calendrier à gauche, message serveur au centre et horloge à droite.
|
||||
|
||||
Interface cliente uniquement, sans migration ni modification de règles ou de
|
||||
mondes. « Options du monde » reste dans les réglages en bas à gauche ; seul le
|
||||
raccourci de navigation Monde disparaît. La carte centrale reste disponible.
|
||||
|
||||
Vérifications :
|
||||
- Test client natif Vulkan réussi : FR/EN aux échelles GUI 2, 3 et 4,
|
||||
calendrier, séparation des groupes, limites des widgets et reprise centrée.
|
||||
Captures dans `build/qa-beta163/` ; journal `build/beta163-client-retry.log`.
|
||||
- Le scénario communautaire vérifie aussi la scène du tableau et les échanges.
|
||||
Une première exécution a expiré pendant une publication ; le test respecte
|
||||
désormais le délai serveur entre deux écritures.
|
||||
- `check build assemblePack --continue` : 23 échecs GameTest, ensemble identique
|
||||
à beta.162, aucun nouvel échec. Les autres tâches ont terminé ; la suite
|
||||
complète reste donc en échec. Comparaison dans `build/session163-server-failures.json`.
|
||||
- Assemblage réussi avec `build assemblePack :sanctuary-test:exportDuoLaunch
|
||||
-x :sanctuary:check`, après la vérification complète.
|
||||
|
||||
JAR local : `mods/sanctuary/build/libs/sanctuary-beta.163.jar`.
|
||||
SHA-256 : `72df10885d1bcb3b623c46bddf361839c23f06dfd543119f91ac5e006a8f961e`.
|
||||
Aucune publication distante ; anciens JAR conservés.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Publications beta.144 à beta.166
|
||||
|
||||
Rattrapage des releases Gitea du 24 septembre 2026. La beta.144 était déjà publiée ; ses tags et fichiers restent inchangés. Les archives suivantes sont publiées avec leurs binaires conservés, sans reconstruire ni renuméroter les anciens mods.
|
||||
|
||||
| Version | Commit source | Fichiers (hors SHA-256) |
|
||||
| --- | --- | --- |
|
||||
| [beta.145](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.145) | `ed2d08827260` | sanctuary-beta.145.jar, Sanctuary-Test-beta.145.mrpack, Sanctuary-beta.145.mrpack, Sanctuary-Template-beta.145.zip |
|
||||
| [beta.146](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.146) | `cf9a670f4415` | sanctuary-beta.146.jar, Sanctuary-beta.146.mrpack, Sanctuary-Template-beta.146.zip |
|
||||
| [beta.147](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.147) | `96a36d99bea0` | sanctuary-beta.147.jar, Sanctuary-beta.147.mrpack, Sanctuary-Template-beta.147.zip |
|
||||
| [beta.148](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.148) | `6f871446bbbf` | sanctuary-beta.148.jar, Sanctuary-beta.148.mrpack, Sanctuary-Template-beta.148.zip |
|
||||
| [beta.149](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.149) | `1c23d0c17ec9` | sanctuary-beta.149.jar, Sanctuary-beta.149.mrpack, Sanctuary-Template-beta.149.zip |
|
||||
| [beta.150](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.150) | `448e2febff2a` | sanctuary-beta.150.jar, Sanctuary-beta.150.mrpack, Sanctuary-Template-beta.150.zip |
|
||||
| [beta.151](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.151) | `b97ec6c9e820` | sanctuary-beta.151.jar, Sanctuary-beta.151.mrpack, Sanctuary-Template-beta.151.zip |
|
||||
| [beta.154](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.154) | `0eb1a381d4d3` | sanctuary-beta.154.jar, Sanctuary-beta.154.mrpack, Sanctuary-Template-beta.154.zip |
|
||||
| [beta.155](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.155) | `3bfb3cb251a1` | sanctuary-beta.155.jar, Sanctuary-beta.155.mrpack, Sanctuary-Template-beta.155.zip |
|
||||
| [beta.156](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.156) | `32158e2e0513` | sanctuary-beta.156.jar, Sanctuary-beta.156.mrpack, Sanctuary-Template-beta.156.zip |
|
||||
| [beta.157](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.157) | `9791d178ab5e` | sanctuary-beta.157.jar, Sanctuary-beta.157.mrpack, Sanctuary-Template-beta.157.zip |
|
||||
| [beta.158](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.158) | `19aa8054a52f` | sanctuary-beta.158.jar, Sanctuary-beta.158.mrpack, Sanctuary-Template-beta.158.zip |
|
||||
| [beta.159](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.159) | `bdaad0c94032` | sanctuary-beta.159.jar, Sanctuary-beta.159.mrpack, Sanctuary-Template-beta.159.zip |
|
||||
| [beta.160](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.160) | `c1d22786745d` | sanctuary-beta.160.jar, Sanctuary-beta.160.mrpack, Sanctuary-Template-beta.160.zip |
|
||||
| [beta.161](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.161) | `8deba7cfb47d` | sanctuary-beta.161.jar, Sanctuary-Template-beta.161.zip |
|
||||
| [beta.162](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.162) | `0c55a375623b` | sanctuary-beta.162.jar, Sanctuary-Template-beta.162.zip |
|
||||
| [beta.163](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.163) | `905b3bcfa125` | sanctuary-beta.163.jar, Sanctuary-Template-beta.163.zip |
|
||||
| [beta.164](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.164) | `9a429c40a15b` | sanctuary-beta.164.jar, Sanctuary-Template-beta.164.zip |
|
||||
| [beta.165](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.165) | `dbb9e3428da2` | sanctuary-beta.165.jar, Sanctuary-Template-beta.165.zip |
|
||||
| [beta.166](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.166) | `460c4706d37a` | sanctuary-beta.166.jar, Sanctuary-Template-beta.166.zip |
|
||||
|
||||
## Historique et intégration
|
||||
|
||||
La PR [#1 de Chris](https://git.botsu.net/koka/sanctuary-beta/pulls/1), intitulée « beta.144 — Code d’accès », est intégrée par le merge `ad960ef`, puis corrigée et livrée dans `460c470` (beta.166). Son numéro de version historique ne remplace pas la release beta.144 dédiée à l’eau. `main` a été avancé en fast-forward sur beta.166. La PR est clôturée : Gitea refuse le mode « manually-merged » dans ce dépôt, elle reste donc affichée comme fermée plutôt que fusionnée. Sa branche et ses commits sont conservés.
|
||||
|
||||
Les beta.152 et beta.153 sont des chantiers shaders non finalisés : aucun commit de livraison ni JAR Sanctuary vérifiable retrouvé. Aucun tag ou binaire artificiel n’est créé pour remplir ces numéros. Les fichiers de travail locaux sont conservés.
|
||||
|
||||
## Contrôles et limites
|
||||
|
||||
- Intégrité ZIP, version Fabric et version source contrôlées pour chaque JAR ; sources Java archivées comparées aux commits de livraison.
|
||||
- Le ZIP de sources beta.145 est antérieur à une correction : il n’est pas publié. Le JAR correspond exactement au SHA-256 documenté dans le ticket, et la classe concernée correspond au binaire beta.146 dont les sources sont vérifiées.
|
||||
- Les MRpack embarquent le JAR de leur release, vérifié par SHA-256. Le MRpack beta.150 conservé contenait un binaire intermédiaire : une copie a été réassemblée avec le JAR final ; l’original local reste intact.
|
||||
- Chaque pièce jointe est retéléchargée anonymement et comparée à son SHA-256 local. Chaque release comporte son fichier SHA256SUMS.
|
||||
- Les anciens échecs de tests restent documentés dans les tickets liés aux releases historiques. Cette publication rétrospective ne prétend pas les avoir corrigés dans les anciens binaires.
|
||||
- La beta.166 a passé `check build assemblePack`, avec 252/252 GameTests ; voir [le contrat et les vérifications](inscription-web-beta166.md). Le parcours OAuth Discord et l’inscription sous le pseudo du dossier ont ensuite été testés localement.
|
||||
- Site compagnon : branche `codex/inscription-web-beta166`, commit `89973c1` publié. Les 12 tests ciblés accès/parrainage passent (162 assertions), y compris le refus d’un autre pseudo et la génération d’invitation refusée à un candidat en attente même administrateur.
|
||||
|
||||
Le canal packwiz et les installations personnelles ne sont pas modifiés par ce rattrapage. Aucun monde, ancien JAR ou secret n’est ajouté à Git. Les fichiers `.env`, sauvegardes SQL et configurations privées du labo restent locaux.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Revue PR #1 de Chris — intégration sur beta.165
|
||||
|
||||
PR : [beta.144 — Code d’accès à la création Hello World](https://git.botsu.net/koka/sanctuary-beta/pulls/1).
|
||||
Révision examinée : `8de569bbf188f7b76ede9235d3a2e5449907bcd5`.
|
||||
Socle local : beta.165 + audit documentaire ; les corrections de GameTests restent
|
||||
un chantier distinct. Dépôt web local examiné : `sanctuary-web-community`, commit
|
||||
`1feab73`. Vérification du distant web après fetch : `6640966b7306b36735c622cc4e75f31467e6f249`, aucun écart sur `AccessCodeService.php` et `routes/api.php`. Inventaire API : une PR mod ouverte, aucune PR web ouverte.
|
||||
|
||||
## Verdict
|
||||
|
||||
**Intégrable, mais ne pas fusionner telle quelle.** Le principal blocage est la
|
||||
reprise après consommation du code côté site. Aucun merge distant, publication,
|
||||
activation du contrôle d’accès ou commentaire à Chris n’a été effectué.
|
||||
|
||||
## Constats
|
||||
|
||||
### P1 — code consommé sans possibilité de reprendre l’accueil
|
||||
|
||||
Dans `ProgressionService.java` de la PR, lignes 375–382, le serveur appelle
|
||||
`redeem` puis ignore le résultat si la session s’est déconnectée avant le retour.
|
||||
Le site peut donc avoir consommé le code alors que le ledger local n’a jamais
|
||||
été écrit. Une coupure réseau après consommation, avant réception de la réponse,
|
||||
produit la même fenêtre.
|
||||
|
||||
Le site actuel (`AccessCodeService.php:71–72`) répond `already_used` sans
|
||||
`minecraft_username` ni `discord_id`. `AccessCodes.redeem` exige ces champs pour
|
||||
la reprise et refuse donc. De plus, même si le site est corrigé pour les renvoyer,
|
||||
`AccessCodes.verifyKey:50` transforme encore tout `already_used` en `code_used` ;
|
||||
`HelloWorldScreen.updateSelection` ne permet de confirmer que `code_recognized`
|
||||
ou un pending local. La réparation doit donc couvrir **le site, le serveur et
|
||||
le parcours client**, pas seulement ajouter deux champs à la réponse API.
|
||||
|
||||
Correction recommandée : un contrat de consommation rejouable pour le même compte
|
||||
et la même admission, avec identité confirmée par l’API serveur authentifiée ;
|
||||
réconciliation du résultat après déconnexion et reprise au prochain accueil.
|
||||
Un code révoqué, un autre compte et une requête non autorisée doivent rester
|
||||
refusés. Vérifier l’identité complète et la portée du reçu avant de l’accepter.
|
||||
Le ledger ne doit jamais conserver le code brut ni le jeton API.
|
||||
|
||||
La documentation actuelle de la PR dit qu’une déconnexion avant `redeemed`
|
||||
ne consomme pas le code : ce n’est pas garanti dès que le POST est parti.
|
||||
Le smoke teste un ledger après `remember`, pas la fenêtre avant cette écriture.
|
||||
|
||||
### P2 — compatibilité des clients existants à expliciter
|
||||
|
||||
Le nouveau contrôle de capacité `AccessPayloads.Gate` dans CONFIGURE est placé
|
||||
avant la recherche de l’habitant. Activer l’accès impose donc aussi la mise à
|
||||
jour des clients des habitants déjà enregistrés. La promesse « habitant existant
|
||||
entre sans écran » ne signifie pas compatibilité avec un ancien client.
|
||||
Décider et tester ce contrat avant activation : mise à jour coordonnée ou
|
||||
capacité exigée uniquement pour les accueils qui utilisent le code.
|
||||
|
||||
## Compatibilité avec nos changements
|
||||
|
||||
`git merge-tree --write-tree HEAD origin/pr-1` a été exécuté sans changer le
|
||||
checkout. Trace ignorée : `build/pr1-merge-tree.txt`.
|
||||
|
||||
- Conflits textuels uniquement dans `gradle.properties` et `packwiz/pack.toml`.
|
||||
**Ne pas reprendre beta.144** : réserver le prochain numéro libre au moment
|
||||
de la livraison, synchroniser les manifestes et garder les archives existantes.
|
||||
- Les changements de progression et Hello World s’appliquent sans conflit textuel
|
||||
au socle examiné. Cela ne constitue pas une preuve de compatibilité fonctionnelle.
|
||||
- Pas de changement proposé aux tables communautaires ou aux pages Gazette/Tableau.
|
||||
L’admission appelle une API HTTP du site ; elle n’utilise pas directement le
|
||||
stockage des publications. Tester quand même fichier et MariaDB en régression.
|
||||
- Sans URL/jeton, la fonctionnalité se désactive. Le laboratoire actuel ne doit pas
|
||||
être activé implicitement : ses comptes locaux doivent rester accessibles.
|
||||
- Le ledger ajoute `data/sanctuary-access.json` sans réécrire le registre des
|
||||
habitants. Prévoir sauvegarde et comportement de retour arrière avant activation.
|
||||
|
||||
## Plan d’intégration concret
|
||||
|
||||
1. Finaliser et enregistrer les corrections de tests séparément.
|
||||
2. Préparer une branche d’intégration depuis ce socle et importer le changement
|
||||
de Chris, en résolvant uniquement les numéros de version puis les adaptations
|
||||
réellement nécessaires. Conserver son attribution.
|
||||
3. Corriger et documenter ensemble le contrat de reprise site/mod ; écrire les
|
||||
tests du POST consommé avec réponse perdue et déconnexion concurrente.
|
||||
4. Valider l’accueil sans configuration, l’accueil configuré neuf, un habitant
|
||||
existant, client ancien, code invalide, mauvais pseudo, code révoqué, panne
|
||||
du site, jeton rejeté, deux confirmations et redémarrage entre les écritures.
|
||||
Vérifier qu’un seul habitant et un seul familier sont créés et qu’aucun coût
|
||||
n’est dupliqué.
|
||||
5. Jouer le parcours réel sur un serveur jetable avec API de test, puis rejouer
|
||||
les échanges Gazette/Tableau en fichier et MariaDB. Garder le site public
|
||||
communautaire en lecture seule.
|
||||
6. Livrer et taguer le nouveau compteur ; activer seulement sur le serveur
|
||||
explicitement visé, après configuration et sauvegarde.
|
||||
|
||||
Pas de compilation ni d’essai de handshake de cette PR revendiqués ici.
|
||||
La suite en cours concerne les tests actualisés de beta.165, pas le code de Chris.
|
||||
@@ -0,0 +1,78 @@
|
||||
# beta.161 — correctifs de la séance du 23 septembre
|
||||
|
||||
Ticket : R002–R009 du carnet de séance. Branches `codex/session-fixes-beta161`.
|
||||
|
||||
## Contrat de données et migration additive
|
||||
|
||||
Aucun monde ni chunk n’est régénéré, aucun format existant n’est modifié.
|
||||
Le nouveau fichier `data/sanctuary-server-metrics.json` version 1 conserve le temps
|
||||
réel cumulé depuis l’activation du compteur, les morts connues par UUID et les
|
||||
UUID des figurants exclus. Au premier démarrage, les morts sont importées des
|
||||
statistiques Minecraft déjà présentes ; le temps antérieur n’est pas inventé.
|
||||
Les valeurs vivantes sont relevées côté serveur, sauvegardées toutes les 30 secondes
|
||||
et à l’arrêt. Les arrêts ne comptent pas dans la durée. Un crash peut perdre au
|
||||
plus la période depuis le dernier relevé enregistré. Un fichier corrompu ou
|
||||
modifié extérieurement arrête la collecte sans le remplacer.
|
||||
|
||||
En mode base, appliquer la migration Web additive `create_sanctuary_server_metrics_table`
|
||||
avant de lancer le mod. Elle crée une table indépendante, une ligne par server_id ;
|
||||
le schéma communautaire v3 et ses données restent inchangés. Le serveur publie un
|
||||
instantané toutes les 30 secondes et un état arrêté à l’arrêt propre. En cas de
|
||||
panne SQL le compteur local continue ; le site marque la dernière mesure périmée
|
||||
après 90 secondes. Une table absente ne bloque pas les publications communautaires.
|
||||
La suppression de cette nouvelle table au rollback perd uniquement les instantanés,
|
||||
recréés depuis le fichier local à la prochaine publication. Ne pas supprimer le fichier
|
||||
local pour revenir en arrière ; les anciennes versions l’ignorent.
|
||||
Un seul serveur actif par server_id, comme pour les publications. Les morts de tous
|
||||
les joueurs ayant des statistiques sont cumulées, y compris les joueurs déconnectés.
|
||||
Les figurants du laboratoire sont exclus des morts et des joueurs en ligne.
|
||||
|
||||
## Résultat livré
|
||||
|
||||
Liste des joueurs sur U (migration unique de Tab ; autres choix conservés), suivi
|
||||
et place unique expliqués dans les demandes. Site : retour au dessin d’origine,
|
||||
intendance limitée au titre, page officielle avec retour, pas de navbar de rubriques
|
||||
ni de sélecteur de langue ni d’incitation à se connecter dans les publications.
|
||||
Gazette en accordéon exclusif : le dernier article visible est ouvert au chargement,
|
||||
ouvrir un autre referme le précédent. Photo entière à gauche, extrait court à droite,
|
||||
visage/pseudo/date et lien de lecture alignés en bas. Calendrier jour/mois encadré,
|
||||
message officiel à droite, sans heure ; année dans le footer.
|
||||
Les pages publiques du site sont en lecture seule, y compris leurs anciennes
|
||||
routes d’écriture pour les comptes connectés et le staff. L’administration
|
||||
Intendance réservée au staff reste disponible. Les joueurs publient, répondent
|
||||
et suivent les demandes dans Minecraft. Compteurs réels et état périmé explicite.
|
||||
|
||||
## Vérifications
|
||||
|
||||
| Vérification | Résultat |
|
||||
| --- | --- |
|
||||
| `./gradlew check build assemblePack` | 252 GameTests : 229 réussis, les mêmes 23 échecs que beta.160, aucun nouvel échec dans cette passe. Comparaison dans `build/session161-server-failures.json`. |
|
||||
| Contrôles JVM et persistance fichier/SQL | Réussis, dont `SERVER_METRICS161_PASS` et `COMMUNITY154_SQL_PASS` dans `build/beta161-release.log`. |
|
||||
| Démarrage et raccourci U | `SESSION161_KEYS_PASS` : défaut U, migration unique depuis Tab, choix personnalisé conservé. |
|
||||
| Parcours natif communautaire | `SEARCH159_CLIENT_PASS mode=file` : photos/galerie, FR/EN, GUI 2/3/4, recherche, bulletin en lecture seule, suivi et infobulle du repère. Captures dans `mods/sanctuary/build/run/clientGameTest/screenshots/`. |
|
||||
| Web SQLite et MariaDB isolée | 29 tests, 252 assertions réussies ; lecture seule visiteurs/joueurs/staff, contrats de persistance, statistiques absentes/périmées/arrêtées. Pint et `git diff --check` réussis. |
|
||||
| Navigateur | Accordéon exclusif, lien vers l’article, calendrier sans heure, visage/auteur/date et lecture en bas ; vérification desktop et 390 × 844. |
|
||||
| Assemblage final | `./gradlew build assemblePack :sanctuary-test:exportDuoLaunch -x :sanctuary:check` réussi, après les vérifications ci-dessus. Exclusion ponctuelle des tests connus en échec, pas de suppression d’assertion. |
|
||||
| Laboratoire réel | Monde existant `duo-flat-160` relancé, serveur 768 MiB/client 2048 MiB, shaders désactivés. KokaLab non OP, 26 niveaux conservés. Deux figurants, compteur SQL et Web : 1 humain. Journal de durée persisté et exclusions vérifiées. |
|
||||
|
||||
Le premier démarrage de test a révélé une mauvaise déclaration du nouveau mixin :
|
||||
`PlayerListKeyMixin` au lieu de `client.PlayerListKeyMixin`. Elle est corrigée ;
|
||||
la classe et sa déclaration sont présentes dans le JAR final. Le test natif a
|
||||
ensuite réussi. L’assemblage avait redéclenché une seconde passe de GameTests
|
||||
serveur ; cette répétition a été interrompue, la première passe complète faisant
|
||||
foi. Logs distincts : `beta161-check-build.log`, `beta161-release.log`,
|
||||
`beta161-client.log`, `beta161-assembly.log`, sous `build/`.
|
||||
|
||||
Livraison locale uniquement : `mods/sanctuary/build/libs/sanctuary-beta.161.jar`
|
||||
et pack assemblé dans `build/packwiz`. SHA-256 du JAR :
|
||||
`c6e0424bf091c41827730bc260dad3b95b7c949fb0dcc41811f0ae3d94bce79c`.
|
||||
Les 29 ressources shader sont identiques à beta.160. Les anciens JAR beta.154–160
|
||||
sont conservés. Aucun canal public ni instance Prism personnelle n’a été modifié.
|
||||
Le téléchargement public du site reste l’ancien lien beta.151, hors de cette
|
||||
livraison locale. Les 23 échecs préexistants restent ouverts.
|
||||
|
||||
Le compteur de jours débute à l’activation de cette version : le fonctionnement
|
||||
antérieur n’était pas mesuré. Le site reçoit les nouveaux compteurs au rechargement
|
||||
de la page. La mesure d’une mort réelle supplémentaire n’a pas été provoquée sur
|
||||
le personnage de la séance ; l’import et l’incrément sont couverts par le test du
|
||||
journal et les tests de lecture Web.
|
||||
+2
-2
@@ -9,8 +9,8 @@ loom_version=1.17.20
|
||||
fabric_api_version=0.160.5+26.3
|
||||
|
||||
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
|
||||
mod_version=beta.160
|
||||
pack_version=beta.160
|
||||
mod_version=beta.166
|
||||
pack_version=beta.166
|
||||
resource_pack_version=beta.121
|
||||
maven_group=fr.koka.sanctuary
|
||||
jei_version=30.32.0-sanctuary.3
|
||||
|
||||
@@ -20,7 +20,7 @@ public final class QuickTestMod implements ModInitializer {
|
||||
PayloadTypeRegistry.clientboundConfiguration().register(Mode.TYPE,Mode.CODEC);
|
||||
ServerConfigurationConnectionEvents.CONFIGURE.register((handler,server)->{
|
||||
if(ServerConfigurationNetworking.canSend(handler,Mode.TYPE))
|
||||
ServerConfigurationNetworking.send(handler,new Mode(QuickTestWorld.active(server)));
|
||||
ServerConfigurationNetworking.send(handler,new Mode(QuickTestWorld.active(server) && !Boolean.getBoolean("sanctuary.duo.intro")));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,9 @@ tasks.named('runGameTest') {
|
||||
}
|
||||
|
||||
tasks.named('processGametestResources') {
|
||||
def access166Client = providers.gradleProperty('sanctuaryAccess166ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryAccess166ClientTests', access166Client)
|
||||
if (access166Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Access166ClientChecks') }
|
||||
def colored139Client = providers.gradleProperty('sanctuaryColored139ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryColored139ClientTests', colored139Client)
|
||||
if (colored139Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.ColoredLights139ClientChecks') }
|
||||
@@ -131,6 +134,9 @@ tasks.named('processGametestResources') {
|
||||
def defaults160 = providers.gradleProperty('sanctuaryDefaults160ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('defaults160', defaults160)
|
||||
if (defaults160) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.ShaderDefaults160ClientChecks') }
|
||||
def session161 = providers.gradleProperty('sanctuarySession161ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuarySession161ClientTests', session161)
|
||||
if (session161) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Session161ClientChecks') }
|
||||
def bloom130Client = providers.gradleProperty('sanctuaryBloom130ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryBloom130ClientTests', bloom130Client)
|
||||
if (bloom130Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Bloom130ClientChecks') }
|
||||
@@ -1159,6 +1165,14 @@ tasks.register('intro031Smoke', JavaExec) {
|
||||
}
|
||||
tasks.named('check') { dependsOn('intro031Smoke') }
|
||||
|
||||
tasks.register('access144Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.progression.AccessCodeSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('access144Smoke') }
|
||||
|
||||
if (providers.gradleProperty('sanctuaryQuickTests').map { it.toBoolean() }.getOrElse(false)) {
|
||||
dependencies { gametestRuntimeOnly project(':sanctuary-test') }
|
||||
}
|
||||
@@ -1244,3 +1258,19 @@ tasks.register('community154Interop', JavaExec) {
|
||||
mainClass = 'fr.koka.sanctuary.community.CommunityInterop'
|
||||
args(providers.gradleProperty('communityInteropPhase').getOrElse('seed'))
|
||||
}
|
||||
|
||||
tasks.register('serverMetrics161Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.community.ServerMetricsSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('serverMetrics161Smoke') }
|
||||
|
||||
// Explicit disposable web fixture only; not part of ordinary offline checks.
|
||||
tasks.register('access166Interop', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.progression.AccessInterop'
|
||||
}
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import fr.koka.sanctuary.gametest.Starter039TestSupport;
|
||||
import fr.koka.sanctuary.progression.*;
|
||||
import java.nio.file.*;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.*;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.minecraft.client.gui.components.*;
|
||||
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
||||
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
|
||||
|
||||
/** Real configuration handshake against a controllable HTTP endpoint; PHP is checked separately. */
|
||||
public final class Access166ClientChecks implements FabricClientGameTest {
|
||||
public void runTest(ClientGameTestContext c) {
|
||||
Path config=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary/access.json");
|
||||
byte[] before;
|
||||
HttpServer api;
|
||||
var consumed=new AtomicBoolean();var attempts=new AtomicInteger();var retries=new AtomicInteger();
|
||||
var running=new AtomicBoolean(true);var filled=new AtomicBoolean();
|
||||
var welcome=new AtomicReference<HelloWorldScreen>();
|
||||
try {
|
||||
before=Files.exists(config)?Files.readAllBytes(config):null;
|
||||
api=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0);
|
||||
api.createContext("/api/v1/access-codes/",exchange->{
|
||||
try {
|
||||
var input=JsonParser.parseString(new String(exchange.getRequestBody().readAllBytes(),java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
boolean redeem=exchange.getRequestURI().getPath().endsWith("redeem");
|
||||
if(redeem && attempts.incrementAndGet()==1){
|
||||
consumed.set(true);exchange.sendResponseHeaders(503,-1);return;
|
||||
}
|
||||
var output=new JsonObject();output.addProperty("valid",true);
|
||||
output.addProperty("reason",consumed.get()?"resumed":redeem?"redeemed":"unused");
|
||||
output.add("minecraft_username",input.get("minecraft_username"));output.add("admission_id",input.get("admission_id"));output.addProperty("discord_id","166001");
|
||||
byte[] bytes=output.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type","application/json");exchange.sendResponseHeaders(200,bytes.length);exchange.getResponseBody().write(bytes);
|
||||
} finally {exchange.close();}
|
||||
});
|
||||
api.start();Files.createDirectories(config.getParent());
|
||||
Files.writeString(config,"{\"appUrl\":\"http://127.0.0.1:"+api.getAddress().getPort()+"\",\"token\":\"test-only\"}");
|
||||
}catch(Exception error){throw new AssertionError(error);}
|
||||
ClientTickEvents.END_CLIENT_TICK.register(m->{
|
||||
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");
|
||||
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){
|
||||
if(text.getKey().equals("sanctuary.starter.name"))box.setValue("Echo");
|
||||
if(text.getKey().equals("sanctuary.progression.code"))box.setValue("SANC-ABCD-EFGH");
|
||||
}
|
||||
}
|
||||
for(var child:screen.children())if(child instanceof Button button && button.active && button.getMessage().getContents() instanceof TranslatableContents text){
|
||||
if(text.getKey().equals("sanctuary.progression.retry_code")){retries.incrementAndGet();button.onPress(enter());break;}
|
||||
if(text.getKey().equals("sanctuary.progression.enter")){button.onPress(enter());break;}
|
||||
}
|
||||
});
|
||||
c.runOnClient(m->{m.options.renderDistance().set(4);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
|
||||
try(var world=c.worldBuilder().adjustSettings(s->{
|
||||
s.setName("Access166 "+UUID.randomUUID());s.setSeed("166");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
|
||||
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());
|
||||
}).create()){
|
||||
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null,1200);
|
||||
check(attempts.get()==2&&retries.get()==1,"Lost response retried once through real HelloWorldScreen");
|
||||
world.getServer().runOnServer(server->{
|
||||
try {
|
||||
Path data=server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data");
|
||||
var ledger=JsonParser.parseString(Files.readString(data.resolve("sanctuary-access.json"))).getAsJsonObject();
|
||||
check(ledger.getAsJsonObject("bound").size()==1&&ledger.getAsJsonObject("pending").size()==0,"Exactly one committed admission");
|
||||
}catch(Exception error){throw new AssertionError(error);}
|
||||
});
|
||||
c.takeScreenshot("access166-admitted");
|
||||
running.set(false);
|
||||
for(String locale:List.of("fr_fr","en_us")) {
|
||||
c.runOnClient(m->{
|
||||
m.options.guiScale().set(2);m.resizeGui();
|
||||
m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
|
||||
m.gui.setScreen(welcome.get());
|
||||
for(var child:welcome.get().children())if(child instanceof AbstractWidget widget)
|
||||
check(widget.getY()>=0&&widget.getY()+widget.getHeight()<=welcome.get().height,"All welcome controls fit the small GUI");
|
||||
});
|
||||
c.waitTicks(2);c.takeScreenshot("access166-welcome-"+locale);
|
||||
}
|
||||
c.runOnClient(m->m.gui.setScreen(null));
|
||||
System.out.println("ACCESS166_CLIENT_PASS: Vulkan, native welcome, lost redeem response, retry verification, single bound admission");
|
||||
} finally {
|
||||
running.set(false);api.stop(0);
|
||||
try {if(before==null)Files.deleteIfExists(config);else Files.write(config,before);}catch(Exception error){throw new AssertionError(error);}
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
-2
@@ -95,6 +95,7 @@ public final class Community158ClientChecks implements FabricClientGameTest {
|
||||
c.runOnClient(m->{check(button(m.gui.screen(),"publish").active,"Photo enables publishing");check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Selection retains draft");press(m.gui.screen(),"sanctuary.community.publish");});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.reply")&&!CommunityClient.pending(),600);
|
||||
c.runOnClient(m->{var article=widgets(m.gui.screen()).filter(CommunityArticle.class::isInstance).map(CommunityArticle.class::cast).findFirst().orElseThrow();var face=widgets(m.gui.screen()).filter(PlayerFaceWidget.class::isInstance).map(PlayerFaceWidget.class::cast).findFirst().orElseThrow();check(face.getY()>=article.getBottom(),"Author metadata below the article");check(button(m.gui.screen(),"actions").getY()==face.getY(),"Author metadata next to ellipsis");check(widgets(m.gui.screen()).anyMatch(w->w instanceof AbstractWidget widget && widget.getMessage().getString().contains("/"+java.time.Year.now()+" ")),"Article date includes year");});
|
||||
articleSceneChecks(c);
|
||||
c.takeScreenshot("cards158-article"+(database?"-database":"-file"));
|
||||
world.getServer().runOnServer(s->{try {
|
||||
var config=CommunityConfig.load(net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
|
||||
@@ -141,6 +142,8 @@ public final class Community158ClientChecks implements FabricClientGameTest {
|
||||
c.runOnClient(m->{press(m.gui.screen(),"sanctuary.community.save");press(m.gui.screen(),"sanctuary.community.audience.shared");press(m.gui.screen(),"sanctuary.community.reward.none");press(m.gui.screen(),"sanctuary.community.reward.items");edit(m.gui.screen()).setValue("Un repas au village");press(m.gui.screen(),"sanctuary.community.save");press(m.gui.screen(),"sanctuary.community.publish");});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.follow")&&!CommunityClient.pending(),400);
|
||||
c.takeScreenshot("cards158-notice-details");
|
||||
noticeSceneChecks(c);
|
||||
|
||||
world.getServer().runOnServer(server->{var inventory=server.getPlayerList().getPlayers().getFirst().getInventory();check(inventory.getItem(6).getCount()==17&&inventory.getItem(6).is(net.minecraft.world.item.Items.OAK_LOG)&&inventory.getItem(7).getCount()==3&&inventory.getItem(7).is(net.minecraft.world.item.Items.DIAMOND),"Descriptive chest preserves real inventory");});
|
||||
c.runOnClient(m->{check(text(m.gui.screen(),"Un repas au village"),"Custom reward round trip");CommunityClient.open(new PauseScreen(true),"notice",noticeId,false);});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.follow")&&!CommunityClient.pending(),300);c.waitTicks(25);
|
||||
@@ -182,6 +185,7 @@ public final class Community158ClientChecks implements FabricClientGameTest {
|
||||
c.runOnClient(m->{widgets(m.gui.screen()).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b->b.getMessage().getString().startsWith("Allow Commands")).findFirst().orElseThrow().onPress(enter());widgets(m.gui.screen()).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b->b.getMessage().getString().equals("Apply Changes")).findFirst().orElseThrow().onPress(enter());});
|
||||
c.waitFor(m->ProgressionClient.operator,150);c.runOnClient(m->{m.gui.setScreen(null);m.getConnection().sendCommand("sanctuary community admin");});
|
||||
c.waitFor(m->m.gui.screen() instanceof CommunityScreen&&has(m.gui.screen(),"sanctuary.community.publish")&&!CommunityClient.pending(),200);
|
||||
c.waitTicks(25); // Respect the server write cooldown after the preceding unfollow.
|
||||
c.runOnClient(m->{press(m.gui.screen(),"sanctuary.community.publish");edit(m.gui.screen()).setValue("Message officiel");body(m.gui.screen()).setValue("Bienvenue !");press(m.gui.screen(),"sanctuary.community.publish");});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.back")&&!CommunityClient.pending(),250);
|
||||
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.follow")&&!has(m.gui.screen(),"sanctuary.community.reply"),"Administrative bulletin has no quest or replies");CommunityClient.open(null,"bulletin","",false);});
|
||||
@@ -194,19 +198,85 @@ public final class Community158ClientChecks implements FabricClientGameTest {
|
||||
c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new PauseScreen(true));});
|
||||
c.waitTicks(20);
|
||||
c.runOnClient(m->{
|
||||
var date=widgets(m.gui.screen()).filter(StringWidget.class::isInstance).map(StringWidget.class::cast).filter(w->w.getMessage().getString().endsWith(java.time.Year.now().toString())).findFirst().orElseThrow();
|
||||
var date=widgets(m.gui.screen()).filter(StringWidget.class::isInstance).map(StringWidget.class::cast).filter(w->w.getMessage().getString().contains(java.time.Year.now().toString())).findFirst().orElseThrow();
|
||||
var official=widgets(m.gui.screen()).filter(PauseTextButton.class::isInstance).map(PauseTextButton.class::cast).filter(w->w.getMessage().getString().contains("Message officiel")).findFirst().orElseThrow();
|
||||
check(date.getRight()<official.getX(),"Full date and server message do not overlap");
|
||||
var screen=m.gui.screen();
|
||||
check(date.getX()==8&&date.getHeight()==38&&date.getY()==3,"Calendar framed at top left");
|
||||
check(!has(screen,"sanctuary.progression.history"),"World navigation removed");
|
||||
var discovery=pauseButton(screen,"sanctuary.progression.blocks");var progression=pauseButton(screen,"sanctuary.progression.progress");var inhabitant=pauseButton(screen,"sanctuary.progression.identity");
|
||||
check(date.getWidth()==discovery.getWidth(),"Calendar matches navigation width");
|
||||
check(official.getX()==date.getRight()+10&&official.getRight()==screen.width-8,"Bulletin spans map and community panels");
|
||||
check(date.getMessage().getString().matches(".*\\d{2}:\\d{2}$"),"Clock included in calendar");
|
||||
check(inhabitant.getY()-progression.getBottom()>progression.getY()-discovery.getBottom(),"Personal and social navigation groups separated");
|
||||
var navigation=screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getX()==8&&b.getY()>=50&&b.getY()<pauseButton(screen,"menu.options").getY()).toList();
|
||||
check(navigation.size()==5,"Five navigation destinations");
|
||||
check(navigation.stream().allMatch(b->b.getBottom()<pauseButton(screen,"menu.options").getY()),"Navigation clears settings");
|
||||
var resume=pauseButton(screen,"menu.returnToGame");
|
||||
check(Math.abs(resume.getX()+resume.getWidth()/2-screen.width/2)<=1&&resume.getY()>screen.height-33,"Resume centered in footer");
|
||||
check(pauseButton(screen,"options.worldOptions.button").getBottom()<screen.height-39,"World settings remain in body");
|
||||
for(var child:screen.children())if(child instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=screen.width&&w.getY()>=0&&w.getBottom()<=screen.height,"Pause widget within viewport");
|
||||
|
||||
});
|
||||
c.takeScreenshot("search159-pause-year-"+locale+"-"+scale);
|
||||
c.takeScreenshot("pause164-calendar-"+locale+"-"+scale);
|
||||
}
|
||||
c.runOnClient(m->m.gui.setScreen(null));
|
||||
System.out.println("PAUSE164_LAYOUT_PASS");
|
||||
System.out.println("SEARCH159_CLIENT_PASS mode="+(database?"database":"file"));
|
||||
} finally {intro.set(false);}
|
||||
}
|
||||
private static void articleSceneChecks(ClientGameTestContext 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();});
|
||||
c.waitTicks(3);
|
||||
c.runOnClient(m->{
|
||||
var s=m.gui.screen();var article=widgets(s).filter(CommunityArticle.class::isInstance).map(CommunityArticle.class::cast).findFirst().orElseThrow();
|
||||
check(button(s,"back").getBottom()<=article.getY(),"Gazette toolbar fixed above article");
|
||||
if(s.width>=522) {
|
||||
check(body(s).getX()>=article.getRight(),"Gazette conversation on the right");
|
||||
check(button(s,"reply").getBottom()<=s.height-33,"Gazette composer above footer");
|
||||
} else {
|
||||
check(body(s).getY()>article.getBottom(),"Narrow Gazette stacks conversation below article");
|
||||
check(widgets(s).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).anyMatch(a->a.maxScrollAmount()>0),"Narrow article and conversation scroll");
|
||||
}
|
||||
body(s).setValue("Brouillon Gazette165");
|
||||
});
|
||||
c.takeScreenshot("gazette165-article-"+locale+"-"+scale);
|
||||
}
|
||||
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();check(body(m.gui.screen()).getValue().equals("Brouillon Gazette165"),"Gazette draft survives resize");body(m.gui.screen()).setValue("Réponse Gazette165");press(m.gui.screen(),"sanctuary.community.reply");});
|
||||
c.waitFor(m->!CommunityClient.pending()&&widgets(m.gui.screen()).filter(CommunityBubble.class::isInstance).map(CommunityBubble.class::cast).anyMatch(w->w.getMessage().getString().equals("Réponse Gazette165")),300);
|
||||
c.takeScreenshot("gazette165-conversation");
|
||||
System.out.println("GAZETTE165_SCENE_PASS");
|
||||
}
|
||||
private static void noticeSceneChecks(ClientGameTestContext 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();});
|
||||
c.waitTicks(3);
|
||||
c.runOnClient(m->{
|
||||
var s=m.gui.screen();var scene=widgets(s).filter(CommunityNoticeScene.class::isInstance).map(CommunityNoticeScene.class::cast).findFirst().orElseThrow();
|
||||
check(button(s,"back").getBottom()<=scene.getY(),"Back/refresh fixed above scene");
|
||||
if(s.width>=522) {
|
||||
check(body(s).getX()>=scene.getRight(),"Conversation to the right of author scene");
|
||||
check(button(s,"reply").getBottom()<=s.height-33,"Reply stays above footer");
|
||||
} else check(body(s).getY()>scene.getBottom(),"Narrow GUI stacks conversation below scene");
|
||||
body(s).setValue("Brouillon scène162");hover(m,0,0);
|
||||
});
|
||||
c.waitTicks(3);
|
||||
var left=new java.util.concurrent.atomic.AtomicReference<Float>();
|
||||
c.runOnClient(m->{var scene=widgets(m.gui.screen()).filter(CommunityNoticeScene.class::isInstance).map(CommunityNoticeScene.class::cast).findFirst().orElseThrow();left.set(scene.yaw());hover(m,m.gui.screen().width,m.gui.screen().height);});
|
||||
c.waitTicks(3);
|
||||
c.runOnClient(m->{var scene=widgets(m.gui.screen()).filter(CommunityNoticeScene.class::isInstance).map(CommunityNoticeScene.class::cast).findFirst().orElseThrow();check(scene.yaw()>left.get()&&Math.abs(scene.pitch())<50,"Portrait tracks pointer within bounded angles");});
|
||||
c.takeScreenshot("notice162-scene-"+locale+"-"+scale);
|
||||
}
|
||||
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();check(body(m.gui.screen()).getValue().equals("Brouillon scène162"),"Draft survives GUI resize");body(m.gui.screen()).setValue("Réponse scène162");press(m.gui.screen(),"sanctuary.community.reply");});
|
||||
c.waitFor(m->!CommunityClient.pending()&&widgets(m.gui.screen()).filter(CommunityBubble.class::isInstance).map(CommunityBubble.class::cast).anyMatch(w->w.getMessage().getString().equals("Réponse scène162")),300);
|
||||
c.takeScreenshot("notice162-conversation");
|
||||
System.out.println("NOTICE162_SCENE_PASS");
|
||||
}
|
||||
private static void hover(net.minecraft.client.Minecraft m,double x,double y) {
|
||||
var w=m.getWindow();double sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();
|
||||
m.mouseHandler.onMove(w.handle(),x*sx,y*sy,(x-m.mouseHandler.getScaledXPos(w))*sx,(y-m.mouseHandler.getScaledYPos(w))*sy);m.mouseHandler.handleAccumulatedMovement();
|
||||
}
|
||||
private static Button pauseButton(Screen screen,String key) {return screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow();}
|
||||
private static AbstractButton button(Screen screen,String key) {return widgets(screen).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.community."+key)).findFirst().orElseThrow();}
|
||||
}
|
||||
|
||||
+6
-6
@@ -54,14 +54,14 @@ public final class Pause155ClientChecks implements FabricClientGameTest {
|
||||
c.runOnClient(m->{
|
||||
var s=m.gui.screen();
|
||||
check(s instanceof PauseAtlasAccess,"Pause atlas access");
|
||||
for(String key:List.of("sanctuary.progression.blocks","sanctuary.progression.history","sanctuary.progression.progress","sanctuary.progression.identity","menu.options","options.worldOptions.button","menu.returnToMenu","menu.returnToGame"))check(has(s,key),"Destination "+key);
|
||||
var navigation=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getX()==8&&b.getY()>=40&&b.getY()<s.height-76).sorted(java.util.Comparator.comparingInt(Button::getY)).toList();
|
||||
check(navigation.size()==6,"Six real navigation buttons");
|
||||
for(int i=1;i<navigation.size();i++)check(navigation.get(i).getY()-navigation.get(i-1).getBottom()==4,"Uniform 4-pixel navigation spacing");
|
||||
for(String key:List.of("sanctuary.progression.blocks","sanctuary.progression.progress","sanctuary.progression.identity","menu.options","options.worldOptions.button","menu.returnToMenu","menu.returnToGame"))check(has(s,key),"Destination "+key);
|
||||
var navigation=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getX()==8&&b.getY()>=50&&b.getY()<nativeButton(s,"menu.options").getY()).sorted(java.util.Comparator.comparingInt(Button::getY)).toList();
|
||||
check(navigation.size()==5&&!has(s,"sanctuary.progression.history"),"Five real navigation buttons without World");
|
||||
for(int i=1;i<navigation.size();i++)check(navigation.get(i).getY()-navigation.get(i-1).getBottom()>=(i==2?8:3),"Two spaced navigation groups");
|
||||
var options=nativeButton(s,"menu.options");var worldOptions=nativeButton(s,"options.worldOptions.button");var quit=nativeButton(s,"menu.returnToMenu");var resume=nativeButton(s,"menu.returnToGame");
|
||||
check(options.getX()==8 && worldOptions.getX()==8 && quit.getX()==8 && worldOptions.getY()-options.getBottom()==4 && quit.getY()-worldOptions.getBottom()==4 && quit.getBottom()==s.height-8,"Options and quit stacked at bottom left");
|
||||
check(options.getX()==8 && worldOptions.getX()==8 && quit.getX()==8 && worldOptions.getY()-options.getBottom()==4 && quit.getY()-worldOptions.getBottom()==4 && quit.getBottom()==s.height-39,"Options and quit stacked at bottom left");
|
||||
var feed=areas(s).getFirst();
|
||||
check(resume.getX()==feed.getX() && resume.getWidth()==feed.getWidth() && resume.getBottom()==s.height-8 && areas(s).getLast().getBottom()<resume.getY(),"Resume aligned below right community column");
|
||||
check(Math.abs(resume.getX()+resume.getWidth()/2-s.width/2)<=1 && resume.getY()>s.height-33 && areas(s).getLast().getBottom()<resume.getY(),"Resume centered in native footer");
|
||||
for(var e:s.children())if(e instanceof Button b && b.getX()==8 && b.getY()<options.getY())check(b.getBottom()<options.getY(),"Navigation does not overlap options");
|
||||
check(!has(s,"gui.advancements") && !has(s,"gui.stats") && !has(s,"menu.game"),"Old grid removed");
|
||||
for(var e:s.children())if(e instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Widget bounds "+w.getClass().getSimpleName()+" "+w.getX()+","+w.getY()+" "+w.getWidth()+"x"+w.getHeight()+" / "+s.width+"x"+s.height);
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import java.nio.file.*;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
|
||||
public final class Session161ClientChecks implements FabricClientGameTest {
|
||||
public void runTest(ClientGameTestContext c) {
|
||||
c.runOnClient(m->{
|
||||
var marker=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-player-list-key-v1.txt");
|
||||
var saved=m.options.keyPlayerList.saveString();
|
||||
try {
|
||||
byte[] previous=Files.exists(marker)?Files.readAllBytes(marker):null;
|
||||
try {
|
||||
check(m.options.keyPlayerList.getDefaultKey().getValue()==InputConstants.KEY_U,"U is reset-to-default player list key");
|
||||
Files.deleteIfExists(marker);
|
||||
m.options.keyPlayerList.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB));
|
||||
HotbarClient.migratePlayerListKey(m);
|
||||
check(m.options.keyPlayerList.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_U)),"Saved Tab migrates to U");
|
||||
Files.delete(marker);
|
||||
m.options.keyPlayerList.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_F8));
|
||||
HotbarClient.migratePlayerListKey(m);
|
||||
check(m.options.keyPlayerList.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_F8)),"Custom binding retained");
|
||||
m.options.keyPlayerList.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB));
|
||||
HotbarClient.migratePlayerListKey(m);
|
||||
check(m.options.keyPlayerList.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB)),"Migration happens once");
|
||||
System.out.println("SESSION161_KEYS_PASS");
|
||||
} finally {
|
||||
if(previous==null)Files.deleteIfExists(marker);else Files.write(marker,previous);
|
||||
m.options.keyPlayerList.setKey(InputConstants.getKey(saved));net.minecraft.client.KeyMapping.resetMapping();m.options.save();
|
||||
}
|
||||
}catch(Exception error){throw new AssertionError(error);}
|
||||
});
|
||||
new Community158ClientChecks().runTest(c);
|
||||
}
|
||||
private static void check(boolean value,String message){if(!value)throw new AssertionError(message);}
|
||||
}
|
||||
+5
-1
@@ -83,7 +83,7 @@ public final class Accessory018GameTests {
|
||||
var p=player(h,0);platform(h,p.blockPosition());AccessoryAccess.of(p).setItem(0,new ItemStack(Items.CREEPER_SPAWN_EGG));
|
||||
FamiliarService.tick(p);var pet=FamiliarService.active(p);h.assertTrue(pet!=null,"Dedicated moving familiar spawns");
|
||||
h.assertTrue(!pet.isNoAi() && pet.getTarget()==null && !pet.shouldBeSaved(),"Navigation runs without destructive mob AI or chunk persistence");
|
||||
float health=pet.getHealth();h.assertTrue(!pet.hurtServer(h.getLevel(),p.damageSources().genericKill(),10000) && pet.getHealth()==health,"Even bypass damage cannot injure familiar");
|
||||
float health=pet.getHealth();h.assertTrue(!pet.hurtServer(h.getLevel(),p.damageSources().playerAttack(p),1) && pet.getHealth()==health,"Friendly owner damage cannot injure familiar");
|
||||
h.assertTrue(!pet.isPushable() && !pet.canBeCollidedWith(p) && !pet.canCollideWith(p),"No entity collision or pushing");
|
||||
var pos=pet.position();pet.push(p);h.assertTrue(pet.position().equals(pos),"Contact does not shove pet");
|
||||
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.LEAD));
|
||||
@@ -92,6 +92,10 @@ public final class Accessory018GameTests {
|
||||
h.assertTrue(pet.teleportNearOwner() && h.getLevel().noBlockCollision(pet,pet.getBoundingBox()),"Safe fallback exits a wall into free supported space");
|
||||
var saved=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());h.assertTrue(!pet.save(saved),"Temporary pet is excluded from saves");
|
||||
FamiliarService.tick(p);h.assertTrue(FamiliarService.active(p)==pet,"Repeated service tick never duplicates pet");
|
||||
var eggBefore=AccessoryAccess.of(p).getItem(0).copy();
|
||||
h.assertTrue(pet.hurtServer(h.getLevel(),p.damageSources().genericKill(),10000),"Non-friendly bypass damage enters the current combat system");
|
||||
h.assertTrue(fr.koka.sanctuary.familiar.FamiliarBattle.bond(p).health()==0 && fr.koka.sanctuary.familiar.FamiliarBattle.bond(p).knockedOutUntil()>fr.koka.sanctuary.companion.CompanionService.now(),"Lethal damage produces a timed KO");
|
||||
h.assertTrue(ItemStack.matches(eggBefore,AccessoryAccess.of(p).getItem(0)),"KO preserves the equipped egg and its identity");
|
||||
AccessoryAccess.of(p).setItem(0,ItemStack.EMPTY);FamiliarService.tick(p);h.assertTrue(pet.isRemoved() && FamiliarService.active(p)==null,"Removing egg removes familiar");h.succeed();
|
||||
}
|
||||
@GameTest public void familiarWalksAroundObstacle(GameTestHelper h) {
|
||||
|
||||
+1
@@ -376,6 +376,7 @@ public final class BlockKnowledgeGameTests {
|
||||
}
|
||||
|
||||
private static boolean place(ServerPlayer player, Item item, BlockPos support, Direction face) {
|
||||
player.setPos(support.getX()+2.5,support.getY()+1,support.getZ()+.5); // Within novice placement reach, outside the target cell.
|
||||
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(item));
|
||||
var hit = new BlockHitResult(Vec3.atCenterOf(support), face, support, false);
|
||||
var context = new BlockPlaceContext(new UseOnContext(player, InteractionHand.MAIN_HAND, hit));
|
||||
|
||||
+12
-3
@@ -108,8 +108,7 @@ public final class Building009GameTests {
|
||||
var origin=fixture(h,Direction.UP,1);
|
||||
for(var pos:BuildPlane.positions(origin,Direction.UP,Direction.SOUTH,1))
|
||||
h.getLevel().setBlockAndUpdate(pos,Blocks.STONE_SLAB.defaultBlockState());
|
||||
var p=player(h,1,Items.STONE_SLAB,16);aim(p,origin,Direction.UP);
|
||||
p.lookAt(EntityAnchorArgument.Anchor.EYES,Vec3.atLowerCornerOf(origin).add(.5,.5,.5));
|
||||
var p=player(h,1,Items.STONE_SLAB,16);aimInsetTop(h,p,origin,.5);
|
||||
start(h,p,origin);finish(h,p);
|
||||
h.assertTrue(p.getMainHandItem().getCount()==7 && h.getLevel().getBlockState(origin).getValue(SlabBlock.TYPE)==SlabType.DOUBLE,"Existing slabs merge through native contexts and consume one item each");
|
||||
BuildingManager.forget(p);
|
||||
@@ -166,7 +165,7 @@ public final class Building009GameTests {
|
||||
var origin=fixture(h,Direction.UP,1);h.getLevel().setBlockAndUpdate(origin,Blocks.CHEST.defaultBlockState());
|
||||
var chest=(net.minecraft.world.level.block.entity.ChestBlockEntity)h.getLevel().getBlockEntity(origin);
|
||||
chest.setItem(0,new ItemStack(Items.DIAMOND,3));
|
||||
var p=player(h,1,Items.COBBLESTONE,16);aim(p,origin,Direction.UP);
|
||||
var p=player(h,1,Items.COBBLESTONE,16);aimInsetTop(h,p,origin,14.0/16);
|
||||
start(h,p,origin);finish(h,p);
|
||||
h.assertTrue(chest.getItem(0).getCount()==3 && p.containerMenu==p.inventoryMenu,"Building on a container never opens it or changes its contents");
|
||||
BuildingManager.forget(p);
|
||||
@@ -220,6 +219,16 @@ public final class Building009GameTests {
|
||||
p.lookAt(EntityAnchorArgument.Anchor.EYES,Vec3.atCenterOf(origin).add(face.getStepX()*.5,face.getStepY()*.5,face.getStepZ()*.5));
|
||||
p.setOnGround(true);
|
||||
}
|
||||
private static void aimInsetTop(GameTestHelper h,ServerPlayer p,BlockPos origin,double top) {
|
||||
// Rank 1 reaches 3.5 blocks: keep inset surfaces comfortably within that range.
|
||||
// Stay outside the 3x3 placement footprint so the player does not exclude a target.
|
||||
p.setPos(origin.getX()+.5,origin.getY()+1,origin.getZ()-1.5);
|
||||
p.lookAt(EntityAnchorArgument.Anchor.EYES,Vec3.atLowerCornerOf(origin).add(.5,top,.5));
|
||||
p.setOnGround(true);
|
||||
var hit=BuildSelection.aimed(p,origin);
|
||||
h.assertTrue(hit!=null && hit.getDirection()==Direction.UP,
|
||||
"Fixture ray must reach the intended inset top before testing grouped placement");
|
||||
}
|
||||
private static void start(GameTestHelper h,ServerPlayer p,BlockPos origin) {
|
||||
BuildingManager.handle(p,new BuildingPayload(1,true,origin,ProgressEffects.get(p).building()));
|
||||
h.assertTrue(BuildingManager.active(p),"Valid native target starts a build session");
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ public final class Collections035GameTests {
|
||||
}
|
||||
var actual=new HashSet<String>();for(var recipe:server.getRecipeManager().getRecipes())if(recipe.id().identifier().getNamespace().equals("minecraft"))actual.add(recipe.id().identifier().toString());
|
||||
check(h,primary.size()==2042&&primary.equals(actual),"All 2042 loaded vanilla recipes classified exactly once");
|
||||
check(h,itemCount==1658&&blockCount==1286&&members.size()==1815,"Complete document inventory uses real registry IDs");h.succeed();
|
||||
check(h,itemCount==1780&&blockCount==1374&&members.size()==1937,"Complete document inventory uses real registry IDs");h.succeed();
|
||||
}
|
||||
@GameTest public void everyAdvancementTeachesItsCollectionsWithoutIngredients(GameTestHelper h){
|
||||
var server=h.getLevel().getServer();var entries=RecipeCollections.entries(server);var all=new TreeSet<String>();entries.forEach(e->all.addAll(e.advancements()));
|
||||
|
||||
+22
-9
@@ -43,7 +43,7 @@ public final class Companion019GameTests {
|
||||
h.assertTrue(p.getAttached(CompanionService.RECORD).equals(bad),"Future data is not silently reset");h.succeed();
|
||||
}
|
||||
@GameTest public void switchingEggRemovesPassiveAndActive(GameTestHelper h){
|
||||
var p=player(h,CompanionType.CAT);CompanionPassives.tick(p);
|
||||
var p=player(h,CompanionType.CAT);equipForWork(p,CompanionType.CAT);CompanionPassives.tick(p);
|
||||
h.assertTrue(Math.abs(p.getAttributeValue(Attributes.FALL_DAMAGE_MULTIPLIER)-.8)<.001,"Cat applies native fall attribute");
|
||||
CompanionEffects.add(p,p,CompanionEffects.Kind.DEFENCE,100,.25F);
|
||||
AccessoryAccess.of(p).setItem(0,new ItemStack(Items.ZOMBIE_SPAWN_EGG));CompanionService.tick(p);
|
||||
@@ -51,9 +51,9 @@ public final class Companion019GameTests {
|
||||
h.assertTrue(CompanionService.passive(p)==null,"New egg has its ten-second attunement");h.succeed();
|
||||
}
|
||||
@GameTest public void poisonDurationAndMilkUseNativeHooks(GameTestHelper h){
|
||||
var p=player(h,CompanionType.CAVE_SPIDER);p.addEffect(new MobEffectInstance(MobEffects.POISON,100,0));
|
||||
var p=player(h,CompanionType.CAVE_SPIDER);equipForWork(p,CompanionType.CAVE_SPIDER);p.addEffect(new MobEffectInstance(MobEffects.POISON,100,0));
|
||||
h.assertTrue(p.getEffect(MobEffects.POISON).getDuration()==80,"New poison is shortened by native addEffect hook");
|
||||
equip(p,CompanionType.COW);p.addEffect(new MobEffectInstance(MobEffects.SPEED,300,0));
|
||||
equipForWork(p,CompanionType.COW);p.addEffect(new MobEffectInstance(MobEffects.SPEED,300,0));
|
||||
CompanionPassives.consume(new ItemStack(Items.MILK_BUCKET),p::removeAllEffects);
|
||||
h.assertTrue(p.hasEffect(MobEffects.SPEED)&&!p.hasEffect(MobEffects.POISON),"Selective milk preserves beneficial effects while native clear is called");h.succeed();
|
||||
}
|
||||
@@ -103,16 +103,19 @@ public final class Companion019GameTests {
|
||||
}
|
||||
@GameTest public void creatureProfilesActuallyMove(GameTestHelper h){
|
||||
var p=player(h,CompanionType.ENDER_DRAGON);var pet=FamiliarService.active(p);var start=pet.position();
|
||||
for(int i=0;i<120;i++)pet.tick();
|
||||
int initialTicks=pet.tickCount;
|
||||
// Native 26.3 commonTick advances the entity clock before tick; calling tick alone freezes roaming.
|
||||
for(int i=0;i<120;i++)h.getLevel().tickNonPassenger(pet);
|
||||
h.assertTrue(pet.tickCount==initialTicks+120,"Native entity cycle advances the familiar clock");
|
||||
h.assertTrue(pet.isNoGravity()&&pet.motion()==FamiliarMotion.DRAGON,"Dragon uses aerial navigation");
|
||||
h.assertTrue(pet.position().distanceToSqr(start)>1&&pet.getY()>p.getY()+.5,"Native navigation produces an actual airborne displacement");
|
||||
h.assertTrue(h.getLevel().noBlockCollision(pet,pet.getBoundingBox()),"Flying familiar stays out of solid blocks");
|
||||
equip(p,CompanionType.GHAST);pet=FamiliarService.active(p);pet.tick();h.assertTrue(pet.isNoGravity(),"Ghast hovers rather than walking");
|
||||
equip(p,CompanionType.WITHER);pet=FamiliarService.active(p);pet.tick();h.assertTrue(pet.isNoGravity(),"Wither hovers rather than walking");h.succeed();
|
||||
equip(p,CompanionType.GHAST);pet=FamiliarService.active(p);h.getLevel().tickNonPassenger(pet);h.assertTrue(pet.isNoGravity(),"Ghast hovers rather than walking");
|
||||
equip(p,CompanionType.WITHER);pet=FamiliarService.active(p);h.getLevel().tickNonPassenger(pet);h.assertTrue(pet.isNoGravity(),"Wither hovers rather than walking");h.succeed();
|
||||
}
|
||||
@GameTest public void aquaticPetFlopsAndThenSwims(GameTestHelper h){
|
||||
var p=player(h,CompanionType.SALMON);var pet=FamiliarService.active(p);pet.tick();
|
||||
h.assertTrue(!pet.isNoGravity()&&pet.getAttributeValue(Attributes.MOVEMENT_SPEED)<=.06,"Fish struggles on dry ground");
|
||||
h.assertTrue(!pet.isNoGravity()&&Math.abs(pet.getAttributeValue(Attributes.MOVEMENT_SPEED)-fr.koka.sanctuary.familiar.FamiliarBattle.profile(p).speed())<.0001,"Combat fish uses its species movement profile on dry ground");
|
||||
var at=p.blockPosition();for(int x=-4;x<=4;x++)for(int z=-4;z<=4;z++)for(int y=0;y<4;y++)h.getLevel().setBlockAndUpdate(at.offset(x,y,z),Blocks.WATER.defaultBlockState());
|
||||
pet.setPos(p.position().add(2,0,0));p.baseTick();for(int i=0;i<15;i++)pet.tick();
|
||||
h.assertTrue(pet.isInWater()&&pet.isNoGravity()&&pet.getAttributeValue(Attributes.MOVEMENT_SPEED)>.3,"Fish switches to buoyant native swimming in water");h.succeed();
|
||||
@@ -150,7 +153,7 @@ public final class Companion019GameTests {
|
||||
}finally{CompanionTrades.cancel(p);villager.discard();}h.succeed();
|
||||
}
|
||||
@GameTest public void cropCyclesStopAfterOwnerLeaves(GameTestHelper h){
|
||||
var p=player(h,CompanionType.BEE);h.getLevel().addNewPlayer(p);var at=p.blockPosition().offset(2,0,0);h.getLevel().setBlockAndUpdate(at.below(),Blocks.FARMLAND.defaultBlockState());h.getLevel().setBlockAndUpdate(at,Blocks.WHEAT.defaultBlockState());
|
||||
var p=player(h,CompanionType.BEE);equipForWork(p,CompanionType.BEE);h.getLevel().addNewPlayer(p);var at=p.blockPosition().offset(2,0,0);h.getLevel().setBlockAndUpdate(at.below(),Blocks.FARMLAND.defaultBlockState());h.getLevel().setBlockAndUpdate(at,Blocks.WHEAT.defaultBlockState());
|
||||
try{
|
||||
var state=h.getLevel().getBlockState(at);var random=net.minecraft.util.RandomSource.create(42);
|
||||
CompanionProduction.cropRoll(h.getLevel(),at,state,random,25,1);String raw=h.getLevel().getAttached(CompanionProduction.CROPS);
|
||||
@@ -161,7 +164,7 @@ public final class Companion019GameTests {
|
||||
}finally{p.discard();}h.succeed();
|
||||
}
|
||||
@GameTest public void furnaceBonusConsumesFuelAndKeepsOneOutput(GameTestHelper h){
|
||||
var p=player(h,CompanionType.BLAZE);h.getLevel().addNewPlayer(p);
|
||||
var p=player(h,CompanionType.BLAZE);equipForWork(p,CompanionType.BLAZE);h.getLevel().addNewPlayer(p);
|
||||
try{
|
||||
var a=p.blockPosition().offset(2,0,0);var b=p.blockPosition().offset(6,0,0);
|
||||
h.getLevel().setBlockAndUpdate(a,Blocks.FURNACE.defaultBlockState());h.getLevel().setBlockAndUpdate(b,Blocks.FURNACE.defaultBlockState());
|
||||
@@ -195,6 +198,16 @@ public final class Companion019GameTests {
|
||||
var at=p.blockPosition();for(int x=-7;x<=7;x++)for(int z=-7;z<=7;z++)for(int y=-1;y<8;y++)h.getLevel().setBlockAndUpdate(at.offset(x,y,z),(y==-1?Blocks.STONE:Blocks.AIR).defaultBlockState());
|
||||
p.setOnGround(true);equip(p,type);return p;
|
||||
}
|
||||
static void equipForWork(ServerPlayer p,CompanionType type){
|
||||
equip(p,type);
|
||||
if(CompanionService.passive(p)!=null)throw new AssertionError("Combat mode must suppress legacy work bonuses");
|
||||
if(!CompanionService.record(p).unlocked()){
|
||||
p.giveExperienceLevels(CompanionService.config(p).activeCost());
|
||||
if(!CompanionService.buy(p))throw new AssertionError("Fixture purchases work access through the server");
|
||||
}
|
||||
fr.koka.sanctuary.familiar.FamiliarBattle.command(p,"work");
|
||||
if(CompanionService.passive(p)!=type)throw new AssertionError("Explicit work command enables the attuned species");
|
||||
}
|
||||
static void equip(ServerPlayer p,CompanionType type){
|
||||
var item=BuiltInRegistries.ITEM.getValue(net.minecraft.resources.Identifier.withDefaultNamespace(type.id()+"_spawn_egg"));
|
||||
AccessoryAccess.of(p).setItem(0,new ItemStack(item));p.tickCount=((p.tickCount/10)+1)*10;FamiliarService.tick(p);CompanionService.tick(p);
|
||||
|
||||
+2
-2
@@ -75,9 +75,9 @@ public final class Companion032GameTests {
|
||||
h.assertTrue(CompanionBudget.state(p).horizontal()==0&&!CompanionBudget.state(p).rabbit(),"Actual stable terrain rearms flight");h.succeed();
|
||||
}
|
||||
@GameTest public void axolotlFoodDurationAndGolemKnockbackUseNativeHooks(GameTestHelper h){
|
||||
var p=Companion019GameTests.player(h,CompanionType.AXOLOTL);
|
||||
var p=Companion019GameTests.player(h,CompanionType.AXOLOTL);Companion019GameTests.equipForWork(p,CompanionType.AXOLOTL);
|
||||
h.assertTrue(new ItemStack(Items.BREAD).getUseDuration(p)==29,"Food is ten percent faster on land");
|
||||
Companion019GameTests.equip(p,CompanionType.IRON_GOLEM);
|
||||
Companion019GameTests.equipForWork(p,CompanionType.IRON_GOLEM);
|
||||
h.assertTrue(Math.abs(CompanionPassives.knockback(p,1,null)-.8)<.00001,"Golem grants constant twenty percent knockback reduction");h.succeed();
|
||||
}
|
||||
@GameTest public void invalidAimedCushionDoesNotConsumeWool(GameTestHelper h){
|
||||
|
||||
@@ -55,6 +55,7 @@ public final class Demeure006GameTests {
|
||||
return view==null ? 0 : view.influences().stream().filter(s -> s.subject().id().equals(player.getUUID().toString())).mapToDouble(FootprintStore.Share::score).sum();
|
||||
}
|
||||
private static boolean place(ServerPlayer player,Item item,BlockPos support) {
|
||||
player.setPos(support.getX()+2.5,support.getY()+1,support.getZ()+.5); // Within novice placement reach, outside the target cell.
|
||||
player.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(item));
|
||||
return ((BlockItem)item).place(new BlockPlaceContext(new UseOnContext(player,InteractionHand.MAIN_HAND,
|
||||
new BlockHitResult(Vec3.atCenterOf(support),Direction.UP,support,false)))).consumesAction();
|
||||
|
||||
+2
-2
@@ -119,7 +119,7 @@ public final class GravesFood038GameTests {
|
||||
}
|
||||
@GameTest public void ordinaryPlacementAndCreativeBreakPreserveGrave(GameTestHelper h){
|
||||
var p=player(h,1);var l=h.getLevel();var floor=h.absolutePos(new BlockPos(1,2,1));l.setBlockAndUpdate(floor,Blocks.STONE.defaultBlockState());var at=floor.above();
|
||||
var head=GraveService.head(p,List.of(new ItemStack(Items.DIAMOND,8)));p.getInventory().setItem(0,head);p.setPos(at.getX()+3.5,at.getY(),at.getZ()+.5);
|
||||
var head=GraveService.head(p,List.of(new ItemStack(Items.DIAMOND,8)));p.getInventory().setItem(0,head);p.setPos(at.getX()+2.5,at.getY(),at.getZ()+.5);
|
||||
var context=new net.minecraft.world.item.context.UseOnContext(p,net.minecraft.world.InteractionHand.MAIN_HAND,new net.minecraft.world.phys.BlockHitResult(net.minecraft.world.phys.Vec3.atCenterOf(floor).add(0,.5,0),Direction.UP,floor,false));
|
||||
head.useOn(context);h.assertTrue(l.getBlockEntity(at) instanceof SkullBlockEntity&&GraveService.isGrave(l.getBlockEntity(at)),"Native BlockItem placement carries grave components");
|
||||
var skull=(SkullBlockEntity)l.getBlockEntity(at);h.assertTrue(total(GraveService.head(skull).get(DataComponents.CONTAINER).nonEmptyItemCopyStream().toList(),Items.DIAMOND)==8,"Placed portable head keeps its items");
|
||||
@@ -130,7 +130,7 @@ public final class GravesFood038GameTests {
|
||||
@GameTest public void familiarSaturationPreviewUsesActualServerPassive(GameTestHelper h){
|
||||
var p=player(h,1);var progress=ProgressRecord.INITIAL;for(int i=0;i<7;i++)progress=progress.buy("hunger",1);p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);
|
||||
for(var species:new fr.koka.sanctuary.companion.CompanionType[]{fr.koka.sanctuary.companion.CompanionType.PIG,fr.koka.sanctuary.companion.CompanionType.MOOSHROOM}){
|
||||
Companion019GameTests.equip(p,species);h.assertTrue(fr.koka.sanctuary.companion.CompanionService.passive(p)==species,"Real familiar passive is ready");
|
||||
Companion019GameTests.equipForWork(p,species);h.assertTrue(fr.koka.sanctuary.companion.CompanionService.passive(p)==species,"Real familiar passive is ready");
|
||||
for(var item:new Item[]{Items.APPLE,Items.MUSHROOM_STEW,Items.COOKED_BEEF})for(int food:new int[]{4,13,19}){
|
||||
var stack=new ItemStack(item);p.getFoodData().setFoodLevel(food);p.getFoodData().setSaturation(2);var state=FoodSync.snapshot(p);var preview=state.preview(stack);
|
||||
h.assertTrue(state.stewMultiplier()>1&&(species==fr.koka.sanctuary.companion.CompanionType.PIG)==(state.foodMultiplier()>1),"Snapshot carries actual dietary modifiers");
|
||||
|
||||
+3
-3
@@ -58,8 +58,8 @@ public final class Inventory010GameTests {
|
||||
@GameTest public void everyNativeContainerKeepsItsIndices(GameTestHelper h) {
|
||||
var vanilla=Progression003GameTests.player(h);var p=player(h,5);int menus=0;
|
||||
for(var type:BuiltInRegistries.MENU) {
|
||||
AbstractContainerMenu before=type.create(12,vanilla.getInventory());
|
||||
AbstractContainerMenu menu=type.create(12,p.getInventory());
|
||||
AbstractContainerMenu before=InventoryMenuFixtures.create(type,12,vanilla.getInventory());
|
||||
AbstractContainerMenu menu=InventoryMenuFixtures.create(type,12,p.getInventory());
|
||||
var ext=(SanctuaryExtendedInventoryMenu)menu;
|
||||
ext.sanctuary$ensureExtendedInventory();
|
||||
if(ext.sanctuary$playerInventoryStart()<0) continue;
|
||||
@@ -77,7 +77,7 @@ public final class Inventory010GameTests {
|
||||
@GameTest public void machineQuickMovesAndMounts(GameTestHelper h) {
|
||||
var p=player(h,5);
|
||||
var menus=new ArrayList<AbstractContainerMenu>();
|
||||
for(var type:BuiltInRegistries.MENU) menus.add(type.create(16,p.getInventory()));
|
||||
for(var type:BuiltInRegistries.MENU) menus.add(InventoryMenuFixtures.create(type,16,p.getInventory()));
|
||||
var donkey=(net.minecraft.world.entity.animal.equine.AbstractHorse)BuiltInRegistries.ENTITY_TYPE.getValue(net.minecraft.resources.Identifier.withDefaultNamespace("donkey")).create(h.getLevel(),net.minecraft.world.entity.EntitySpawnReason.COMMAND);
|
||||
var nautilus=(net.minecraft.world.entity.animal.nautilus.AbstractNautilus)BuiltInRegistries.ENTITY_TYPE.getValue(net.minecraft.resources.Identifier.withDefaultNamespace("nautilus")).create(h.getLevel(),net.minecraft.world.entity.EntitySpawnReason.COMMAND);
|
||||
menus.add(new HorseInventoryMenu(17,p.getInventory(),new SimpleContainer(15),donkey,5));
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ public final class Inventory012GameTests {
|
||||
int compared=0;
|
||||
for(var type:BuiltInRegistries.MENU) for(Item item:new Item[]{Items.DIRT,Items.COAL,Items.IRON_ORE,Items.IRON_SWORD,Items.LAPIS_LAZULI,Items.DIAMOND,Items.PAPER,Items.GLASS_BOTTLE}) {
|
||||
var a=player(h,5);var b=player(h,5);
|
||||
var left=type.create(23,a.getInventory());var right=type.create(23,b.getInventory());
|
||||
var left=InventoryMenuFixtures.create(type,23,a.getInventory());var right=InventoryMenuFixtures.create(type,23,b.getInventory());
|
||||
var ext=extend(left);var other=extend(right);if(!ext.sanctuary$hasExtendedInventory()) continue;
|
||||
int nativeIndex=ext.sanctuary$playerInventoryStart(),extraIndex=other.sanctuary$overflowEnd()-1;
|
||||
a.getInventory().setItem(9,new ItemStack(item,Math.min(13,item.getDefaultMaxStackSize())));
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||
import net.minecraft.world.inventory.MenuType;
|
||||
import fr.koka.sanctuary.plans.ClayWorkshopMenu;
|
||||
import fr.koka.sanctuary.plans.Statuary;
|
||||
import fr.koka.sanctuary.multiblock.MultiblockMenu;
|
||||
import fr.koka.sanctuary.multiblock.Multiblocks;
|
||||
|
||||
/** Supply the opening data required by Sanctuary's extended menus; retain coverage of every registry entry. */
|
||||
final class InventoryMenuFixtures {
|
||||
private InventoryMenuFixtures() { }
|
||||
static AbstractContainerMenu create(MenuType<?> type,int id,Inventory inventory) {
|
||||
if(type==Statuary.MENU)return new ClayWorkshopMenu(id,inventory,new ClayWorkshopMenu.View(BlockPos.ZERO,new UUID(0,1),List.of()));
|
||||
if(type==Multiblocks.MENU)return new MultiblockMenu(id,inventory,new MultiblockMenu.View(false,0,1,"",0,java.util.stream.IntStream.range(0,54).toArray()));
|
||||
return type.create(id,inventory);
|
||||
}
|
||||
}
|
||||
+1
@@ -159,6 +159,7 @@ public final class MaterialActivityGameTests {
|
||||
}
|
||||
|
||||
private static boolean place(ServerPlayer player, Item item, BlockPos support) {
|
||||
player.setPos(support.getX()+2.5,support.getY()+1,support.getZ()+.5); // Within novice placement reach, outside the target cell.
|
||||
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(item));
|
||||
var hit = new BlockHitResult(Vec3.atCenterOf(support), Direction.UP, support, false);
|
||||
return ((BlockItem) item).place(new BlockPlaceContext(new UseOnContext(player, InteractionHand.MAIN_HAND, hit))).consumesAction();
|
||||
|
||||
+10
@@ -338,6 +338,16 @@ public final class PopulationDiagnostics {
|
||||
helper.assertTrue(cell.x()>=owner.minX() && cell.x()<=owner.maxX() && cell.z()>=owner.minZ() && cell.z()<=owner.maxZ(),
|
||||
"Hydrology must never be truncated at or written across region ownership: "+cell);
|
||||
helper.assertTrue(ids.contains(cell.featureId()),"Each retained cell needs its complete owning feature");
|
||||
if (plan.isOutletCell(cell)) {
|
||||
// Alpha30 bank cuts remove rock and deliberately need no foundation.
|
||||
helper.assertTrue(!cell.hasWater() && cell.sedimentDepth() == 0
|
||||
&& cell.carveTop() > cell.bedY(),
|
||||
"A bank opening must only carve, never add sediment or a source: " + cell);
|
||||
helper.assertTrue(plan.spills().stream().anyMatch(spill -> spill.id() == cell.featureId()
|
||||
&& spill.toFeatureId() == -1),
|
||||
"Every bank opening belongs to a declared terminal spill: " + cell);
|
||||
continue;
|
||||
}
|
||||
for(int y=cell.bedY()-cell.sedimentDepth()-1;y<=cell.bedY();y++)
|
||||
helper.assertTrue(random.sampleBlockValueUncached(
|
||||
generator.generatorSettings().value().noiseRouter().finalDensity(),cell.x(),y,cell.z())>0,
|
||||
|
||||
+4
-2
@@ -119,8 +119,10 @@ public final class Progression003GameTests {
|
||||
@GameTest
|
||||
public void serverCustomNameConfiguration(GameTestHelper helper) {
|
||||
var root=helper.getLevel().getServer().getCommands().getDispatcher().getRoot().getChild("sanctuary");
|
||||
helper.assertTrue(!root.canUse(player(helper).createCommandSourceStack())
|
||||
&& root.canUse(helper.getLevel().getServer().createCommandSourceStack()),"Names registration preserves existing operator-only Sanctuary commands");
|
||||
var ordinary=player(helper).createCommandSourceStack();var console=helper.getLevel().getServer().createCommandSourceStack();
|
||||
helper.assertTrue(root.canUse(ordinary)&&root.getChild("intro").canUse(ordinary),"Public Sanctuary navigation stays available");
|
||||
helper.assertTrue(!root.getChild("names").canUse(ordinary)&&root.getChild("names").canUse(console),"Names administration remains operator-only");
|
||||
helper.assertTrue(!root.getChild("community").getChild("admin").canUse(ordinary)&&root.getChild("community").getChild("admin").canUse(console),"Community administration remains operator-only");
|
||||
var config=MobNamesConfig.parse("{\"schema\":1,\"enabled\":true,\"types\":{\"minecraft:cow\":{\"includeDefaults\":false,\"names\":[\"Marguerite\",\"Rosalie\"]},\"example:creature\":{\"includeDefaults\":true,\"names\":[\"Pouet\"]}}}");
|
||||
helper.assertTrue(config.types().get("minecraft:cow").names().size()==2
|
||||
&& !config.types().get("minecraft:cow").includeDefaults(),"Per-type replacement and custom names");
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ public final class UnifiedWorldGameTests {
|
||||
PopulationDiagnostics.verifyDensity(helper);
|
||||
var level = helper.getLevel();
|
||||
var binding = binding(level);
|
||||
helper.assertTrue(level.getMinY() == 0 && level.getHeight() == 384, "Unification preserves the complete vertical world extent");
|
||||
helper.assertTrue(level.getMinY() == 0 && level.getHeight() == 640, "Unification preserves the complete vertical world extent");
|
||||
var spawn = SanctuaryWorldGameTests.initialSpawn;
|
||||
helper.assertTrue(spawn != null, "Capture the original natural spawn before the GameTest fixtures move it");
|
||||
var at = spawn.pos();
|
||||
|
||||
@@ -44,6 +44,7 @@ public final class SanctuaryMod implements ModInitializer {
|
||||
@Override
|
||||
public void onInitialize() {
|
||||
fr.koka.sanctuary.community.CommunityNetwork.register();
|
||||
fr.koka.sanctuary.community.ServerMetrics.register();
|
||||
fr.koka.sanctuary.building.ColoredBricks.register();
|
||||
fr.koka.sanctuary.gems.Gems.register();
|
||||
fr.koka.sanctuary.sky.VortexClouds.register();
|
||||
|
||||
@@ -8,30 +8,27 @@ import net.minecraft.client.gui.components.*;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.util.FormattedCharSequence;
|
||||
import net.minecraft.world.item.component.ResolvableProfile;
|
||||
import static fr.koka.sanctuary.client.CommunityClient.*;
|
||||
|
||||
/** Photo left, generous headline and smaller text right, followed by the separate discussion. */
|
||||
/** Full-width hero photo, headline and description share one dark article container. */
|
||||
final class CommunityArticle extends AbstractWidget implements AutoCloseable {
|
||||
private final JsonObject entry;
|
||||
private final int imageWidth,textX;
|
||||
private final int imageWidth,imageHeight;
|
||||
private final float titleScale=1.35f;
|
||||
private final List<FormattedCharSequence> title,body;
|
||||
private CommunityPhotoWidget photo;
|
||||
CommunityArticle(int width,JsonObject entry) {
|
||||
super(0,0,width,100,Component.literal(str(entry,"title")));this.entry=entry;imageWidth=Math.max(72,width*42/100-16);textX=imageWidth+24;
|
||||
super(0,0,width,100,Component.literal(str(entry,"title")));imageWidth=width-20;imageHeight=Math.max(40,imageWidth*9/16);
|
||||
var font=Minecraft.getInstance().font;
|
||||
title=font.split(getMessage().copy().withStyle(net.minecraft.ChatFormatting.BOLD),Math.max(30,(int)((width-textX-12)/titleScale)));
|
||||
body=font.split(Component.literal(str(entry,"body")),Math.max(40,width-textX-12));
|
||||
height=Math.max(imageWidth*9/16+24,24+title.size()*15+12+body.size()*11);
|
||||
title=font.split(getMessage().copy().withStyle(net.minecraft.ChatFormatting.BOLD),Math.max(30,(int)((width-24)/titleScale)));
|
||||
body=font.split(Component.literal(str(entry,"body")),Math.max(40,width-24));
|
||||
height=12+imageHeight+14+title.size()*15+10+body.size()*11+12;
|
||||
try {
|
||||
if(!str(entry,"photo").isEmpty())photo=new CommunityPhotoWidget(imageWidth,Math.max(40,imageWidth*9/16),fr.koka.sanctuary.community.CommunityPhoto.decode(Base64.getDecoder().decode(str(entry,"photo")),960,540),text("photo"),false,null);
|
||||
if(!str(entry,"photo").isEmpty())photo=new CommunityPhotoWidget(imageWidth,imageHeight,fr.koka.sanctuary.community.CommunityPhoto.decode(Base64.getDecoder().decode(str(entry,"photo")),960,540),text("photo"),false,null);
|
||||
}catch(Exception ignored){}
|
||||
active=false;
|
||||
}
|
||||
int textOffset(){return textX;}
|
||||
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int mx,int my,float delta) {
|
||||
var font=Minecraft.getInstance().font;int x=getX(),y=getY(),tx=x+textX,top=y+12;
|
||||
var font=Minecraft.getInstance().font;int x=getX(),y=getY(),tx=x+12,top=y+12+imageHeight+14;
|
||||
g.fill(x,y,getRight(),getBottom(),0xed171d23);
|
||||
if(photo!=null){photo.setPosition(x+10,y+12);photo.extractRenderState(g,mx,my,delta);}
|
||||
else g.text(font,text("photo_unavailable"),x+10,y+12,0xffaaaaaa,false);
|
||||
|
||||
@@ -18,8 +18,8 @@ final class CommunityCard extends AbstractButton implements AutoCloseable {
|
||||
private final PlayerFaceWidget face;
|
||||
private final boolean compact,article;
|
||||
private CommunityPhotoWidget photo;
|
||||
static int color(String category) {return switch(category){case "event"->0xffba93dd;case "work"->0xffd4ac71;case "need"->0xff80c1a4;default->0xff80b2d2;};}
|
||||
private static int background(String category) {return switch(category){case "event"->0xff2e253a;case "work"->0xff322b20;case "need"->0xff20322c;default->0xff202d37;};}
|
||||
static int color(String category) {return switch(category){case "event"->0xffba93dd;case "work"->0xffd4ac71;case "need"->0xff80c1a4;default->0xffead47e;};}
|
||||
static int background(String category) {return switch(category){case "event"->0xff2e253a;case "work"->0xff322b20;case "need"->0xff20322c;default->0xff34301f;};}
|
||||
CommunityCard(int width,JsonObject entry,boolean compact,Runnable action) {
|
||||
super(0,0,width,compact?44:90,Component.literal(str(entry,"title")));this.entry=entry;this.compact=compact;this.action=action;article=str(entry,"kind").equals("article");
|
||||
face=new PlayerFaceWidget(compact?20:24,ResolvableProfile.createUnresolved(UUID.fromString(str(entry,"authorId"))));
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.components.AbstractWidget;
|
||||
import net.minecraft.client.gui.components.PlayerSkinWidget;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.client.input.MouseButtonEvent;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.util.FormattedCharSequence;
|
||||
import net.minecraft.world.entity.player.PlayerSkin;
|
||||
import net.minecraft.world.item.component.ResolvableProfile;
|
||||
import static fr.koka.sanctuary.client.CommunityClient.*;
|
||||
|
||||
/** A GUI-only author portrait and persistent speech bubble; no world entity is created. */
|
||||
final class CommunityNoticeScene extends AbstractWidget {
|
||||
private final JsonObject entry;
|
||||
private final List<FormattedCharSequence> heading,title,body;
|
||||
private final Portrait portrait;
|
||||
private final int portraitWidth,bubbleX;
|
||||
CommunityNoticeScene(int width,JsonObject entry) {
|
||||
super(0,0,width,160,Component.literal(str(entry,"title")).append("\n").append(str(entry,"body")));
|
||||
this.entry=entry;
|
||||
portraitWidth=Math.clamp(width/4,56,94);bubbleX=portraitWidth+10;
|
||||
var font=Minecraft.getInstance().font;int wrap=Math.max(36,width-bubbleX-18);
|
||||
heading=font.split(text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))),wrap);
|
||||
title=font.split(Component.literal(str(entry,"title")).withStyle(net.minecraft.ChatFormatting.BOLD),wrap);
|
||||
body=font.split(Component.literal(str(entry,"body")),wrap);
|
||||
height=Math.max(154,28+(heading.size()+title.size()+body.size())*11);
|
||||
portrait=new Portrait(portraitWidth,140,skin(entry));active=false;
|
||||
}
|
||||
private static Supplier<PlayerSkin> skin(JsonObject entry) {
|
||||
var m=Minecraft.getInstance();var uuid=UUID.fromString(str(entry,"authorId"));
|
||||
var fallback=m.playerSkinRenderCache().createLookup(ResolvableProfile.createUnresolved(uuid));
|
||||
return ()->{
|
||||
var connection=m.getConnection();var online=connection==null?null:connection.getPlayerInfo(uuid);
|
||||
return online!=null?online.getSkin():fallback.get().playerSkin();
|
||||
};
|
||||
}
|
||||
float yaw(){return portrait.yaw;}
|
||||
float pitch(){return portrait.pitch;}
|
||||
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int mx,int my,float delta) {
|
||||
int x=getX(),y=getY(),bx=x+bubbleX,accent=CommunityCard.color(str(entry,"category"));
|
||||
g.fill(x,y,getRight(),getBottom(),0xA013171C);
|
||||
portrait.setPosition(x,y+6);portrait.extractRenderState(g,mx,my,delta);
|
||||
g.fill(bx,y,getRight(),getBottom(),CommunityCard.background(str(entry,"category")));
|
||||
g.outline(bx,y,width-bubbleX,height,accent);
|
||||
for(int step=0;step<6;step++)g.fill(bx-step-1,y+39+step,bx-step,y+50-step,accent);
|
||||
int top=y+9;
|
||||
for(var line:heading){g.text(Minecraft.getInstance().font,line,bx+9,top,accent,false);top+=11;}
|
||||
top+=5;
|
||||
for(var line:title){g.text(Minecraft.getInstance().font,line,bx+9,top,0xfffaf5e6,false);top+=11;}
|
||||
top+=5;
|
||||
for(var line:body){g.text(Minecraft.getInstance().font,line,bx+9,top,0xffeeeae1,false);top+=11;}
|
||||
}
|
||||
@Override protected void updateWidgetNarration(NarrationElementOutput output){defaultButtonNarrationText(output);}
|
||||
|
||||
private static final class Portrait extends PlayerSkinWidget {
|
||||
private float pitch=-5,yaw=30;
|
||||
Portrait(int width,int height,Supplier<PlayerSkin> skin){super(width,height,Minecraft.getInstance().getEntityModels(),skin);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))*0.55f;
|
||||
float nextPitch=-(float)Math.toDegrees(Math.atan((my-getY()-30)/180f))*0.4f;
|
||||
// Native 26.3 skin widget applies 2.5 degrees per drag unit; use bounded absolute targets.
|
||||
super.onDrag(null,(nextYaw-yaw)/2.5,(pitch-nextPitch)/2.5);
|
||||
yaw=nextYaw;pitch=nextPitch;
|
||||
super.extractWidgetRenderState(g,mx,my,delta);
|
||||
}
|
||||
@Override protected void onDrag(MouseButtonEvent event,double dx,double dy) { }
|
||||
}
|
||||
}
|
||||
@@ -16,67 +16,101 @@ import static fr.koka.sanctuary.client.CommunityClient.*;
|
||||
public final class CommunityPausePanels implements AutoCloseable {
|
||||
private final Screen screen;
|
||||
private final PauseCommunityFeed articles, notices;
|
||||
private final StringWidget date, clock;
|
||||
private final Calendar date;
|
||||
private final PauseTextButton bulletin;
|
||||
private final PauseAtlasWidget map;
|
||||
private int ticks;
|
||||
private boolean closed;
|
||||
public CommunityPausePanels(Screen screen,Consumer<AbstractWidget> add,List<Button> nativeButtons) {
|
||||
this.screen=screen;
|
||||
var font=Minecraft.getInstance().font;
|
||||
int margin=8,gap=10,top=40,bottom=screen.height-38;
|
||||
int margin=8,gap=10,top=50,bottom=screen.height-39;
|
||||
int left=Math.min(132,Math.max(76,screen.width/5)),right=Math.min(244,Math.max(104,screen.width*31/100));
|
||||
int centerX=margin+left+gap,rightX=screen.width-margin-right,centerWidth=rightX-gap-centerX;
|
||||
date=new StringWidget(margin,8,left,20,Component.empty(),font);
|
||||
clock=new StringWidget(rightX,8,right,20,Component.empty(),font);
|
||||
add.accept(date);add.accept(clock);
|
||||
int headerSide=Math.max(84,screen.width/5);
|
||||
bulletin=new PauseTextButton(headerSide,3,screen.width-2*headerSide,32,text("bulletin"),()->open(screen,"bulletin","",false));
|
||||
int optionHeight=screen.height<280?16:20,navGap=screen.height<280?3:4,groupGap=screen.height<280?8:12;
|
||||
int optionsTop=bottom-(3*optionHeight+8);
|
||||
int navHeight=Math.min(20,Math.max(11,(optionsTop-top-8-4*navGap-groupGap)/5));
|
||||
int step=navHeight+navGap;
|
||||
add.accept(new Frame(screen.width,screen.height,left,top+2*step+(groupGap-navGap)/2));
|
||||
date=new Calendar(margin,3,left);
|
||||
add.accept(date);
|
||||
bulletin=new PauseTextButton(centerX,3,screen.width-margin-centerX,38,text("bulletin"),()->open(screen,"bulletin",items("bulletins").isEmpty()?"":str(items("bulletins").get(0).getAsJsonObject(),"id"),false));
|
||||
add.accept(bulletin);
|
||||
String[] sections={"blocks","history","progress","identity"};
|
||||
int optionsTop=screen.height-76;
|
||||
int navHeight=Math.min(20,Math.max(16,(optionsTop-top-28)/6));
|
||||
int step=navHeight+4;
|
||||
String[] sections={"blocks","progress","identity"};
|
||||
for(int i=0;i<sections.length;i++) {
|
||||
String target=sections[i];
|
||||
String target=sections[i];int y=top+i*step+(i>=2?groupGap:0);
|
||||
add.accept(Button.builder(fr.koka.sanctuary.progression.ProgressionService.label(target),b->ProgressionClient.open(screen,target))
|
||||
.bounds(margin,top+i*step,left,navHeight).build());
|
||||
.bounds(margin,y,left,navHeight).build());
|
||||
}
|
||||
add.accept(Button.builder(fr.koka.sanctuary.cycle.CycleService.label("faction"),b->CycleClient.open(screen,"faction")).bounds(margin,top+4*step,left,navHeight).build());
|
||||
add.accept(Button.builder(fr.koka.sanctuary.arena.Arenas.text("title"),b->ArenaClient.open(screen)).bounds(margin,top+5*step,left,navHeight).build());
|
||||
add.accept(Button.builder(fr.koka.sanctuary.cycle.CycleService.label("faction"),b->CycleClient.open(screen,"faction")).bounds(margin,top+3*step+groupGap,left,navHeight).build());
|
||||
add.accept(Button.builder(fr.koka.sanctuary.arena.Arenas.text("title"),b->ArenaClient.open(screen)).bounds(margin,top+4*step+groupGap,left,navHeight).build());
|
||||
map=new PauseAtlasWidget(screen,centerX,top,centerWidth,Math.max(30,bottom-top));add.accept(map);
|
||||
place(nativeButtons,add,"menu.returnToGame",rightX,screen.height-28,right);
|
||||
int resumeWidth=Math.min(200,screen.width-24);
|
||||
place(nativeButtons,add,"menu.returnToGame",(screen.width-resumeWidth)/2,screen.height-26,resumeWidth);
|
||||
int feedHeight=(bottom-top-gap)/2;
|
||||
articles=new PauseCommunityFeed(screen,add,rightX,top,right,feedHeight,"article");
|
||||
notices=new PauseCommunityFeed(screen,add,rightX,top+feedHeight+gap,right,bottom-top-feedHeight-gap,"notice");
|
||||
place(nativeButtons,add,"menu.options",margin,optionsTop,left);
|
||||
place(nativeButtons,add,"options.worldOptions.button",margin,optionsTop+24,left);
|
||||
place(nativeButtons,add,"menu.options",margin,optionsTop,left,optionHeight);
|
||||
place(nativeButtons,add,"options.worldOptions.button",margin,optionsTop+optionHeight+4,left,optionHeight);
|
||||
for(var button:nativeButtons) if(key(button).equals("menu.returnToMenu") || key(button).equals("menu.disconnect")) {
|
||||
button.setRectangle(left,20,margin,optionsTop+48);button.setTooltip(Tooltip.create(button.getMessage()));add.accept(button);
|
||||
button.setRectangle(left,optionHeight,margin,optionsTop+2*(optionHeight+4));button.setTooltip(Tooltip.create(button.getMessage()));add.accept(button);
|
||||
}
|
||||
updateHeader();
|
||||
}
|
||||
public static String key(Button button) { return button.getMessage().getContents() instanceof TranslatableContents t?t.getKey():""; }
|
||||
private static void place(List<Button> buttons,Consumer<AbstractWidget> add,String key,int x,int y,int width) {
|
||||
buttons.stream().filter(b->key(b).equals(key)).findFirst().ifPresent(b->{b.setRectangle(width,20,x,y);b.setTooltip(Tooltip.create(b.getMessage()));add.accept(b);});
|
||||
place(buttons,add,key,x,y,width,20);
|
||||
}
|
||||
private static void place(List<Button> buttons,Consumer<AbstractWidget> add,String key,int x,int y,int width,int height) {
|
||||
buttons.stream().filter(b->key(b).equals(key)).findFirst().ifPresent(b->{b.setRectangle(width,height,x,y);b.setTooltip(Tooltip.create(b.getMessage()));add.accept(b);});
|
||||
}
|
||||
private static JsonArray items(String key) { return overview.has(key)?overview.getAsJsonArray(key):new JsonArray(); }
|
||||
private void updateHeader() {
|
||||
var locale=Locale.forLanguageTag(Minecraft.getInstance().getLanguageManager().getSelected().replace('_','-'));
|
||||
var now=ZonedDateTime.now();
|
||||
date.setMessage(Component.literal(DateTimeFormatter.ofPattern("d MMMM yyyy",locale).format(now)));
|
||||
clock.setMessage(Component.literal(DateTimeFormatter.ofPattern("HH:mm",locale).format(now)));
|
||||
clock.setX(screen.width-8-Minecraft.getInstance().font.width(clock.getMessage()));clock.setWidth(Minecraft.getInstance().font.width(clock.getMessage()));
|
||||
date.update(now,locale);
|
||||
var list=items("bulletins");
|
||||
Component message=list.isEmpty()?text("bulletin_empty"):Component.literal(str(list.get(0).getAsJsonObject(),"title")+" · "+str(list.get(0).getAsJsonObject(),"body"));
|
||||
Component message=list.isEmpty()?text("bulletin_empty"):Component.literal(str(list.get(0).getAsJsonObject(),"title"));
|
||||
if(!overviewError.isEmpty())message=text(overviewError);
|
||||
bulletin.setMessage(message);
|
||||
bulletin.setTooltip(Tooltip.create(text("bulletin").append("\n").append(list.isEmpty()?message:Component.literal(str(list.get(0).getAsJsonObject(),"body")))));
|
||||
date.setWidth(Minecraft.getInstance().font.width(date.getMessage()));
|
||||
int left=Math.max(date.getRight()+8,screen.width/5),right=screen.width-Math.max(56,screen.width/5);
|
||||
bulletin.setRectangle(Math.max(24,right-left),32,left,3);
|
||||
int left=date.getRight()+10,right=screen.width-8;
|
||||
bulletin.setRectangle(Math.max(24,right-left),38,left,3);
|
||||
bulletin.active=available();
|
||||
}
|
||||
/** Shares Progression's native header/footer separator textures. */
|
||||
private static final class Frame extends AbstractWidget {
|
||||
private final int left,groupY;
|
||||
Frame(int width,int height,int left,int groupY){super(0,0,width,height,Component.empty());this.left=left;this.groupY=groupY;active=false;}
|
||||
@Override public void extractWidgetRenderState(net.minecraft.client.gui.GuiGraphicsExtractor g,int mx,int my,float delta) {
|
||||
var texture=Minecraft.getInstance().level==null?Screen.HEADER_SEPARATOR:Screen.INWORLD_HEADER_SEPARATOR;
|
||||
g.blit(net.minecraft.client.renderer.RenderPipelines.GUI_TEXTURED,texture,0,44,0,0,width,2,32,2);
|
||||
texture=Minecraft.getInstance().level==null?Screen.FOOTER_SEPARATOR:Screen.INWORLD_FOOTER_SEPARATOR;
|
||||
g.blit(net.minecraft.client.renderer.RenderPipelines.GUI_TEXTURED,texture,0,height-33,0,0,width,2,32,2);
|
||||
g.fill(12,groupY,left+4,groupY+1,0x665F6869);
|
||||
}
|
||||
@Override protected void updateWidgetNarration(net.minecraft.client.gui.narration.NarrationElementOutput out) { }
|
||||
}
|
||||
private static final class Calendar extends StringWidget {
|
||||
private String day="",monthYear="",time="";
|
||||
private final int calendarWidth;
|
||||
Calendar(int x,int y,int width){super(x,y,width,38,Component.empty(),Minecraft.getInstance().font);calendarWidth=width;}
|
||||
void update(ZonedDateTime now,Locale locale){
|
||||
day=DateTimeFormatter.ofPattern("d",locale).format(now);monthYear=DateTimeFormatter.ofPattern("MMMM yyyy",locale).format(now);time=DateTimeFormatter.ofPattern("HH:mm",locale).format(now);
|
||||
setMessage(Component.literal(DateTimeFormatter.ofPattern("d MMMM yyyy HH:mm",locale).format(now)));
|
||||
setWidth(calendarWidth);
|
||||
}
|
||||
@Override public void extractWidgetRenderState(net.minecraft.client.gui.GuiGraphicsExtractor g,int mx,int my,float delta) {
|
||||
var font=Minecraft.getInstance().font;int x=getX(),y=getY();
|
||||
g.fill(x,y,getRight(),getBottom(),0xA0202529);g.outline(x,y,width,height,0xFF66716E);
|
||||
g.pose().pushMatrix();g.pose().translate(x+6,y+4);g.pose().scale(1.8f,1.8f);
|
||||
g.text(font,day,0,0,0xFFF0EEE6,false);g.pose().popMatrix();
|
||||
g.text(font,time,getRight()-6-font.width(time),y+9,0xFFE0DED6,false);
|
||||
// Keep the month and year together even at the smallest GUI width.
|
||||
float scale=Math.min(1f,(width-12f)/Math.max(1,font.width(monthYear)));
|
||||
g.pose().pushMatrix();g.pose().translate(x+6,y+25);g.pose().scale(scale,scale);
|
||||
g.text(font,monthYear,0,0,0xFFE0DED6,false);g.pose().popMatrix();
|
||||
}
|
||||
}
|
||||
public void tick() {
|
||||
if(closed)return;
|
||||
map.tick(); updateHeader();
|
||||
|
||||
@@ -55,6 +55,7 @@ public final class CommunityScreen extends BlocodexFrame {
|
||||
@Override protected void init() {
|
||||
searchBox=null;
|
||||
closePhotos();initFrame("history",text(kind)); contentX=(width-contentWidth)/2; int w = contentWidth - 28;
|
||||
if(!composing && (kind.equals("notice") || kind.equals("article")) && data.has("thread")) {communityDetail(data.getAsJsonObject("thread"),w);return;}
|
||||
var layout = LinearLayout.vertical().spacing(8);
|
||||
if (!message.isEmpty()) line(layout,w,text(message));
|
||||
if (!available()) line(layout,w,text("unavailable"));
|
||||
@@ -100,22 +101,61 @@ public final class CommunityScreen extends BlocodexFrame {
|
||||
if(kind.equals("notice"))taskDetails(layout,w,entry);
|
||||
}
|
||||
if (kind.equals("notice")) line(layout,w,text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))));
|
||||
if (!kind.equals("bulletin")) {
|
||||
line(layout,w,text("replies"));
|
||||
var replies = thread.getAsJsonObject("replies");
|
||||
for (var item : replies.getAsJsonArray("entries")) message(layout,w,item.getAsJsonObject());
|
||||
pages(layout,w,replies);
|
||||
if (str(entry,"status").equals("open")) {
|
||||
useDraft("reply:"+id);
|
||||
var replyDraft=draft;
|
||||
var box=layout.addChild(MultiLineEditBox.builder().setPlaceholder(text("reply_hint")).build(font,w,52,text("reply")));
|
||||
box.setCharacterLimit(1000); box.setValue(replyDraft.body); box.setValueListener(value -> replyDraft.body=value);
|
||||
button(layout,100,"reply",() -> request(this,"create",replyDraft.operation,id,"reply","info","",replyDraft.body,0,0,result -> {
|
||||
if (result.has("error")) { message=str(result,"error"); rebuildWidgets(); return; }
|
||||
DRAFTS.values().removeIf(value -> value==replyDraft); message="saved"; needsLoad=true; stale=true;
|
||||
}));
|
||||
}
|
||||
if (!kind.equals("bulletin")) { replies(layout,w,thread);replyComposer(layout,w,entry); }
|
||||
}
|
||||
private void replies(LinearLayout layout,int w,JsonObject thread) {
|
||||
line(layout,w,text("replies"));
|
||||
var replies=thread.getAsJsonObject("replies");
|
||||
for(var item:replies.getAsJsonArray("entries"))message(layout,w,item.getAsJsonObject());
|
||||
if(replies.getAsJsonArray("entries").isEmpty())line(layout,w,text("conversation_empty"));
|
||||
pages(layout,w,replies);
|
||||
}
|
||||
private void replyComposer(LinearLayout layout,int w,JsonObject entry) {
|
||||
if(!str(entry,"status").equals("open"))return;
|
||||
useDraft("reply:"+id);var replyDraft=draft;
|
||||
var box=layout.addChild(MultiLineEditBox.builder().setPlaceholder(text("reply_hint")).build(font,w,52,text("reply")));
|
||||
box.setCharacterLimit(1000);box.setValue(replyDraft.body);box.setValueListener(value->replyDraft.body=value);
|
||||
button(layout,Math.min(w,100),"reply",()->request(this,"create",replyDraft.operation,id,"reply","info","",replyDraft.body,0,0,result->{
|
||||
if(result.has("error")){message=str(result,"error");rebuildWidgets();return;}
|
||||
DRAFTS.values().removeIf(value->value==replyDraft);message="saved";needsLoad=true;stale=true;
|
||||
}));
|
||||
}
|
||||
private void communityDetail(JsonObject thread,int w) {
|
||||
var entry=thread.getAsJsonObject("entry");
|
||||
var toolbar=LinearLayout.horizontal().spacing(5);
|
||||
button(toolbar,90,"back",()->{id="";page=0;data=new JsonObject();needsLoad=true;rebuildWidgets();});
|
||||
button(toolbar,90,"refresh",()->needsLoad=true);
|
||||
toolbar.setPosition(contentX+8,contentTop);toolbar.arrangeElements();toolbar.visitWidgets(this::addRenderableWidget);
|
||||
int top=contentTop+28,availableHeight=contentBottom-top;
|
||||
boolean columns=w>=470 && availableHeight>=180;
|
||||
int leftWidth=columns?(w-20)*3/5:w;
|
||||
var scene=LinearLayout.vertical().spacing(8);
|
||||
if(!message.isEmpty())line(scene,leftWidth,text(message));
|
||||
if(!available())line(scene,leftWidth,text("unavailable"));
|
||||
if(kind.equals("article")) {
|
||||
var article=new CommunityArticle(leftWidth,entry);cards.add(article);scene.addChild(article);
|
||||
articleAuthor(scene,leftWidth,entry);
|
||||
} else {
|
||||
scene.addChild(new CommunityNoticeScene(leftWidth,entry));
|
||||
author(scene,leftWidth,entry,true);
|
||||
}
|
||||
if(expandedId.equals(id))controls(scene,leftWidth,entry);
|
||||
if(kind.equals("notice"))taskDetails(scene,leftWidth,entry);
|
||||
if(columns) {
|
||||
scrollColumn(scene,leftWidth,contentX+8,top,availableHeight);
|
||||
int rightWidth=w-leftWidth-20,rightX=contentX+8+leftWidth+20;
|
||||
var conversation=LinearLayout.vertical().spacing(8);replies(conversation,rightWidth,thread);
|
||||
int composeHeight=str(entry,"status").equals("open")?88:0;
|
||||
scrollColumn(conversation,rightWidth,rightX,top,availableHeight-composeHeight);
|
||||
if(composeHeight>0){var compose=LinearLayout.vertical().spacing(8);replyComposer(compose,rightWidth,entry);compose.setPosition(rightX,contentBottom-80);compose.arrangeElements();compose.visitWidgets(this::addRenderableWidget);}
|
||||
} else {
|
||||
replies(scene,w,thread);replyComposer(scene,w,entry);
|
||||
scrollColumn(scene,w,contentX+8,top,availableHeight);
|
||||
}
|
||||
}
|
||||
private void scrollColumn(LinearLayout layout,int w,int x,int y,int h) {
|
||||
var scroll=new ScrollableLayout(minecraft,layout,Math.max(24,h),ScrollableLayout.ReserveStrategy.RIGHT);
|
||||
scroll.setMinWidth(w);scroll.setPosition(x,y);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
|
||||
}
|
||||
private boolean own(JsonObject entry) { return minecraft.player!=null && str(entry,"authorId").equals(minecraft.player.getStringUUID()); }
|
||||
private void articleAuthor(LinearLayout layout,int w,JsonObject entry) {
|
||||
@@ -209,6 +249,8 @@ public final class CommunityScreen extends BlocodexFrame {
|
||||
}
|
||||
private void taskDetails(LinearLayout layout,int w,JsonObject entry) {
|
||||
var task=readTask(entry);
|
||||
if(!task.equals(fr.koka.sanctuary.community.CommunityTask.EMPTY)) {
|
||||
line(layout,w,text("request_details"));
|
||||
if(task.location()!=null) {
|
||||
line(layout,w,text("location_value",task.location().dimension(),task.location().x(),task.location().y(),task.location().z()));
|
||||
var map=layout.addChild(Button.builder(text("map_show"),b->minecraft.gui.setScreen(new SanctuaryAtlasScreen(this,task.location()))).width(Math.min(w,220)).build());
|
||||
@@ -220,10 +262,13 @@ public final class CommunityScreen extends BlocodexFrame {
|
||||
if(task.reward().equals("items"))CommunityMaterialsScreen.showItems(layout,w,task.rewards());
|
||||
if(task.reward().equals("custom"))line(layout,w,Component.literal(task.customReward()));
|
||||
line(layout,w,text("exchange_help"));
|
||||
}
|
||||
int count=data.has("members")?data.getAsJsonArray("members").size():0;
|
||||
line(layout,w,text("followers",count));
|
||||
if(data.has("members"))for(var member:data.getAsJsonArray("members"))line(layout,w,Component.literal("• "+str(member.getAsJsonObject(),"playerName")));
|
||||
boolean subscribed=flag(data,"subscribed");
|
||||
if(str(entry,"status").equals("open"))line(layout,w,text(subscribed?"follow_map_active":"follow_map_help"));
|
||||
if(!subscribed && str(entry,"status").equals("open") && task.audience().equals("single") && count>0)line(layout,w,text("notice_full_help"));
|
||||
var follow=layout.addChild(Button.builder(text(subscribed?"unfollow":"follow"),b->request(this,subscribed?"unfollow":"follow",id,"","notice","info","","",0,0,result->{
|
||||
message=result.has("error")?str(result,"error"):"saved";needsLoad=true;stale=true;
|
||||
if(!result.has("error")&&!subscribed)NotificationClient.communitySubscribed(Component.literal(str(entry,"title")));
|
||||
|
||||
@@ -12,13 +12,25 @@ import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.FontDescription;
|
||||
import net.minecraft.network.chat.Style;
|
||||
import net.minecraft.util.FormattedCharSequence;
|
||||
import net.minecraft.ChatFormatting;
|
||||
|
||||
/** Configuration-stage identity and starter slots. Closing cancels connection, never the saved offer. */
|
||||
public final class HelloWorldScreen extends Screen {
|
||||
private static final FontDescription GALACTIC = new FontDescription.Resource(Identifier.withDefaultNamespace("alt"));
|
||||
private static final Style GALACTIC_STYLE = Style.EMPTY.withFont(GALACTIC);
|
||||
private ProgressionPayloads.Hello offer;private final Runnable disconnect;
|
||||
private EditBox bio,familiarName;private int color=-1;private Button submit;private boolean waiting,identityLocked;
|
||||
private String draft="",starter="",nameDraft="";private List<StarterCompanions.Choice> starterChoices=List.of();
|
||||
private String draft="",starter="",nameDraft="",codeDraft="",codeStatus="",lastChecked="";
|
||||
private java.net.URI accessSite;
|
||||
public void setAccessSite(String url) {
|
||||
try { var uri=java.net.URI.create(url); accessSite=("https".equals(uri.getScheme()) || "http".equals(uri.getScheme())) && uri.getHost()!=null && uri.getUserInfo()==null ? uri : null; }
|
||||
catch(IllegalArgumentException error) { accessSite=null; }
|
||||
}
|
||||
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;
|
||||
public HelloWorldScreen(ProgressionPayloads.Hello offer,Runnable disconnect){super(ProgressionService.label("hello_title"));this.offer=offer;this.disconnect=disconnect;}
|
||||
@@ -29,30 +41,48 @@ public final class HelloWorldScreen extends Screen {
|
||||
if(identityLocked){draft=welcome.bio();color=offer.colors().getFirst();}
|
||||
if(starterChoices.stream().noneMatch(c->c.mob().equals(starter)))starter="";
|
||||
}
|
||||
public void accept(StarterPayloads.Welcome welcome){if(bio!=null)draft=bio.getValue();load(welcome);waiting=false;if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
|
||||
public void accept(ProgressionPayloads.Hello offer){this.offer=offer;waiting=false;if(bio!=null)draft=bio.getValue();if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
|
||||
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();}
|
||||
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();compact=naming&&height<278;
|
||||
panelWidth=Math.min(520,width-20);panelX=(width-panelWidth)/2;panelY=Math.max(0,(height-(naming&&!compact?270:238))/2);colors.clear();starters.clear();familiarName=null;
|
||||
bioLabelY=panelY+(compact?37:44);colorLabelY=panelY+(compact?71:81);starterLabelY=panelY+(compact?145:177);nameLabelY=starterLabelY+36;
|
||||
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;
|
||||
bio=addRenderableWidget(new EditBox(font,right,panelY+(compact?48:57),w,18,ProgressionService.label("bio")));bio.setMaxLength(256);bio.setHint(ProgressionService.label("bio_hint"));bio.setValue(draft);bio.setResponder(value->draft=value);
|
||||
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());
|
||||
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)+(i/4)*rowHeight,cell-2,rowHeight,rgb,i+1,b->{color=rgb;updateSelection();}));
|
||||
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,nameLabelY+12,w,18,Component.translatable("sanctuary.starter.name")));
|
||||
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+(naming&&!compact?248:216);
|
||||
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->{
|
||||
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;}
|
||||
admitting=true;waiting=true;updateSelection();
|
||||
ClientConfigurationNetworking.send(new AccessPayloads.Admit(bio.getValue(),color,starterChoices.isEmpty()?"":starter,nameDraft,accessPending||codeBox==null?"":codeBox.getValue()));
|
||||
return;
|
||||
}
|
||||
if(!starterChoices.isEmpty()&&!nameDraft.isBlank()&&!ClientConfigurationNetworking.canSend(StarterPayloads.NamedCreate.TYPE)){
|
||||
offer=new ProgressionPayloads.Hello(offer.name(),offer.colors(),"starter_name_version_required");return;
|
||||
}
|
||||
@@ -62,9 +92,14 @@ public final class HelloWorldScreen extends Screen {
|
||||
else ClientConfigurationNetworking.send(new StarterPayloads.Create(bio.getValue(),color,starter));
|
||||
}).bounds(panelX+96,actionsY,panelWidth-96,20).build());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"));}
|
||||
private void updateSelection(){
|
||||
if(submit!=null)submit.active=!waiting&&color>=0&&(starterChoices.isEmpty()||(!starter.isEmpty()&&StarterCompanions.validName(nameDraft)));
|
||||
boolean codeOk=!accessRequired||accessPending||"code_recognized".equals(codeStatus)||retryCode();
|
||||
if(submit!=null)submit.setMessage(ProgressionService.label(waiting?"joining":retryCode()?"retry_code":"enter"));
|
||||
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);
|
||||
}
|
||||
@Override public void extractBackground(GuiGraphicsExtractor g,int mx,int my,float a){
|
||||
@@ -72,14 +107,21 @@ public final class HelloWorldScreen extends Screen {
|
||||
}
|
||||
@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);
|
||||
g.text(font,Component.literal(offer.name()).withStyle(s->color<0?s:s.withColor(color)),panelX+72,panelY+(compact?24:29),0xFFFFFFFF);
|
||||
g.text(font,ProgressionService.label("bio"),panelX+72,bioLabelY,0xFFBBBBBB);g.text(font,ProgressionService.label("choose_color"),panelX+72,colorLabelY,0xFFBBBBBB);
|
||||
String status=waiting?"joining":offer.error();
|
||||
if(!starterChoices.isEmpty()){
|
||||
g.text(font,status.isEmpty()?Component.translatable("sanctuary.starter.slots"):ProgressionService.label(status),panelX+72,starterLabelY,status.isEmpty()?0xFFBBBBBB:0xFFFFFFAA);
|
||||
g.text(font,Component.translatable("sanctuary.starter.name"),panelX+72,nameLabelY,0xFFBBBBBB);
|
||||
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);
|
||||
}
|
||||
else if(!status.isEmpty())g.centeredText(font,ProgressionService.label(status),width/2,panelY+204,0xFFFFFFAA);
|
||||
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());}
|
||||
|
||||
@@ -21,7 +21,20 @@ public final class HotbarClient {
|
||||
private static boolean held;
|
||||
private static boolean worldHeld;
|
||||
private HotbarClient() {}
|
||||
static void migratePlayerListKey(net.minecraft.client.Minecraft client) {
|
||||
var marker=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-player-list-key-v1.txt");
|
||||
if(java.nio.file.Files.exists(marker))return;
|
||||
try {
|
||||
if(client.options.keyPlayerList.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB))) {
|
||||
client.options.keyPlayerList.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_U));
|
||||
KeyMapping.resetMapping();client.options.save();
|
||||
}
|
||||
java.nio.file.Files.createDirectories(marker.getParent());
|
||||
java.nio.file.Files.writeString(marker,"beta.161\n",java.nio.file.StandardOpenOption.CREATE_NEW);
|
||||
} catch(java.io.IOException error) {fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Cannot record player list key migration",error);}
|
||||
}
|
||||
public static void register(KeyMapping.Category category) {
|
||||
net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents.CLIENT_STARTED.register(HotbarClient::migratePlayerListKey);
|
||||
key=KeyMappingHelper.registerKeyMapping(new KeyMapping("screen.sanctuary.inventory.rotate_key",InputConstants.Type.KEYBOARD,InputConstants.KEY_TAB,category));
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(HotbarPayloads.Capability.TYPE,(p,c) -> {});
|
||||
ClientPlayNetworking.registerGlobalReceiver(HotbarPayloads.State.TYPE,(p,c) -> c.client().execute(() -> {
|
||||
|
||||
@@ -17,7 +17,7 @@ final class PauseTextButton extends AbstractButton {
|
||||
var font=Minecraft.getInstance().font;
|
||||
if(height>24) {
|
||||
var lines=font.split(getMessage(),width-10);int count=Math.min(2,lines.size());
|
||||
for(int i=0;i<count;i++)g.text(font,lines.get(i),getX()+(width-font.width(lines.get(i)))/2,getY()+(height-count*10)/2+i*10,active?0xFFF0EEE6:0xFF969696);
|
||||
for(int i=0;i<count;i++)g.text(font,lines.get(i),getX()+5,getY()+(height-count*10)/2+i*10,active?0xFFF0EEE6:0xFF969696);
|
||||
return;
|
||||
}
|
||||
g.text(font,font.plainSubstrByWidth(getMessage().getString(),width-10),getX()+5,getY()+(height-9)/2,active?0xFFF0EEE6:0xFF969696);
|
||||
|
||||
@@ -11,6 +11,8 @@ import net.minecraft.client.gui.screens.Screen;
|
||||
|
||||
public final class ProgressionClient {
|
||||
public static ProgressRecord progress;
|
||||
private static boolean accessGate,accessPending;
|
||||
private static String accessSite="";
|
||||
public static boolean operator;
|
||||
public static InhabitantStore.Identity identity;
|
||||
public static ProgressionConfig config=ProgressionConfig.DEFAULT;
|
||||
@@ -38,14 +40,25 @@ public final class ProgressionClient {
|
||||
CycleClient.register();
|
||||
EntityRendererRegistry.register(SanctuarySitting.SEAT,NoopRenderer::new);
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(ProgressionPayloads.InventoryLayout.TYPE,(payload,context) -> {});
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Site.TYPE,(payload,context)->context.client().execute(()->{
|
||||
accessSite=payload.url();
|
||||
if(context.client().gui.screen() instanceof HelloWorldScreen screen)screen.setAccessSite(accessSite);
|
||||
}));
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Gate.TYPE,(payload,context)->context.client().execute(()->{
|
||||
accessGate=payload.required();accessPending=payload.pending();
|
||||
if(payload.required()&&context.client().gui.screen() instanceof HelloWorldScreen screen)screen.requireAccess(payload.pending());
|
||||
}));
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Status.TYPE,(payload,context)->context.client().execute(()->{
|
||||
if(context.client().gui.screen() instanceof HelloWorldScreen screen)screen.acceptCode(payload.code(),payload.reason());
|
||||
}));
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(StarterPayloads.Welcome.TYPE,(payload,context)->context.client().execute(()->{
|
||||
if(context.client().gui.screen() instanceof HelloWorldScreen screen)screen.accept(payload);
|
||||
else context.client().gui.setScreen(new HelloWorldScreen(payload,()->context.responseSender().disconnect(ProgressionService.label("cancelled"))));
|
||||
else showHello(context.client(),new HelloWorldScreen(payload,()->context.responseSender().disconnect(ProgressionService.label("cancelled"))));
|
||||
}));
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(ProgressionPayloads.Hello.TYPE,(payload,context) ->
|
||||
context.client().execute(() -> {
|
||||
if (context.client().gui.screen() instanceof HelloWorldScreen screen) screen.accept(payload);
|
||||
else context.client().gui.setScreen(new HelloWorldScreen(payload,
|
||||
else showHello(context.client(),new HelloWorldScreen(payload,
|
||||
() -> context.responseSender().disconnect(ProgressionService.label("cancelled"))));
|
||||
}));
|
||||
ClientPlayNetworking.registerGlobalReceiver(ProgressionPayloads.Snapshot.TYPE,(payload,context) ->
|
||||
@@ -91,9 +104,11 @@ public final class ProgressionClient {
|
||||
if (active() && ClientPlayNetworking.canSend(ProgressionPayloads.Action.TYPE))
|
||||
ClientPlayNetworking.send(new ProgressionPayloads.Action(target,progress.revision()));
|
||||
}
|
||||
private static void showHello(Minecraft client,HelloWorldScreen screen){screen.setAccessSite(accessSite);if(accessGate)screen.prepareAccess(accessPending);client.gui.setScreen(screen);}
|
||||
private static void reset(Minecraft client) {
|
||||
if (client.player != null) ProgressEffects.clear(client.player);
|
||||
identity=null;progress=null;operator=false;config=ProgressionConfig.DEFAULT;names=MobNamesConfig.DEFAULT;
|
||||
accessGate=false;accessPending=false;accessSite="";
|
||||
wasSneaking=false;pendingSit=false;lastSneak=-100;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package fr.koka.sanctuary.community;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.DriverManager;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.*;
|
||||
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.stats.Stats;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
|
||||
/** Server-thread observations, bounded asynchronous persistence; no web requests touch the game thread. */
|
||||
public final class ServerMetrics {
|
||||
private static final Map<MinecraftServer,Session> SESSIONS=new HashMap<>();
|
||||
private ServerMetrics() { }
|
||||
public static void register() {
|
||||
ServerLifecycleEvents.SERVER_STARTED.register(server -> {if(!server.isSingleplayer()) {var s=new Session(server);SESSIONS.put(server,s);s.capture(true);}});
|
||||
ServerTickEvents.END_SERVER_TICK.register(server -> {var s=SESSIONS.get(server);if(s!=null && System.nanoTime()-s.lastCapture>=30_000_000_000L)s.capture(true);});
|
||||
ServerPlayConnectionEvents.DISCONNECT.register((handler,server)->{var s=SESSIONS.get(server);if(s!=null)s.observe(handler.player);});
|
||||
ServerLifecycleEvents.SERVER_STOPPING.register(server -> {var s=SESSIONS.remove(server);if(s!=null)s.stop();});
|
||||
}
|
||||
private static boolean actor(ServerPlayer player) {
|
||||
String name=player.getGameProfile().name();
|
||||
return name.startsWith("Lab_") && player.getUUID().equals(UUID.nameUUIDFromBytes(("SanctuaryDuo:"+name).getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
private static final class Session {
|
||||
final MinecraftServer server;
|
||||
final ThreadPoolExecutor worker=new ThreadPoolExecutor(1,1,0L,TimeUnit.SECONDS,new ArrayBlockingQueue<>(2),r->{var t=new Thread(r,"sanctuary-metrics");t.setDaemon(true);return t;});
|
||||
final Map<String,Long> latest=new HashMap<>();
|
||||
final Set<String> actors=new HashSet<>();
|
||||
final long started=System.nanoTime()/1_000_000;
|
||||
ServerMetricsLedger ledger;
|
||||
CommunityConfig config;
|
||||
boolean broken;
|
||||
long lastCapture,lastSqlWarning;
|
||||
Session(MinecraftServer server){this.server=server;}
|
||||
void observe(ServerPlayer player) {
|
||||
if(actor(player))actors.add(player.getStringUUID());
|
||||
else latest.put(player.getStringUUID(),(long)player.getStats().getValue(Stats.CUSTOM.get(Stats.DEATHS)));
|
||||
}
|
||||
void capture(boolean running) {
|
||||
lastCapture=System.nanoTime();
|
||||
int online=0;
|
||||
for(var player:server.getPlayerList().getPlayers()) {observe(player);if(!actor(player))online++;}
|
||||
var values=Map.copyOf(latest);var excluded=Set.copyOf(actors);
|
||||
long monotonic=lastCapture/1_000_000,wall=System.currentTimeMillis();int count=running?online:0;
|
||||
try {worker.execute(()->persist(monotonic,wall,values,excluded,count,running));}
|
||||
catch(RejectedExecutionException error){SanctuaryMod.LOGGER.warn("Server metrics worker busy; next sample will include elapsed time");}
|
||||
}
|
||||
void persist(long monotonic,long wall,Map<String,Long> values,Set<String> excluded,int online,boolean running) {
|
||||
if(broken)return;
|
||||
ServerMetricsLedger.Snapshot snapshot;
|
||||
try {
|
||||
if(ledger==null)ledger=new ServerMetricsLedger(server.getWorldPath(LevelResource.ROOT),started);
|
||||
snapshot=ledger.sample(monotonic,wall,values,excluded,online,running);
|
||||
} catch(Exception error) {broken=true;SanctuaryMod.LOGGER.error("Server metrics stopped; existing local data preserved ({})",error.getClass().getSimpleName());return;}
|
||||
try {
|
||||
if(config==null)config=CommunityConfig.load(FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
|
||||
if(!config.mode().equals("database"))return;
|
||||
String password=System.getenv(config.passwordEnv());if(password==null)throw new IllegalStateException("Missing metrics database credential");
|
||||
var props=new Properties();props.setProperty("user",config.user());props.setProperty("password",password);props.setProperty("connectTimeout","2000");props.setProperty("socketTimeout","3000");
|
||||
try(var c=DriverManager.getConnection(config.jdbcUrl(),props);var stmt=c.prepareStatement("INSERT INTO sanctuary_server_metrics (server_id,uptime_millis,deaths,online_players,updated_at,running) VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE uptime_millis=VALUES(uptime_millis),deaths=VALUES(deaths),online_players=VALUES(online_players),updated_at=VALUES(updated_at),running=VALUES(running)")) {
|
||||
stmt.setQueryTimeout(3);stmt.setString(1,config.serverId());stmt.setLong(2,snapshot.uptimeMillis());stmt.setLong(3,snapshot.deaths());stmt.setInt(4,snapshot.onlinePlayers());stmt.setLong(5,snapshot.updatedAt());stmt.setBoolean(6,snapshot.running());stmt.executeUpdate();
|
||||
}
|
||||
} catch(Exception error) {
|
||||
if(lastSqlWarning==0 || wall-lastSqlWarning>=300_000){lastSqlWarning=wall;SanctuaryMod.LOGGER.warn("Server metrics publication unavailable; local measurements retained ({})",error.getClass().getSimpleName());}
|
||||
}
|
||||
}
|
||||
void stop() {
|
||||
capture(false);worker.shutdown();
|
||||
try {if(!worker.awaitTermination(8,TimeUnit.SECONDS))worker.shutdownNow();}
|
||||
catch(InterruptedException error){worker.shutdownNow();Thread.currentThread().interrupt();}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package fr.koka.sanctuary.community;
|
||||
|
||||
import com.google.gson.*;
|
||||
import fr.koka.sanctuary.progression.AtomicJson;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
|
||||
/** Additive ledger: elapsed process time, vanilla deaths, and explicitly excluded lab actors. */
|
||||
public final class ServerMetricsLedger {
|
||||
private static final Gson JSON = new Gson();
|
||||
private final Path path;
|
||||
private byte[] previous;
|
||||
private long uptimeMillis;
|
||||
private final Map<String,Long> deaths = new HashMap<>();
|
||||
private final Set<String> excluded = new HashSet<>();
|
||||
private long sampledAt;
|
||||
public record Snapshot(long uptimeMillis, long deaths, int onlinePlayers, long updatedAt, boolean running) { }
|
||||
public ServerMetricsLedger(Path root, long monotonicMillis) throws Exception {
|
||||
path=root.resolve("data/sanctuary-server-metrics.json");
|
||||
sampledAt=monotonicMillis;
|
||||
previous=AtomicJson.read(path,4*1024*1024);
|
||||
if(previous!=null) {
|
||||
var data=AtomicJson.parse(new String(previous,StandardCharsets.UTF_8));
|
||||
AtomicJson.fields(data,"version","uptimeMillis","deaths","excluded");
|
||||
var o=data.getAsJsonObject();
|
||||
if(o.get("version").getAsInt()!=1 || (uptimeMillis=o.get("uptimeMillis").getAsLong())<0)throw new IllegalArgumentException("Metrics version or uptime");
|
||||
for(var e:o.getAsJsonObject("deaths").entrySet()) {Community.uuid(e.getKey());long value=e.getValue().getAsLong();if(value<0)throw new IllegalArgumentException("Deaths");deaths.put(e.getKey(),value);}
|
||||
for(var e:o.getAsJsonArray("excluded"))excluded.add(Community.uuid(e.getAsString()));
|
||||
}
|
||||
var stats=root.resolve("stats");
|
||||
if(Files.isDirectory(stats))try(var files=Files.list(stats)) {
|
||||
for(var file:files.filter(f->f.getFileName().toString().matches("[0-9a-f-]{36}\\.json")).toList()) {
|
||||
String id=Community.uuid(file.getFileName().toString().substring(0,36));
|
||||
byte[] bytes=AtomicJson.read(file,8*1024*1024);
|
||||
var o=AtomicJson.parse(new String(bytes,StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
var all=o.getAsJsonObject("stats");var custom=all==null?null:all.getAsJsonObject("minecraft:custom");
|
||||
if(custom!=null && custom.has("minecraft:deaths")) {
|
||||
long value=custom.get("minecraft:deaths").getAsLong();if(value<0)throw new IllegalArgumentException("Deaths");
|
||||
deaths.merge(id,value,Math::max);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public Snapshot sample(long monotonicMillis,long wallMillis,Map<String,Long> currentDeaths,Set<String> actors,int online,boolean running) throws Exception {
|
||||
if(monotonicMillis<sampledAt || wallMillis<0 || online<0)throw new IllegalArgumentException("Metrics clock");
|
||||
uptimeMillis=Math.addExact(uptimeMillis,monotonicMillis-sampledAt);sampledAt=monotonicMillis;
|
||||
excluded.addAll(actors);
|
||||
for(var e:currentDeaths.entrySet()) {Community.uuid(e.getKey());if(e.getValue()<0)throw new IllegalArgumentException("Deaths");deaths.merge(e.getKey(),e.getValue(),Math::max);}
|
||||
excluded.forEach(deaths::remove);
|
||||
var o=new JsonObject();o.addProperty("version",1);o.addProperty("uptimeMillis",uptimeMillis);o.add("deaths",JSON.toJsonTree(deaths));o.add("excluded",JSON.toJsonTree(excluded));
|
||||
previous=AtomicJson.write(path,previous,JSON.toJson(o),4*1024*1024);
|
||||
long total=0;for(long value:deaths.values())total=Math.addExact(total,value);
|
||||
return new Snapshot(uptimeMillis,total,online,wallMillis,running);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import net.minecraft.client.Options;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.ModifyArgs;
|
||||
import org.spongepowered.asm.mixin.injection.invoke.arg.Args;
|
||||
|
||||
@Mixin(Options.class)
|
||||
public abstract class PlayerListKeyMixin {
|
||||
@ModifyArgs(method = "<init>", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/KeyMapping;<init>(Ljava/lang/String;ILnet/minecraft/client/KeyMapping$Category;)V"))
|
||||
private void sanctuary$playerListDefault(Args args) {
|
||||
if ("key.playerlist".equals(args.get(0))) args.set(1, InputConstants.KEY_U);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package fr.koka.sanctuary.progression;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Site access-code contract. The client never calls the site; only these decisions run on the server. */
|
||||
public final class AccessCodes {
|
||||
private static final Pattern CODE = Pattern.compile("SANC-[A-Z0-9]{4}-[A-Z0-9]{4}");
|
||||
private static final Pattern USER = Pattern.compile("[A-Za-z0-9_]{3,16}");
|
||||
private static final Pattern DISCORD = Pattern.compile("[0-9]{1,20}");
|
||||
private AccessCodes() { }
|
||||
public record Result(boolean valid, String reason, String minecraftUsername, String discordId) {
|
||||
public static Result of(boolean valid, String reason, String username, String discordId) {
|
||||
return new Result(valid, reason == null ? "" : reason, username == null ? "" : username, discordId == null ? "" : discordId);
|
||||
}
|
||||
public static Result unreachable() { return of(false, "unreachable", "", ""); }
|
||||
public static Result unauthorized() { return of(false, "unauthorized", "", ""); }
|
||||
public static Result unconfigured() { return of(false, "unconfigured", "", ""); }
|
||||
}
|
||||
public record Redeem(boolean accepted, String reason, String minecraftUsername, String discordId) {
|
||||
public static Redeem accept(String username, String discordId) { return new Redeem(true, "code_recognized", username, discordId); }
|
||||
public static Redeem reject(String reason) { return new Redeem(false, reason, "", ""); }
|
||||
}
|
||||
public static String normalize(String code) { return code == null ? "" : code.trim().toUpperCase(Locale.ROOT); }
|
||||
public static boolean validFormat(String normalized) { return normalized != null && CODE.matcher(normalized).matches(); }
|
||||
public static boolean validUsername(String name) { return name != null && USER.matcher(name).matches(); }
|
||||
public static boolean validDiscord(String id) { return id != null && DISCORD.matcher(id).matches(); }
|
||||
public static boolean sameUser(String returned, String session) {
|
||||
return validUsername(session) && returned != null && returned.equalsIgnoreCase(session) && validUsername(returned);
|
||||
}
|
||||
public static Result parse(int status, String body) {
|
||||
if (status == 401) return Result.unauthorized();
|
||||
if (status == 429 || status < 200 || status >= 500) return Result.unreachable();
|
||||
try {
|
||||
var object = JsonParser.parseString(body).getAsJsonObject();
|
||||
if (!object.has("valid") || !object.has("reason")) return Result.unreachable();
|
||||
return Result.of(object.get("valid").getAsBoolean(), text(object, "reason"), text(object, "minecraft_username"), text(object, "discord_id"));
|
||||
} catch (RuntimeException error) { return Result.unreachable(); }
|
||||
}
|
||||
/** Positive responses must echo the server-generated admission, including after a lost response. */
|
||||
public static Result parseScoped(int status, String body, String admissionId) {
|
||||
Result result = parse(status, body);
|
||||
if (!result.valid()) return result;
|
||||
try {
|
||||
var object = JsonParser.parseString(body).getAsJsonObject();
|
||||
if (!admissionId.equals(text(object, "admission_id"))) return Result.of(false, "invalid", "", "");
|
||||
return result;
|
||||
} catch (RuntimeException error) { return Result.unreachable(); }
|
||||
}
|
||||
public static String verifyKey(Result result, String username) {
|
||||
if (result == null) return "code_unreachable";
|
||||
return switch (result.reason()) {
|
||||
case "unconfigured" -> "code_unconfigured";
|
||||
case "unauthorized" -> "code_unauthorized";
|
||||
case "unreachable" -> "code_unreachable";
|
||||
case "unused", "resumed" -> result.valid() && sameUser(result.minecraftUsername(), username) && validDiscord(result.discordId()) ? "code_recognized" : "code_invalid";
|
||||
case "invalid" -> "code_invalid";
|
||||
case "already_used" -> "code_used";
|
||||
default -> "code_invalid";
|
||||
};
|
||||
}
|
||||
/** Redeem commits the code; a scoped positive resumed receipt can finish the same admission. */
|
||||
public static Redeem redeem(Result result, String username) {
|
||||
if (result == null) return Redeem.reject("code_unreachable");
|
||||
return switch (result.reason()) {
|
||||
case "unconfigured" -> Redeem.reject("code_unconfigured");
|
||||
case "unauthorized" -> Redeem.reject("code_unauthorized");
|
||||
case "unreachable" -> Redeem.reject("code_unreachable");
|
||||
case "redeemed", "resumed" -> result.valid() ? account(result, username) : Redeem.reject("code_invalid");
|
||||
case "already_used" -> Redeem.reject("code_used");
|
||||
case "invalid" -> Redeem.reject("code_invalid");
|
||||
default -> Redeem.reject("code_invalid");
|
||||
};
|
||||
}
|
||||
private static Redeem account(Result result, String username) {
|
||||
if (!result.minecraftUsername().isEmpty() && !result.minecraftUsername().equalsIgnoreCase(username))
|
||||
return Redeem.reject("code_mismatch");
|
||||
if (!sameUser(result.minecraftUsername(), username) || !validDiscord(result.discordId()))
|
||||
return Redeem.reject("code_replay");
|
||||
return Redeem.accept(result.minecraftUsername(), result.discordId());
|
||||
}
|
||||
private static String text(JsonObject object, String key) {
|
||||
if (!object.has(key) || object.get(key).isJsonNull() || !object.get(key).isJsonPrimitive()) return "";
|
||||
return object.get(key).getAsString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package fr.koka.sanctuary.progression;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/** Server-only site access. The token stays in the environment or the server config, never in the client. */
|
||||
public final class AccessConfig {
|
||||
private static final Logger LOG = LoggerFactory.getLogger("Sanctuary/Access");
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).followRedirects(HttpClient.Redirect.NEVER).build();
|
||||
public interface Directory {
|
||||
AccessCodes.Result verify(String username, String code, String admissionId);
|
||||
AccessCodes.Result redeem(String username, String code, String admissionId);
|
||||
}
|
||||
private static volatile boolean enabled;
|
||||
private static volatile String registrationUrl="";
|
||||
private static volatile Directory directory = new Disabled();
|
||||
private AccessConfig() { }
|
||||
public static String registrationUrl() { return registrationUrl; }
|
||||
public static boolean enabled() { return enabled; }
|
||||
public static Directory directory() { return directory; }
|
||||
public static void load(Path configDir) {
|
||||
registrationUrl="";
|
||||
String url = env("SANCTUARY_APP_URL");
|
||||
String token = env("SANCTUARY_API_TOKEN");
|
||||
if (url.isEmpty() || token.isEmpty()) {
|
||||
var file = readFile(configDir.resolve("access.json"));
|
||||
if (url.isEmpty()) url = file.url;
|
||||
if (token.isEmpty()) token = file.token;
|
||||
}
|
||||
if (url.isEmpty() && token.isEmpty()) {
|
||||
enabled = false; directory = new Disabled();
|
||||
LOG.info("Sanctuary access codes are off. Set SANCTUARY_APP_URL and SANCTUARY_API_TOKEN to require a code.");
|
||||
return;
|
||||
}
|
||||
URI uri;
|
||||
try { uri = URI.create(url.endsWith("/") ? url.substring(0, url.length() - 1) : url); }
|
||||
catch (IllegalArgumentException error) { failClosed("Sanctuary access URL is invalid. Character creation stays closed."); return; }
|
||||
if (uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getHost() == null || uri.getFragment() != null || !(uri.getPath().isEmpty() || uri.getPath().equals("/"))) {
|
||||
failClosed("Sanctuary access URL must be a bare https origin. Character creation stays closed."); return;
|
||||
}
|
||||
String scheme = uri.getScheme();
|
||||
if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) {
|
||||
failClosed("Sanctuary access URL must use https. Character creation stays closed."); return;
|
||||
}
|
||||
if (token.isEmpty()) { failClosed("Sanctuary access token is missing. Character creation stays closed."); return; }
|
||||
if ("http".equalsIgnoreCase(scheme)) LOG.warn("Sanctuary access URL is not HTTPS. Use HTTPS outside a local site.");
|
||||
enabled = true;
|
||||
registrationUrl=uri.toString()+"/candidature";
|
||||
directory = new HttpDirectory(uri, token);
|
||||
LOG.info("Sanctuary access codes are required before character creation.");
|
||||
}
|
||||
private static void failClosed(String message) {
|
||||
enabled = true; directory = new Disabled(); LOG.error(message);
|
||||
}
|
||||
private static String env(String key) {
|
||||
String value = System.getenv(key);
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
private record FileConfig(String url, String token) { }
|
||||
private static FileConfig readFile(Path file) {
|
||||
if (!Files.isRegularFile(file)) return new FileConfig("", "");
|
||||
try {
|
||||
var object = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
return new FileConfig(text(object, "appUrl"), text(object, "token"));
|
||||
} catch (Exception error) {
|
||||
LOG.error("Sanctuary access config cannot be read. Character creation stays closed.");
|
||||
return new FileConfig("http://invalid", "");
|
||||
}
|
||||
}
|
||||
private static String text(JsonObject object, String key) {
|
||||
if (!object.has(key) || object.get(key).isJsonNull()) return "";
|
||||
return object.get(key).getAsString().trim();
|
||||
}
|
||||
private static final class Disabled implements Directory {
|
||||
public AccessCodes.Result verify(String username, String code, String admissionId) { return AccessCodes.Result.unconfigured(); }
|
||||
public AccessCodes.Result redeem(String username, String code, String admissionId) { return AccessCodes.Result.unconfigured(); }
|
||||
}
|
||||
private static final class HttpDirectory implements Directory {
|
||||
private final URI origin;
|
||||
private final String token;
|
||||
HttpDirectory(URI origin, String token) { this.origin = origin; this.token = token; }
|
||||
public AccessCodes.Result verify(String username, String code, String admissionId) { return post("/api/v1/access-codes/verify", username, code, admissionId); }
|
||||
public AccessCodes.Result redeem(String username, String code, String admissionId) { return post("/api/v1/access-codes/redeem", username, code, admissionId); }
|
||||
private AccessCodes.Result post(String path, String username, String code, String admissionId) {
|
||||
var body = new JsonObject();
|
||||
body.addProperty("code", code);
|
||||
body.addProperty("admission_id", admissionId);
|
||||
body.addProperty("minecraft_username", username);
|
||||
var request = HttpRequest.newBuilder(URI.create(origin.toString() + path)).timeout(Duration.ofSeconds(10))
|
||||
.header("Authorization", "Bearer " + token).header("Content-Type", "application/json").header("Accept", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build();
|
||||
try {
|
||||
var response = HTTP.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() == 401) LOG.warn("Sanctuary access token was rejected");
|
||||
return AccessCodes.parseScoped(response.statusCode(), response.body(), admissionId);
|
||||
} catch (Exception error) {
|
||||
LOG.warn("Sanctuary access endpoint is unreachable ({})", error.getClass().getSimpleName());
|
||||
return AccessCodes.Result.unreachable();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package fr.koka.sanctuary.progression;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Discord binding for an inhabitant. Pending entries finish a creation interrupted after the site consumed the code. */
|
||||
public final class AccessLedger {
|
||||
private static final int LIMIT = 1024 * 1024;
|
||||
private final Path file;
|
||||
private final long seed;
|
||||
private final UUID scope;
|
||||
private byte[] previous;
|
||||
private final TreeMap<UUID, Binding> pending = new TreeMap<>();
|
||||
private final TreeMap<UUID, Binding> bound = new TreeMap<>();
|
||||
private boolean failed;
|
||||
public record Binding(String name, String discordId) { }
|
||||
public AccessLedger(Path file, long seed) throws IOException {
|
||||
this.file = file; this.seed = seed;
|
||||
var scopeFile = file.resolveSibling("sanctuary-admission-id");
|
||||
java.nio.file.Files.createDirectories(file.getParent());
|
||||
if (java.nio.file.Files.notExists(scopeFile))
|
||||
AtomicJson.write(scopeFile, null, UUID.randomUUID().toString(), 36);
|
||||
scope = UUID.fromString(new String(AtomicJson.read(scopeFile,36),StandardCharsets.UTF_8).trim());
|
||||
previous = AtomicJson.read(file, LIMIT);
|
||||
if (previous == null) return;
|
||||
var root = AtomicJson.parse(new String(previous, StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
AtomicJson.fields(root, "schema", "seed", "pending", "bound");
|
||||
if (root.get("schema").getAsInt() != 1 || root.get("seed").getAsLong() != seed)
|
||||
throw new IllegalArgumentException("Invalid access ledger");
|
||||
read(root.getAsJsonObject("pending"), pending);
|
||||
read(root.getAsJsonObject("bound"), bound);
|
||||
}
|
||||
/** Stable across reconnects and restarts, distinct for each world and authenticated player. */
|
||||
public String admissionId(UUID id) {
|
||||
return UUID.nameUUIDFromBytes((scope + ":" + id).getBytes(StandardCharsets.UTF_8)).toString();
|
||||
}
|
||||
public Binding known(UUID id) {
|
||||
var open = pending.get(id);
|
||||
return open != null ? open : bound.get(id);
|
||||
}
|
||||
public void remember(UUID id, String name, String discordId) throws IOException {
|
||||
if (failed) throw new IOException("Access ledger failed earlier");
|
||||
if (!AccessCodes.validUsername(name) || !AccessCodes.validDiscord(discordId))
|
||||
throw new IllegalArgumentException("Invalid access binding");
|
||||
if (bound.containsKey(id)) return;
|
||||
pending.put(id, new Binding(name, discordId));
|
||||
save();
|
||||
}
|
||||
public void commit(UUID id) throws IOException {
|
||||
if (failed) throw new IOException("Access ledger failed earlier");
|
||||
var binding = pending.remove(id);
|
||||
if (binding == null) return;
|
||||
bound.put(id, binding);
|
||||
save();
|
||||
}
|
||||
private static void read(JsonObject object, TreeMap<UUID, Binding> into) {
|
||||
for (var entry : object.entrySet()) {
|
||||
AtomicJson.fields(entry.getValue(), "name", "discordId");
|
||||
UUID id = UUID.fromString(entry.getKey());
|
||||
var value = entry.getValue().getAsJsonObject();
|
||||
String name = value.get("name").getAsString();
|
||||
String discordId = value.get("discordId").getAsString();
|
||||
if (!AccessCodes.validUsername(name) || !AccessCodes.validDiscord(discordId))
|
||||
throw new IllegalArgumentException("Invalid access binding");
|
||||
if (into.put(id, new Binding(name, discordId)) != null) throw new IllegalArgumentException("Duplicate access binding");
|
||||
}
|
||||
}
|
||||
private void save() throws IOException {
|
||||
var root = new JsonObject();
|
||||
root.addProperty("schema", 1);
|
||||
root.addProperty("seed", seed);
|
||||
root.add("pending", write(pending));
|
||||
root.add("bound", write(bound));
|
||||
try {
|
||||
previous = AtomicJson.write(file, previous, new GsonBuilder().setPrettyPrinting().create().toJson(root), LIMIT);
|
||||
} catch (IOException | RuntimeException error) { failed = true; throw error; }
|
||||
}
|
||||
private static JsonObject write(TreeMap<UUID, Binding> map) {
|
||||
var object = new JsonObject();
|
||||
map.forEach((id, binding) -> {
|
||||
var value = new JsonObject();
|
||||
value.addProperty("name", binding.name);
|
||||
value.addProperty("discordId", binding.discordId);
|
||||
object.add(id.toString(), value);
|
||||
});
|
||||
return object;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package fr.koka.sanctuary.progression;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
|
||||
/** Additive Hello World handshake. Existing create packets stay unchanged when access codes are off. */
|
||||
public final class AccessPayloads {
|
||||
private AccessPayloads() { }
|
||||
public record Gate(boolean required, boolean pending) implements CustomPacketPayload {
|
||||
public static final Type<Gate> TYPE = new Type<>(SanctuaryMod.id("access_gate_v1"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Gate> CODEC = CustomPacketPayload.codec(
|
||||
(p, b) -> { b.writeBoolean(p.required); b.writeBoolean(p.pending); },
|
||||
b -> new Gate(b.readBoolean(), b.readBoolean()));
|
||||
public Type<Gate> type() { return TYPE; }
|
||||
}
|
||||
public record Site(String url) implements CustomPacketPayload {
|
||||
public static final Type<Site> TYPE = new Type<>(SanctuaryMod.id("access_site_v1"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Site> CODEC = CustomPacketPayload.codec(
|
||||
(p,b) -> b.writeUtf(p.url,2048), b -> new Site(b.readUtf(2048)));
|
||||
public Type<Site> type() { return TYPE; }
|
||||
}
|
||||
public record Check(String code) implements CustomPacketPayload {
|
||||
public static final Type<Check> TYPE = new Type<>(SanctuaryMod.id("access_check_v1"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Check> CODEC = CustomPacketPayload.codec(
|
||||
(p, b) -> b.writeUtf(p.code, 32), b -> new Check(b.readUtf(32)));
|
||||
public Type<Check> type() { return TYPE; }
|
||||
}
|
||||
public record Status(String code, String reason) implements CustomPacketPayload {
|
||||
public static final Type<Status> TYPE = new Type<>(SanctuaryMod.id("access_status_v1"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Status> CODEC = CustomPacketPayload.codec(
|
||||
(p, b) -> { b.writeUtf(p.code, 32); b.writeUtf(p.reason, 64); },
|
||||
b -> new Status(b.readUtf(32), b.readUtf(64)));
|
||||
public Type<Status> type() { return TYPE; }
|
||||
}
|
||||
public record Admit(String bio, int color, String mob, String name, String code) implements CustomPacketPayload {
|
||||
public static final Type<Admit> TYPE = new Type<>(SanctuaryMod.id("access_admit_v1"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Admit> CODEC = CustomPacketPayload.codec(
|
||||
(p, b) -> { b.writeUtf(p.bio, 256); b.writeInt(p.color); b.writeUtf(p.mob, 64); b.writeUtf(p.name, StarterCompanions.NAME_LIMIT); b.writeUtf(p.code, 32); },
|
||||
b -> new Admit(b.readUtf(256), b.readInt(), b.readUtf(64), b.readUtf(StarterCompanions.NAME_LIMIT), b.readUtf(32)));
|
||||
public Type<Admit> type() { return TYPE; }
|
||||
}
|
||||
}
|
||||
+122
-1
@@ -50,6 +50,7 @@ public final class ProgressionService {
|
||||
public StarterCompanions.Store starters;
|
||||
public CycleConfig cycleConfig=CycleConfig.DEFAULT;
|
||||
public TutorialConfig tutorialConfig=TutorialConfig.DEFAULT;
|
||||
public AccessLedger access;
|
||||
Session(InhabitantStore inhabitants, ProgressionConfig config, MobNamesConfig names) {
|
||||
this.inhabitants=inhabitants; this.config=config; this.names=names;
|
||||
}
|
||||
@@ -61,6 +62,11 @@ public final class ProgressionService {
|
||||
PayloadTypeRegistry.clientboundConfiguration().register(StarterPayloads.Welcome.TYPE,StarterPayloads.Welcome.CODEC);
|
||||
PayloadTypeRegistry.serverboundConfiguration().register(StarterPayloads.Create.TYPE,StarterPayloads.Create.CODEC);
|
||||
PayloadTypeRegistry.serverboundConfiguration().register(StarterPayloads.NamedCreate.TYPE,StarterPayloads.NamedCreate.CODEC);
|
||||
PayloadTypeRegistry.clientboundConfiguration().register(AccessPayloads.Site.TYPE,AccessPayloads.Site.CODEC);
|
||||
PayloadTypeRegistry.clientboundConfiguration().register(AccessPayloads.Gate.TYPE,AccessPayloads.Gate.CODEC);
|
||||
PayloadTypeRegistry.clientboundConfiguration().register(AccessPayloads.Status.TYPE,AccessPayloads.Status.CODEC);
|
||||
PayloadTypeRegistry.serverboundConfiguration().register(AccessPayloads.Check.TYPE,AccessPayloads.Check.CODEC);
|
||||
PayloadTypeRegistry.serverboundConfiguration().register(AccessPayloads.Admit.TYPE,AccessPayloads.Admit.CODEC);
|
||||
SanctuarySitting.register();
|
||||
fr.koka.sanctuary.inventory.HotbarService.register();
|
||||
fr.koka.sanctuary.inventory.InventorySortService.register();
|
||||
@@ -87,6 +93,10 @@ public final class ProgressionService {
|
||||
});
|
||||
ServerConfigurationConnectionEvents.CONFIGURE.register((handler,server) -> {
|
||||
if (session(server) == null) return;
|
||||
var identity = session(server).healthy ? session(server).inhabitants.get(((ProgressionConfigurationAccess)handler).sanctuary$profile().id()) : null;
|
||||
if (session(server).access != null && (identity == null || !identity.arrived()) && !ServerConfigurationNetworking.canSend(handler,AccessPayloads.Gate.TYPE)) {
|
||||
handler.disconnect(label("access_version_required")); return;
|
||||
}
|
||||
if (!ServerConfigurationNetworking.canSend(handler,ProgressionPayloads.Hello.TYPE)) {
|
||||
handler.disconnect(label("client_required")); return;
|
||||
}
|
||||
@@ -147,6 +157,12 @@ public final class ProgressionService {
|
||||
ServerConfigurationNetworking.registerGlobalReceiver(StarterPayloads.NamedCreate.TYPE,(payload,context)->context.server().execute(()->{
|
||||
HelloTask task=PENDING.get(context.packetListener());if(task!=null)task.createWithStarter(new StarterPayloads.Create(payload.bio(),payload.color(),payload.mob()),payload.name());
|
||||
}));
|
||||
ServerConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Check.TYPE,(payload,context)->context.server().execute(()->{
|
||||
HelloTask task=PENDING.get(context.packetListener());if(task!=null)task.check(payload.code());
|
||||
}));
|
||||
ServerConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Admit.TYPE,(payload,context)->context.server().execute(()->{
|
||||
HelloTask task=PENDING.get(context.packetListener());if(task!=null)task.admit(payload);
|
||||
}));
|
||||
ServerConfigurationNetworking.registerGlobalReceiver(ProgressionPayloads.IntroFinished.TYPE, (payload,context) ->
|
||||
context.server().execute(() -> {
|
||||
HelloTask task = PENDING.get(context.packetListener());
|
||||
@@ -194,6 +210,7 @@ public final class ProgressionService {
|
||||
private static void start(MinecraftServer server) {
|
||||
if (!SanctuaryGameplay.supported(server)) return;
|
||||
try {
|
||||
AccessConfig.load(CONFIG);
|
||||
var config=ProgressionConfig.load(CONFIG.resolve("progression.json"));
|
||||
var names=MobNamesConfig.load(CONFIG.resolve("mob-names.json"));
|
||||
Path world=server.getWorldPath(LevelResource.ROOT);
|
||||
@@ -210,6 +227,7 @@ public final class ProgressionService {
|
||||
session.cycleConfig=CycleConfig.load(CONFIG.resolve("cycle-factions.json"));
|
||||
session.tutorialConfig=TutorialConfig.load(CONFIG.resolve("tutorial.json"));
|
||||
session.cycles=new CycleStore(world.resolve("data/sanctuary-cycle-factions.json"),server.overworld().getSeed());
|
||||
if (AccessConfig.enabled()) session.access=new AccessLedger(world.resolve("data/sanctuary-access.json"),server.overworld().getSeed());
|
||||
for (var event:session.cycles.events()) {
|
||||
if (inhabitants.get(event.actor())==null || (!event.target().isEmpty() && inhabitants.get(UUID.fromString(event.target()))==null))
|
||||
throw new java.io.IOException("Cycle journal references a missing inhabitant");
|
||||
@@ -233,7 +251,10 @@ public final class ProgressionService {
|
||||
private static final Type TYPE=new Type("sanctuary:hello_world");
|
||||
private final ServerConfigurationPacketListenerImpl handler;
|
||||
private final MinecraftServer server;
|
||||
private boolean awaitingIntro,awaitingStarter;
|
||||
private boolean awaitingIntro,awaitingStarter,admitted,busy;
|
||||
private String lastChecked="",lastStatus="code_unreachable",queuedCheck;
|
||||
private AccessPayloads.Admit queuedAdmit;
|
||||
private long lastCheckAt;
|
||||
HelloTask(ServerConfigurationPacketListenerImpl handler,MinecraftServer server) { this.handler=handler;this.server=server; }
|
||||
public Type type() { return TYPE; }
|
||||
public void start(Consumer<Packet<?>> sender) {
|
||||
@@ -255,6 +276,10 @@ public final class ProgressionService {
|
||||
void offer(String error) {
|
||||
var profile=((ProgressionConfigurationAccess)handler).sanctuary$profile();
|
||||
var session=session(server);var inhabitant=session.inhabitants.get(profile.id());var starters=session.starters.get(profile.id());
|
||||
if (session.access != null && ServerConfigurationNetworking.canSend(handler,AccessPayloads.Site.TYPE))
|
||||
ServerConfigurationNetworking.send(handler,new AccessPayloads.Site(AccessConfig.registrationUrl()));
|
||||
if (session.access != null && ServerConfigurationNetworking.canSend(handler,AccessPayloads.Gate.TYPE))
|
||||
ServerConfigurationNetworking.send(handler,new AccessPayloads.Gate(true,session.access.known(profile.id())!=null));
|
||||
if(starters!=null){
|
||||
var identity=new ProgressionPayloads.Hello(profile.name(),inhabitant==null?session.inhabitants.available():java.util.List.of(inhabitant.color()),error);
|
||||
ServerConfigurationNetworking.send(handler,new StarterPayloads.Welcome(identity,inhabitant==null?"":inhabitant.bio(),inhabitant!=null,starters.choices()));
|
||||
@@ -263,6 +288,7 @@ public final class ProgressionService {
|
||||
void create(ProgressionPayloads.Create payload) {
|
||||
if (awaitingIntro || awaitingStarter) return;
|
||||
if (!handler.isAcceptingMessages()) { PENDING.remove(handler); return; }
|
||||
if (AccessConfig.enabled() && !admitted) { offer("code_required"); return; }
|
||||
Session session=session(server);
|
||||
if (session == null || !session.healthy) { handler.disconnect(label("storage_invalid")); return; }
|
||||
try {
|
||||
@@ -288,6 +314,7 @@ public final class ProgressionService {
|
||||
}
|
||||
void createWithStarter(StarterPayloads.Create payload,String name) {
|
||||
if(awaitingIntro||!handler.isAcceptingMessages())return;
|
||||
if (AccessConfig.enabled() && !admitted) { offer("code_required"); return; }
|
||||
var session=session(server);if(session==null||!session.healthy){handler.disconnect(label("storage_invalid"));return;}
|
||||
try {
|
||||
var profile=((ProgressionConfigurationAccess)handler).sanctuary$profile();var uuid=profile.id();
|
||||
@@ -303,6 +330,99 @@ public final class ProgressionService {
|
||||
awaitingStarter=false;beginIntro(uuid);
|
||||
}catch(Exception error){fail(error);}
|
||||
}
|
||||
void check(String code) {
|
||||
if (awaitingIntro || !handler.isAcceptingMessages()) return;
|
||||
var session=session(server);
|
||||
if (session == null || session.access == null) return;
|
||||
var profile=((ProgressionConfigurationAccess)handler).sanctuary$profile();
|
||||
String normalized=AccessCodes.normalize(code);
|
||||
if (session.access.known(profile.id()) != null) { status(normalized,"code_recognized"); return; }
|
||||
if (!AccessCodes.validFormat(normalized)) { status(normalized,"code_malformed"); return; }
|
||||
if (!AccessCodes.validUsername(profile.name())) { status(normalized,"code_username"); return; }
|
||||
if (busy) { queuedCheck=normalized; queuedAdmit=null; return; }
|
||||
if (normalized.equals(lastChecked) && System.nanoTime()-lastCheckAt < 1_000_000_000L) { status(normalized,lastStatus); return; }
|
||||
verify(profile.name(),normalized);
|
||||
}
|
||||
void verify(String username,String code) {
|
||||
busy=true; lastChecked=code; lastCheckAt=System.nanoTime(); queuedCheck=null;
|
||||
var directory=AccessConfig.directory();
|
||||
String admission=session(server).access.admissionId(((ProgressionConfigurationAccess)handler).sanctuary$profile().id());
|
||||
java.util.concurrent.CompletableFuture.supplyAsync(() -> directory.verify(username,code,admission)).whenComplete((result,error) -> server.execute(() -> {
|
||||
busy=false;
|
||||
if (PENDING.get(handler) != this || !handler.isAcceptingMessages()) return;
|
||||
lastStatus=error != null || result == null ? "code_unreachable" : AccessCodes.verifyKey(result,username);
|
||||
status(code,lastStatus);
|
||||
if (queuedAdmit != null) { var next=queuedAdmit; queuedAdmit=null; admit(next); return; }
|
||||
if (queuedCheck != null && !queuedCheck.equals(code) && AccessCodes.validFormat(queuedCheck)) {
|
||||
String next=queuedCheck; queuedCheck=null; verify(username,next);
|
||||
}
|
||||
}));
|
||||
}
|
||||
void status(String code,String reason) {
|
||||
if (handler.isAcceptingMessages() && ServerConfigurationNetworking.canSend(handler,AccessPayloads.Status.TYPE))
|
||||
ServerConfigurationNetworking.send(handler,new AccessPayloads.Status(code,reason));
|
||||
}
|
||||
void admit(AccessPayloads.Admit payload) {
|
||||
if (awaitingIntro || !handler.isAcceptingMessages()) return;
|
||||
String normalized=AccessCodes.normalize(payload.code());
|
||||
if (busy) { queuedAdmit=payload; return; }
|
||||
var session=session(server);
|
||||
if (session == null || !session.healthy) { handler.disconnect(label("storage_invalid")); return; }
|
||||
if (session.access == null) { offer("code_unconfigured"); return; }
|
||||
var profile=((ProgressionConfigurationAccess)handler).sanctuary$profile();
|
||||
var uuid=profile.id();
|
||||
if (!ready(session,uuid,payload)) return;
|
||||
if (session.access.known(uuid) != null) { finishAdmit(session,uuid,payload); drain(); return; }
|
||||
if (!AccessCodes.validFormat(normalized)) { offer("code_malformed"); return; }
|
||||
if (!AccessCodes.validUsername(profile.name())) { offer("code_username"); return; }
|
||||
busy=true;
|
||||
String username=profile.name();
|
||||
var directory=AccessConfig.directory();
|
||||
String admission=session.access.admissionId(uuid);
|
||||
java.util.concurrent.CompletableFuture.supplyAsync(() -> directory.redeem(username,normalized,admission)).whenComplete((result,error) -> server.execute(() -> {
|
||||
busy=false;
|
||||
if (session(server) != session || !session.healthy) return;
|
||||
boolean connected=PENDING.get(handler) == this && handler.isAcceptingMessages();
|
||||
if (error != null || result == null) { if (connected) { offer("code_unreachable"); drain(); } return; }
|
||||
var decision=AccessCodes.redeem(result,username);
|
||||
if (!decision.accepted()) { if (connected) { offer(decision.reason()); drain(); } return; }
|
||||
try {
|
||||
session.access.remember(uuid,decision.minecraftUsername(),decision.discordId());
|
||||
if (!connected) return;
|
||||
finishAdmit(session,uuid,payload);
|
||||
} catch (Exception failure) { fail(failure); }
|
||||
drain();
|
||||
}));
|
||||
}
|
||||
boolean ready(Session session,UUID uuid,AccessPayloads.Admit payload) {
|
||||
var starters=session.starters.get(uuid);
|
||||
if (starters != null) {
|
||||
if (starters.choices().stream().noneMatch(choice -> choice.mob().equals(payload.mob()))) { offer("starter_required"); return false; }
|
||||
if (!StarterCompanions.validName(payload.name())) { offer("starter_name_invalid"); return false; }
|
||||
} else if (!payload.mob().isEmpty()) { offer("starter_required"); return false; }
|
||||
if (session.inhabitants.get(uuid) == null) {
|
||||
if (!InhabitantStore.validBio(payload.bio())) { offer("bio_invalid"); return false; }
|
||||
if (!session.inhabitants.available().contains(payload.color())) { offer("color_taken"); return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
void drain() {
|
||||
if (!handler.isAcceptingMessages() || PENDING.get(handler) != this) return;
|
||||
if (queuedAdmit != null) { var next=queuedAdmit; queuedAdmit=null; admit(next); }
|
||||
else if (queuedCheck != null && AccessCodes.validFormat(queuedCheck)) {
|
||||
String next=queuedCheck; queuedCheck=null;
|
||||
verify(((ProgressionConfigurationAccess)handler).sanctuary$profile().name(),next);
|
||||
}
|
||||
}
|
||||
void finishAdmit(Session session,UUID uuid,AccessPayloads.Admit payload) {
|
||||
admitted=true;
|
||||
try {
|
||||
if (payload.mob().isEmpty()) create(new ProgressionPayloads.Create(payload.bio(),payload.color()));
|
||||
else createWithStarter(new StarterPayloads.Create(payload.bio(),payload.color(),payload.mob()),payload.name());
|
||||
} finally { admitted=false; }
|
||||
try { if (session.inhabitants.get(uuid) != null) session.access.commit(uuid); }
|
||||
catch (Exception error) { fail(error); }
|
||||
}
|
||||
void fail(Exception error){
|
||||
session(server).healthy=false;LOG.error("Could not persist Hello World starter",error);handler.disconnect(label("storage_invalid"));
|
||||
}
|
||||
@@ -325,6 +445,7 @@ public final class ProgressionService {
|
||||
if (!restore(player)) return;
|
||||
sendNames(player,session);
|
||||
try {
|
||||
if (session.access != null) session.access.commit(player.getUUID());
|
||||
StarterCompanions.deliver(player,session.starters);
|
||||
if (session.inhabitants.arrive(player.getUUID())) player.level().getServer().getPlayerList()
|
||||
.broadcastSystemMessage(Component.literal("helloworld ").append(player.getDisplayName()),false);
|
||||
|
||||
@@ -272,6 +272,21 @@
|
||||
"sanctuary.progression.choose_color": "Choose your colour",
|
||||
"sanctuary.progression.cancel": "Cancel",
|
||||
"sanctuary.progression.enter": "Enter Sanctuary",
|
||||
"sanctuary.progression.code": "Access code",
|
||||
"sanctuary.progression.code_hint": "SANC-XXXX-XXXX",
|
||||
"sanctuary.progression.code_recognized": "Code recognised.",
|
||||
"sanctuary.progression.code_checking": "Checking the code…",
|
||||
"sanctuary.progression.code_malformed": "The code should look like SANC-XXXX-XXXX.",
|
||||
"sanctuary.progression.code_invalid": "This code does not match this name.",
|
||||
"sanctuary.progression.code_used": "This code has already been used.",
|
||||
"sanctuary.progression.code_replay": "This code is already used and Sanctuary cannot recover the account. Try again later.",
|
||||
"sanctuary.progression.code_unreachable": "Sanctuary is unreachable, try again.",
|
||||
"sanctuary.progression.code_unauthorized": "The server cannot reach Sanctuary.",
|
||||
"sanctuary.progression.code_unconfigured": "The server has no Sanctuary token.",
|
||||
"sanctuary.progression.code_required": "Enter the code sent in a private message.",
|
||||
"sanctuary.progression.code_username": "This name cannot receive a code.",
|
||||
"sanctuary.progression.code_mismatch": "The code does not match this session.",
|
||||
"sanctuary.progression.access_version_required": "This server requires the Sanctuary access code. Update the client.",
|
||||
"sanctuary.progression.joining": "Character saved, joining…",
|
||||
"sanctuary.progression.bio_invalid": "Use plain text for your bio (256 characters).",
|
||||
"sanctuary.progression.color_taken": "Colour just taken. Choose another one.",
|
||||
@@ -2439,5 +2454,13 @@
|
||||
"sanctuary.community.task_expired": "Request expired. Please publish again.",
|
||||
"sanctuary.community.own_notice": "You cannot follow your own request.",
|
||||
"sanctuary.community.follow_limit": "You already follow 16 requests.",
|
||||
"sanctuary.community.notice_full": "This request has no available places."
|
||||
"sanctuary.community.notice_full": "This request has no available places.",
|
||||
"sanctuary.community.follow_map_active": "Following: a map marker appears when a location is provided.",
|
||||
"sanctuary.community.follow_map_help": "Replying does not subscribe you. Follow this request to display its map marker.",
|
||||
"sanctuary.community.notice_full_help": "The single place is taken. You can reply, but cannot follow this request.",
|
||||
"sanctuary.community.conversation_empty": "No replies yet.",
|
||||
"sanctuary.community.request_details": "The request",
|
||||
"sanctuary.progression.get_code": "Get my code",
|
||||
"sanctuary.progression.retry_code": "Check code again",
|
||||
"sanctuary.starter.compact_slots": "Familiar"
|
||||
}
|
||||
|
||||
@@ -272,6 +272,21 @@
|
||||
"sanctuary.progression.choose_color": "Choisis ta couleur",
|
||||
"sanctuary.progression.cancel": "Annuler",
|
||||
"sanctuary.progression.enter": "Entrer dans Sanctuary",
|
||||
"sanctuary.progression.code": "Code d’accès",
|
||||
"sanctuary.progression.code_hint": "SANC-XXXX-XXXX",
|
||||
"sanctuary.progression.code_recognized": "Code reconnu.",
|
||||
"sanctuary.progression.code_checking": "Vérification du code…",
|
||||
"sanctuary.progression.code_malformed": "Le code doit ressembler à SANC-XXXX-XXXX.",
|
||||
"sanctuary.progression.code_invalid": "Ce code ne correspond pas à ce pseudo.",
|
||||
"sanctuary.progression.code_used": "Ce code a déjà été utilisé.",
|
||||
"sanctuary.progression.code_replay": "Ce code est déjà consommé et Sanctuary ne peut pas retrouver le compte. Réessaie plus tard.",
|
||||
"sanctuary.progression.code_unreachable": "Sanctuary est injoignable, réessaie.",
|
||||
"sanctuary.progression.code_unauthorized": "Le serveur ne peut pas joindre Sanctuary.",
|
||||
"sanctuary.progression.code_unconfigured": "Le serveur n’a pas de jeton Sanctuary.",
|
||||
"sanctuary.progression.code_required": "Entre le code reçu en message privé.",
|
||||
"sanctuary.progression.code_username": "Ce pseudo ne peut pas recevoir de code.",
|
||||
"sanctuary.progression.code_mismatch": "Le code ne correspond pas à cette session.",
|
||||
"sanctuary.progression.access_version_required": "Ce serveur exige le code d’accès Sanctuary. Mets le client à jour.",
|
||||
"sanctuary.progression.joining": "Création enregistrée, arrivée en cours…",
|
||||
"sanctuary.progression.bio_invalid": "La bio doit contenir du texte simple (256 caractères).",
|
||||
"sanctuary.progression.color_taken": "Couleur déjà prise. Choisis-en une autre.",
|
||||
@@ -2439,5 +2454,13 @@
|
||||
"sanctuary.community.task_expired": "La demande a expiré. Réessayez de publier.",
|
||||
"sanctuary.community.own_notice": "Vous ne pouvez pas suivre votre propre demande.",
|
||||
"sanctuary.community.follow_limit": "Vous suivez déjà 16 demandes.",
|
||||
"sanctuary.community.notice_full": "Cette demande n’a plus de place disponible."
|
||||
"sanctuary.community.notice_full": "Cette demande n’a plus de place disponible.",
|
||||
"sanctuary.community.follow_map_active": "Demande suivie : son repère apparaît sur la carte si un lieu est indiqué.",
|
||||
"sanctuary.community.follow_map_help": "Répondre ne vous abonne pas. Suivez la demande pour afficher son repère sur la carte.",
|
||||
"sanctuary.community.notice_full_help": "La place unique est occupée. Vous pouvez répondre, mais pas suivre cette demande.",
|
||||
"sanctuary.community.conversation_empty": "Aucune réponse pour le moment.",
|
||||
"sanctuary.community.request_details": "La demande",
|
||||
"sanctuary.progression.get_code": "Récupérer mon code",
|
||||
"sanctuary.progression.retry_code": "Revérifier le code",
|
||||
"sanctuary.starter.compact_slots": "Familier"
|
||||
}
|
||||
|
||||
@@ -289,7 +289,8 @@
|
||||
"client.NaturalWaterFogColorMixin",
|
||||
"client.NaturalWaterSectionCopyAccess",
|
||||
"client.PauseAtlasServerMixin",
|
||||
"client.CommunityPauseMixin"
|
||||
"client.CommunityPauseMixin",
|
||||
"client.PlayerListKeyMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package fr.koka.sanctuary.community;
|
||||
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
|
||||
public final class ServerMetricsSmoke {
|
||||
public static void main(String[] args) throws Exception {
|
||||
Path root=Files.createTempDirectory("sanctuary-metrics-test");
|
||||
String player="11111111-1111-4111-8111-111111111111",bot="22222222-2222-4222-8222-222222222222";
|
||||
Files.createDirectories(root.resolve("stats"));
|
||||
Files.writeString(root.resolve("stats/"+player+".json"),"{\"stats\":{\"minecraft:custom\":{\"minecraft:deaths\":12}}}");
|
||||
var ledger=new ServerMetricsLedger(root,100);
|
||||
var first=ledger.sample(3100,10000,Map.of(player,13L,bot,9L),Set.of(bot),1,true);
|
||||
check(first.uptimeMillis()==3000 && first.deaths()==13 && first.onlinePlayers()==1,"imports offline deaths, counts current deaths, excludes actors");
|
||||
ledger.sample(4100,11000,Map.of(),Set.of(),0,false);
|
||||
var restarted=new ServerMetricsLedger(root,900000);
|
||||
var next=restarted.sample(901000,9000000,Map.of(player,13L),Set.of(),1,true);
|
||||
check(next.uptimeMillis()==5000 && next.deaths()==13,"downtime excluded and deaths not counted twice");
|
||||
var death=restarted.sample(902000,9001000,Map.of(player,14L),Set.of(),1,true);
|
||||
check(death.deaths()==14,"new death increments cumulative total");
|
||||
Path file=root.resolve("data/sanctuary-server-metrics.json");Files.writeString(file,"corrupt");
|
||||
try{restarted.sample(903000,9002000,Map.of(),Set.of(),0,false);throw new AssertionError("overwrite");}catch(java.io.IOException expected){}
|
||||
check(Files.readString(file).equals("corrupt"),"external edit preserved");
|
||||
try{new ServerMetricsLedger(root,0);throw new AssertionError("reset");}catch(IllegalArgumentException expected){}
|
||||
System.out.println("SERVER_METRICS161_PASS");
|
||||
}
|
||||
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package fr.koka.sanctuary.progression;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class AccessCodeSmoke {
|
||||
public static void main(String[] args) throws Exception {
|
||||
require(AccessCodes.normalize(" sanc-ab3d-k7pq ").equals("SANC-AB3D-K7PQ"), "Codes are trimmed and uppercased");
|
||||
require(AccessCodes.validFormat("SANC-AB3D-K7PQ"), "Documented code shape is accepted");
|
||||
require(!AccessCodes.validFormat("SANC-AB3D-K7P") && !AccessCodes.validFormat("SANC-AB3D-K7PQX"), "Partial codes are refused");
|
||||
require(AccessCodes.validUsername("Steve") && !AccessCodes.validUsername("A") && !AccessCodes.validUsername("bad name"), "Session names stay Minecraft names");
|
||||
var unused = AccessCodes.parse(200, "{\"valid\":true,\"reason\":\"unused\",\"minecraft_username\":\"Steve\",\"discord_id\":\"123456789012345678\"}");
|
||||
require(AccessCodes.verifyKey(unused, "steve").equals("code_recognized"), "Verify matches the session name without consuming the code");
|
||||
require(AccessCodes.verifyKey(AccessCodes.parse(200, "{\"valid\":false,\"reason\":\"invalid\"}"), "Steve").equals("code_invalid"), "Unknown code stays on the screen");
|
||||
require(AccessCodes.verifyKey(AccessCodes.parse(200, "{\"valid\":false,\"reason\":\"already_used\"}"), "Steve").equals("code_used"), "Used code is not a fresh verification");
|
||||
require(AccessCodes.parse(401, "").reason().equals("unauthorized"), "A bad token is not a character");
|
||||
require(AccessCodes.parse(503, "nope").reason().equals("unreachable"), "An outage does not invent a character");
|
||||
var redeemed = AccessCodes.parse(200, "{\"valid\":true,\"reason\":\"redeemed\",\"minecraft_username\":\"Steve\",\"discord_id\":\"42\"}");
|
||||
var decision = AccessCodes.redeem(redeemed, "Steve");
|
||||
require(decision.accepted() && decision.discordId().equals("42"), "Redeem is the commitment");
|
||||
require(!AccessCodes.redeem(redeemed, "Alex").accepted(), "Another username cannot use the code");
|
||||
var replay = AccessCodes.parse(200, "{\"valid\":false,\"reason\":\"already_used\",\"minecraft_username\":\"Steve\",\"discord_id\":\"42\"}");
|
||||
require(!AccessCodes.redeem(replay, "Steve").accepted(), "Legacy consumption without scoped receipt stays refused");
|
||||
var stuck = AccessCodes.parse(200, "{\"valid\":false,\"reason\":\"already_used\"}");
|
||||
require(AccessCodes.redeem(stuck, "Steve").reason().equals("code_used"), "A consumed code without an account does not create anyone");
|
||||
|
||||
var path = Files.createTempDirectory("sanctuary-access-").resolve("access.json");
|
||||
UUID id = UUID.fromString("01234567-89ab-cdef-fedc-ba9876543210");
|
||||
var ledger = new AccessLedger(path, 42L);
|
||||
String admission = ledger.admissionId(id);
|
||||
require(new AccessLedger(path, 42L).admissionId(id).equals(admission), "Scope persists before HTTP request, even before ledger write");
|
||||
require(!ledger.admissionId(UUID.randomUUID()).equals(admission), "Another player has another admission");
|
||||
require(!new AccessLedger(Files.createTempDirectory("other-world").resolve("access.json"),42L).admissionId(id).equals(admission), "Same seed and player in another world cannot replay");
|
||||
String response = "{\"valid\":true,\"reason\":\"resumed\",\"minecraft_username\":\"Steve\",\"discord_id\":\"42\",\"admission_id\":\"" + admission + "\"}";
|
||||
var resumed = AccessCodes.parseScoped(200,response,admission);
|
||||
require(AccessCodes.verifyKey(resumed,"Steve").equals("code_recognized"), "Lost response can pass verification again");
|
||||
require(AccessCodes.redeem(resumed,"Steve").accepted(), "Lost response can finish admission");
|
||||
require(!AccessCodes.redeem(AccessCodes.parseScoped(200,response,UUID.randomUUID().toString()),"Steve").accepted(), "Wrong admission receipt refused");
|
||||
require(!AccessCodes.parseScoped(200,"{\"valid\":true,\"reason\":\"redeemed\"}",admission).valid(), "Missing admission receipt refused");
|
||||
ledger.remember(id, "Steve", "42");
|
||||
var reopened = new AccessLedger(path, 42L);
|
||||
require(reopened.known(id).discordId().equals("42"), "A crash after redeem keeps the Discord account");
|
||||
reopened.commit(id);
|
||||
var committed = new AccessLedger(path, 42L);
|
||||
require(committed.known(id).name().equals("Steve"), "The binding survives the character write");
|
||||
committed.commit(id);
|
||||
committed.remember(id, "Steve", "42");
|
||||
String saved = Files.readString(path);
|
||||
require(saved.indexOf(id.toString()) == saved.lastIndexOf(id.toString()), "An existing character is not returned to pending");
|
||||
byte[] broken = "{".getBytes();
|
||||
Files.write(path, broken);
|
||||
boolean refused = false;
|
||||
try { new AccessLedger(path, 42L); } catch (IllegalArgumentException error) { refused = true; }
|
||||
require(refused && Files.readAllBytes(path).length == 1, "A corrupt ledger is preserved and refused");
|
||||
System.out.println("ACCESS-01: code contract, replay and ledger passed");
|
||||
}
|
||||
private static void require(boolean value, String message) { if (!value) throw new AssertionError(message); }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package fr.koka.sanctuary.progression;
|
||||
|
||||
import java.nio.file.*;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Explicit opt-in: consumes a disposable web fixture, never a real player's code. */
|
||||
public final class AccessInterop {
|
||||
public static void main(String[] args) throws Exception {
|
||||
AccessConfig.load(Path.of(System.getenv("SANCTUARY_ACCESS_TEST_CONFIG")));
|
||||
String code=System.getenv("SANCTUARY_ACCESS_TEST_CODE");
|
||||
String username="AccessProbe";
|
||||
UUID player=UUID.nameUUIDFromBytes("OfflinePlayer:AccessProbe".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
Path file=Files.createTempDirectory("sanctuary-access-interop-").resolve("access.json");
|
||||
var ledger=new AccessLedger(file,166);
|
||||
String admission=ledger.admissionId(player);
|
||||
var directory=AccessConfig.directory();
|
||||
require(AccessCodes.verifyKey(directory.verify(username,code,admission),username).equals("code_recognized"),"Fresh verify");
|
||||
directory.redeem(username,code,admission); // Deliberately lose the response, before any local remember().
|
||||
var restarted=new AccessLedger(file,166);
|
||||
require(AccessCodes.verifyKey(directory.verify(username,code,restarted.admissionId(player)),username).equals("code_recognized"),"Verify after lost response and restart");
|
||||
var decision=AccessCodes.redeem(directory.redeem(username,code,restarted.admissionId(player)),username);
|
||||
require(decision.accepted(),"Redeem after lost response");
|
||||
restarted.remember(player,decision.minecraftUsername(),decision.discordId());
|
||||
restarted.commit(player);
|
||||
require(new AccessLedger(file,166).known(player)!=null,"Binding survived restart");
|
||||
require(!AccessCodes.redeem(directory.redeem(username,code,UUID.randomUUID().toString()),username).accepted(),"Another admission refused");
|
||||
require(!AccessCodes.redeem(directory.redeem("OtherProbe",code,admission),"OtherProbe").accepted(),"Another player refused");
|
||||
System.out.println("ACCESS166_INTEROP_PASS: actual PHP API, lost response, restart, replay, wrong scope and wrong player");
|
||||
}
|
||||
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "beta.160"
|
||||
version = "beta.166"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
@@ -63,6 +63,8 @@ def launch(args):
|
||||
# JVM overrides must precede the main class, therefore rewrite a temporary argfile.
|
||||
lines = argfile.read_text().splitlines()
|
||||
lines = [f'"-Xmx{args.memory}m"' if line.startswith('"-Xmx') else line for line in lines]
|
||||
if args.command == "server" and args.intro:
|
||||
lines.insert(0, '"-Dsanctuary.duo.intro=true"')
|
||||
if args.command == "client":
|
||||
lines += ['"--quickPlayMultiplayer"', '"127.0.0.1:25575"']
|
||||
runtime = LAB / (args.command + "-runtime.args")
|
||||
@@ -113,6 +115,8 @@ def main():
|
||||
for command, memory in [("server", 768), ("client", 2048)]:
|
||||
item = sub.add_parser(command)
|
||||
item.add_argument("--memory", type=int, default=memory)
|
||||
if command == "server":
|
||||
item.add_argument("--intro", action="store_true", help="Play the real introduction instead of skipping it")
|
||||
capture = sub.add_parser("record")
|
||||
capture.add_argument("--window", type=int, required=True, help="Verified Minecraft window ID; never the desktop")
|
||||
capture.add_argument("--seconds", type=int, default=120)
|
||||
|
||||
Reference in New Issue
Block a user