Compare commits

...
17 Commits
Author SHA1 Message Date
koka 7979f33028 Document beta.144–166 release backfill and integrated Chris PR
Build Sanctuary / build (push) Waiting to run
2026-09-24 20:01:06 +02:00
koka 460c4706d3 beta.166: integrate resumable web registration and prepare fresh intro lab
Build Sanctuary / build (push) Waiting to run
2026-09-24 17:41:29 +02:00
koka ad960ef9bc Merge Chris access-code registration onto beta.165 2026-09-24 17:17:26 +02:00
koka 835e6116e6 Review Chris access-code PR integration and recovery gap 2026-09-24 17:10:51 +02:00
koka aadff6fd5e Refresh legacy GameTests against current gameplay contracts 2026-09-24 17:10:51 +02:00
koka 705fe9c1a0 Audit all 23 known beta.165 GameTest failures 2026-09-24 16:56:30 +02:00
koka dbb9e3428d Lay out Gazette article and conversation for beta.165
Build Sanctuary / build (push) Waiting to run
2026-09-24 16:34:38 +02:00
koka 9a429c40a1 Align pause calendar and server bulletin for beta.164
Build Sanctuary / build (push) Waiting to run
2026-09-24 13:56:27 +02:00
koka 905b3bcfa1 Reshape pause navigation and frame for beta.163
Build Sanctuary / build (push) Waiting to run
2026-09-24 13:36:56 +02:00
koka 0c55a37562 Deliver beta.162 notice author scene and side conversation
Build Sanctuary / build (push) Waiting to run
2026-09-24 00:38:09 +02:00
koka 8deba7cfb4 Deliver beta.161 playtest fixes and shared server metrics
Build Sanctuary / build (push) Waiting to run
2026-09-24 00:20:06 +02:00
koka c1d2278674 beta.160: opt-in shaders and lightweight local duo test lab
Build Sanctuary / build (push) Waiting to run
2026-09-23 22:27:49 +02:00
koka bdaad0c940 Add community search, responsive gallery and followed notice map markers
Build Sanctuary / build (push) Waiting to run
2026-09-23 01:20:17 +02:00
koka 19aa8054a5 beta.158: redesign community cards and add descriptive notice tracking
Build Sanctuary / build (push) Waiting to run
2026-09-23 00:24:47 +02:00
koka 9791d178ab beta.157: require article photos with a scrollable screenshot gallery
Build Sanctuary / build (push) Waiting to run
2026-09-22 22:43:19 +02:00
koka 32158e2e05 Déplace la reprise du jeu sous la colonne communautaire (beta.156)
Build Sanctuary / build (push) Waiting to run
2026-09-22 21:34:41 +02:00
ChrisM-PekandCursor 8de569bbf1 Add access-code gate to Hello World creation (beta.144).
Build Sanctuary / build (push) Canceled after 0s
Build Sanctuary / build (pull_request) Canceled after 0s
Require a site-issued SANC code when the server has app URL and API token configured, with replay-safe ledger binding and FR/EN UI including galactic code display.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 03:03:48 +02:00
102 changed files with 5055 additions and 228 deletions
+7
View File
@@ -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.
+88 -10
View File
@@ -1,13 +1,91 @@
# Sanctuary
## beta.155 — nouvelle structure du menu pause (pack local)
[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
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
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
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
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
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
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
@@ -15,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
@@ -2383,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
@@ -2412,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).
+61
View File
@@ -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.
+55
View File
@@ -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.
+137
View File
@@ -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).
+98
View File
@@ -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).
+79
View File
@@ -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).
+100
View File
@@ -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).
+98
View File
@@ -0,0 +1,98 @@
# beta.158 — cartes communautaires et demandes suivies
## Contrat v3 et migration
Les règles de génération et les inventaires Minecraft ne changent pas. Les coffres
sont des sélections descriptives : aucun objet n'est prélevé, réservé, livré ou
créé. Les échanges et la répartition des récompenses sont organisés entre joueurs.
Il n'y a ni gagnant automatique ni validation automatique d'une livraison.
Une annonce peut porter `task` : position facultative (dimension, x/y/z), liste
`materials` de 27 identifiants d'objets et quantités (1 à 999999), participation
`shared` ou `single`, récompense `none`, `items` (27 sélections) ou `custom` (240
caractères). Les catégories info/work/need/event gardent leurs identifiants.
Le serveur Minecraft valide aussi les objets et dimensions contre son registre.
Les données du site sont descriptives et restent lisibles si un objet manque côté
client ; les identifiants inconnus ne créent jamais d'objet.
Les abonnements sont authentifiés, idempotents, au plus 16 par joueur et 64 par
annonce. Une annonce single accepte un seul abonné. L'auteur ne s'abonne pas à sa
propre annonce. Fermeture et masquage retirent le suivi actif ; réouvrir autorise
à nouveau l'abonnement. Les abonnements existants ne sont pas supprimés par la
fermeture : un joueur peut se désabonner. Changer shared vers single est refusé
si plusieurs abonnés sont présents. La liste côté HUD est issue du serveur ; les
notifications affichent les trois suivis ouverts les plus récents, avec le lieu
et un rappel des matériaux. La fermeture est annoncée, sans attribuer de gain.
Fichier : ajout du champ task et de la collection followers ; lecture v1/v2 sans
écriture. À la première écriture, backup exact `.v1.bak` ou `.v2.bak` sans
écrasement puis écriture atomique en v3. Le plafond 64 Mio reste appliqué. Aucun
monde personnel n'est migré pendant le développement. Retour arrière : serveur
arrêté, restauration du backup et de la version logicielle correspondante.
MariaDB : arrêter les écritures du mod et du site, sauvegarder, appliquer la
migration v2→v3 (SQL livré ou Laravel), puis mettre à jour les deux applications.
Colonne task nullable, tables followers et subscribers (verrous par joueur).
Aucune migration automatique par le mod ; aucune suppression des anciens posts.
Les anciens articles et annonces restent lisibles. Rollback uniquement par
restauration explicite ; pas de suppression automatique des nouvelles données.
La Gazette et le tableau utilisent une grille à deux colonnes. Les aperçus de
photo sont bornés à 6 Kio binaires par carte ; les listes ne diffusent pas les
photos complètes. L'intendance reste affichée dans l'en-tête, sans accès public
à son historique ni à son éditeur ; sa gestion est réservée au panneau admin.
Le panneau Web est complété par `/sanctuary community admin` côté jeu, réservé
aux opérateurs par le serveur, notamment pour les installations en mode fichier.
Les métadonnées de l’article (visage, nom, date) sont sur la rangée du bouton
« … », juste sous l’encadré photo/texte. La galerie est plafonnée à 420 unités
de largeur et 180 de hauteur ; l’aperçu du formulaire est limité à 160 × 72.
Le bouton de liaison du compte Web disparaît des écrans communautaires en jeu.
Les sondages restent une piste, sans fonctionnalité livrée dans cette version.
## Vérifications
Vérifications ciblées du 23 septembre 2026 :
- Stockage fichier et MariaDB : métadonnées, inscription exclusive concurrente,
idempotence, limite de 16 suivis, fermeture/réouverture, désabonnement,
refus shared→single occupé, masquage et redémarrage. Migration v1/v2 avec
conservation exacte des backups.
- Client natif Vulkan : français/anglais, échelles GUI 2/3/4, grilles de deux
colonnes, défilement et chargement des cartes suivantes sans retour en haut,
galerie compacte, photo obligatoire, publication, coffre de sélection,
position courante, récompense personnalisée, abonnement et HUD.
- Vérification du déplacement visage/nom/date sous l’article, à côté de « … ».
- Accès opérateur au panneau d’intendance en jeu : refus au joueur ordinaire,
ouverture et publication après activation des permissions natives.
- Régression du menu pause : colonnes, boutons, carte, défilements indépendants,
longs articles/annonces et réponse, sous Vulkan en FR/EN et GUI 2/3/4.
- Site : 23 tests / 131 assertions, dont droits, migrations v3 sans perte ni
rétrogradation, validation des matériaux et inscription exclusive.
- Interopérabilité MariaDB réelle Java→PHP→Java : photo, édition, réponse,
identité vérifiée, demande et abonnement Web relus par le mod (18 assertions PHP).
- Ressources de shaders : 29 fichiers identiques au JAR beta.157, qui conservait
le socle beta.151. Les anciens JAR et MRpack sont conservés.
Suite générale : `./gradlew check build assemblePack` exécuté ; 252 GameTests,
229 réussites et les mêmes 23 échecs identifiés dans la beta.157, aucun nouvel ID.
Comparaison conservée dans `build/cards158-server-failures.json`. Les autres
contrôles, le build et l’assemblage passent avec cette suite déjà vérifiée exclue :
`./gradlew check build assemblePack :sanctuary:runClientGameTest -x :sanctuary:runGameTest`
avec les propriétés client Cards158/Vulkan et la base MariaDB de développement.
Le dernier parcours confirme aussi le refus des quantités invalides et la
conservation des 17 bûches et 3 diamants réels après publication descriptive.
Export packwiz réussi ; version interne beta.158 et JAR embarqué vérifiés.
Captures finales : `build/cards158-database-screens/` ; captures fichier :
`build/cards158-file-screens/`. Les grilles, la galerie et le pied d’article
ont été inspectés visuellement. Journaux : `build/beta158-*.log`.
Site local : commit `0dcab21` sur
`codex/community-quests`. Aucun déploiement dans une installation personnelle,
aucune publication du canal packwiz ni modification d’un monde existant.
## Artefacts locaux
- `mods/sanctuary/build/libs/sanctuary-beta.158.jar` — SHA-256 `0590320dcece7f94543ab17e99fb23ac37cc3e2578499aa6e80ad801212675e8`.
- `build/Sanctuary-beta.158.mrpack` — SHA-256 `9c1e556ddce3094febf92631c8a1c8cc0ec56971b332cd9c2d7448f2f9931b89`.
+80
View File
@@ -1,5 +1,8 @@
# Communauté Sanctuary — contrat v1
> Depuis beta.158, le schéma actif est la v3 : photos et demandes suivies.
> Appliquer les migrations avant de connecter les deux applications.
Ticket beta.154, branche `codex/community-beta154`, socle beta.151 `b97ec6c`.
Ce contrat décrit le comportement cible et les échanges implémentés par ce ticket.
La note de livraison indique séparément les vérifications effectivement réussies.
@@ -158,3 +161,80 @@ Sur le site, le rendu par UUID utilise le prestataire mc-api.io déjà employé
par la page de compte ; chargement différé, sans référent, initiale locale en
cas d’erreur. Aucune image ni URL arbitraire n’entre dans le contrat des posts.
Documentation du rendu : https://mc-api.io/docs (GET `/render/{uuid}?size=64`).
## Évolution v2 (beta.157)
Le champ photo et sa migration explicite, avec conservation des anciens articles,
sont décrits dans [Photos de Gazette](gazette-photos-beta157.md).
Pour une nouvelle base partagée avec le site, utiliser les migrations Laravel.
Pour une base autonome administrée par SQL, appliquer schema-v1.sql puis
schema-v1-to-v2.sql avant de connecter le mod beta.157.
## Évolution v3 — demandes suivies (beta.158)
Annonce : `task` contient `location` nullable (`dimension`, `x`, `y`, `z`),
`materials` et `rewards` (27 objets maximum, `item` namespacé de 100 caractères
maximum, `count` de 1 à 999999), `audience` shared/single, `reward` none/items/custom
et `customReward` (240 caractères). Les coffres sont descriptifs : échanges
et partage des récompenses entre joueurs, sans transaction d'inventaire.
Les tables `sanctuary_community_followers` et `sanctuary_community_subscribers`
portent les abonnements et les verrous par joueur. Maximum 16 abonnements/joueur,
64/annonce, un seul en mode single ; pas d'auto-abonnement de l'auteur.
Le masquage supprime les abonnements ; la fermeture les conserve mais suspend
le suivi actif. Un passage en single est refusé au-delà d'un abonné.
Arrêter les écritures et sauvegarder avant migration. Installation SQL neuve :
schema-v1.sql, schema-v1-to-v2.sql, puis schema-v2-to-v3.sql du mod. Sur le site,
`php artisan migrate` applique l'équivalent et reconnaît les tables déjà créées.
Le compte JDBC nécessite SELECT/INSERT/DELETE sur followers et SELECT/INSERT
sur subscribers, en plus des permissions v2. Aucun DDL automatique par le mod.
L'intendance se gère dans le panneau admin ; seul son dernier message reste
public en en-tête. La liaison de compte se fait avec le code généré sur le site
et `/sanctuary community link <code>` en jeu (serveur authentifié requis).
Les anciens boutons de liaison et d'historique public de l'intendance disparaissent.
En mode fichier, le panneau d’intendance en jeu s’ouvre avec
`/sanctuary community admin` ; le serveur exige les permissions opérateur.
## Évolution beta.159 — recherche et lecture de l’intendance
Le schéma reste **v3** : aucune migration de données. Cette évolution remplace
la restriction de lecture publique de beta.158 : les onglets Gazette, Tableau
et Serveur permettent de lire leurs historiques. La publication et la gestion
de l’intendance restent dans l’administration, sans réponse ni abonnement.
`list` et `cards` acceptent une recherche facultative dans `Request.body` (120
caractères maximum, vide par défaut). Le site utilise le paramètre GET `q`,
conservé dans la pagination. Recherche littérale de sous-chaîne dans le nom de
l’auteur, le titre **ou** le corps, sans distinction de casse, avant pagination.
Les accents restent significatifs et `%` / `_` sont des caractères ordinaires.
Le filtre reste limité au serveur et à la rubrique sélectionnés ; les contenus
masqués restent exclus. Les épingles gardent leur priorité. Le client attend
300 ms après la frappe et ignore les réponses correspondant à un ancien filtre.
Les pages restent bornées à 10 publications ; aucune recherche dans les réponses.
Les dates des publications affichent l’année (UTC). Le menu pause affiche la date
complète dans la langue et le fuseau local du joueur. La galerie utilise la
surface disponible du GUI, adapte ses colonnes et conserve ses pages de 24
captures défilantes. Le clic ouvre un aperçu dans cette surface, en conservant
les proportions et les boutons Retour / Assigner. Le formulaire conserve son
aperçu compact. Les PNG sources ne sont pas modifiés.
Les demandes suivies ouvertes qui ont une position affichent un `!` sur la carte
de pause et le grand atlas, dans la dimension correspondante. Elles ne révèlent
pas le terrain et ne chargent aucun chunk. L’infobulle montre titre, auteur,
résumé, coordonnées, matériaux et récompense ; les listes longues sont abrégées
pour rester dans le GUI. Le clic ouvre l’annonce. « Voir sur la carte » centre
l’atlas sur son lieu dans la dimension courante, sans téléportation. Les repères
suivent tous les abonnements, indépendamment des trois suivis visibles dans le
HUD ; ils disparaissent au désabonnement, à la clôture ou au masquage. Un
rafraîchissement des suivis reste actif lorsque la carte ou la pause est ouverte
(période de cinq secondes, différée si une autre requête est en cours).
Le message réseau `following` ajoute `summary`, extrait littéral du corps limité
à 240 caractères Unicode plus une ellipse. Le texte intégral et les photos ne
sont pas diffusés dans ce message. Aucun champ persistant supplémentaire.
+81
View File
@@ -0,0 +1,81 @@
# beta.159 — recherche communautaire et galerie adaptable
Branche `codex/community-search-gallery-beta159`, socle beta.158.
## Résultat
Une barre de recherche filtre les publications par auteur, titre ou texte côté
serveur, avant pagination. Le filtre accompagne les pages suivantes, conserve
les épingles et se réinitialise en effaçant le champ. Les réponses aux anciennes
recherches sont ignorées et le focus de saisie est conservé.
Les trois onglets Gazette / Tableau / Serveur donnent accès aux publications de
chaque rubrique. L’en-tête d’intendance de la pause ouvre aussi son historique.
La lecture est publique ; la publication et la gestion des messages du serveur
restent réservées au panneau administrateur, même pour un opérateur visitant
l’écran public. Les messages du serveur n’acceptent ni réponses ni abonnements.
L’année figure dans la date de pause et les métadonnées des publications. La
galerie utilise l’espace disponible aux différentes échelles de GUI et tailles
de fenêtre. Les captures restent paginées par 24 avec défilement. Cliquer une
capture ouvre un grand aperçu avec les commandes de retour et d’assignation.
La photo assignée reste compacte dans le formulaire et les brouillons sont
conservés lors du retour. Aucune modification des PNG originaux.
Les annonces suivies géolocalisées portent un « ! » sur les deux cartes.
L’infobulle fournit titre, auteur, résumé, coordonnées, matériaux et récompense
sans ouvrir la discussion ; le clic reste disponible. Un bouton dans l’annonce
centre le grand atlas sur sa position, dans la dimension courante. Aucune
téléportation ni révélation du terrain : seules les coordonnées publiées sont
projetées. Les annonces fermées, masquées, sans lieu ou non suivies ne portent
pas de repère. Les longues infobulles sont abrégées pour tenir dans le GUI.
Le site suit le même contrat de recherche et de lecture. Schéma v3 inchangé,
aucune migration de sauvegarde ni de base. Libellés français et anglais.
## Vérifications
- Stockage fichier et MariaDB : recherche par auteur et corps, casse et accents,
filtre avant pagination, épingles conservées, caractères `%` / `_` littéraux,
rubrique et serveur isolés, contenus masqués exclus, borne de 120 caractères.
- Client natif Minecraft 26.3 / Java 25 / Vulkan : galerie et aperçu en FR/EN,
GUI 2/3/4 et fenêtres 1280×960 / 1920×1080 ; retour au brouillon, assignation,
publication, listes défilantes et chargement des pages suivantes.
- Recherche en jeu au-delà de la première page, filtre par joueur, effacement,
conservation du focus et rejet d’une réponse correspondant à l’ancien filtre.
- Lecture des messages serveur par un joueur ordinaire, aucun contrôle d’écriture
dans les pages publiques, y compris pour l’opérateur ; administration inchangée.
- Carte : projection dans la dimension correspondante, titre/auteur/résumé,
matériaux et récompense au survol ; ouverture de la demande par clic sur les
deux cartes ; retrait immédiat du repère après désabonnement.
- Date avec année en FR/EN, GUI 2/3/4, sans chevauchement de l’intendance.
- Site : 24 tests / 157 assertions sur SQLite puis MariaDB de développement,
formatage Pint réussi. Commit local `e70e78d`, branche `codex/community-search`.
La commande complète `./gradlew :sanctuary:runClientGameTest check build assemblePack`
avec les propriétés client Cards158, QuickTests et Vulkan a exécuté 252 GameTests :
229 réussites et les mêmes 23 échecs que beta.158. Aucun test serveur supprimé
ou modifié. La comparaison est conservée dans `build/search159-server-failures.json`
et le journal dans `build/beta159-check-build.log`.
Les contrôles restants, la compilation et `assemblePack` passent avec la suite
serveur déjà exécutée exclue (`-x :sanctuary:runGameTest`) : 133 tâches, journal
`build/beta159-release.log`. Le parcours client final passe en mode MariaDB,
puis en mode fichier (`build/beta159-file-client.log`). Les repères ont été
vérifiés avec et sans terrain d’atlas débloqué ; les infobulles restent dans la
fenêtre aux GUI 2/3/4. Captures représentatives inspectées :
`build/search159-file-screens/` et `build/search159-database-screens/`.
L’export packwiz est vérifié : intégrité ZIP, version beta.159, JAR embarqué
identique au JAR de livraison. Les 29 ressources de shaders sont identiques à
beta.158, qui conserve le socle beta.151. Les anciens JAR et MRpack beta.154 à
beta.158 restent présents. Aucun monde personnel, déploiement de serveur, canal
packwiz ou instance Prism n’est modifié.
## Artefacts locaux
- `mods/sanctuary/build/libs/sanctuary-beta.159.jar` — SHA-256 `c30d57a5f37e0a5c9dbb387ca831ce9e5c15a1dfe4cb55af0af20a376e862576`.
- `build/Sanctuary-beta.159.mrpack` — SHA-256 `868932d88bc241bfb526a7c5e4054009e247658bd0784b7141d82017edcc2823`.
+60
View File
@@ -0,0 +1,60 @@
# Récap Discord — beta.130 à beta.160
Brouillon à relire avant publication. Les captures sont des scènes de test,
pas des photos d'un serveur public. Aucune annonce envoyée automatiquement.
## Message 1 — l'image et la lumière
**Sanctuary — retour sur les beta.130 à 160**
On a d'abord beaucoup travaillé l'ambiance : bloom, minerais lumineux, rebonds
de couleur, relief des matériaux, reflets, rayons du soleil et lumières portées.
L'eau a reçu ses propres réglages, puis plusieurs passes de correction ont
amélioré les transitions, les distances et les matières.
La version du shader conservée est celle du socle beta.151. Elle reste gourmande,
mais on garde ce travail ! En beta.160, le shader devient **désactivé par défaut
sur une nouvelle configuration**, et peut être réactivé dans les options.
Images : `01-minerais-bloom-beta130.png`, `02-eau-beta144.png`.
## Message 2 — la vie du serveur
Le chantier suivant rapproche Minecraft et le site Sanctuary : **Gazette,
tableau d'affichage et intendance** partagent un contrat de publications.
Le mod fonctionne avec des fichiers par défaut ; une base MariaDB permet de
partager les données avec le site.
Le menu pause a été réorganisé autour des systèmes Sanctuary, de la carte et
des publications. La Gazette accepte les articles avec une capture obligatoire,
les réponses, les épingles et la recherche par joueur ou contenu.
Le tableau distingue les types d'annonces par couleur. Les demandes peuvent
indiquer un lieu, des matériaux, une récompense et des participants. Ce sont
des descriptions pour organiser les échanges entre joueurs : aucun coffre de
dépôt ni paiement automatique. Une demande suivie apparaît avec un **! sur la
carte**, et son résumé se lit au survol.
Images : `03-menu-et-quete-beta159.png`, `04-materiaux-beta158.png`.
## Message 3 — tester ensemble
Avec la beta.160, on prépare nos séances de test en binôme sur Mac : un petit
serveur local, un seul Minecraft à l'écran et des personnages de laboratoire.
Le but : vérifier les échanges côté technique pendant que les vrais essais en
jeu nous montrent ce qu'il faut simplifier dans l'interface.
Des scènes courtes et des captures horodatées permettront de reprendre les
moments où l'on hésite, cherche un bouton ou perd le fil. Le prochain chantier
est l'ergonomie, dans le jeu comme sur le site.
## Sources et pièces jointes
Les images originales et leurs empreintes sont rassemblées dans
`build/discord-beta130-160/` (ignoré par Git), avec `images.json` pour la provenance.
Les captures anciennes illustrent leur version d'origine, sans revendiquer une
nouvelle validation graphique. Les sondages restent une idée, pas une fonction livrée.
Références : `shader-beta130.md` à `nether-pbr-beta151.md`,
`community-beta154.md`, `pause-redesign-beta155.md`, `gazette-photos-beta157.md`,
`community-cards-beta158.md`, `community-search-beta159.md`, `duo-lab-beta160.md`.
+155
View File
@@ -0,0 +1,155 @@
# DUO-160 — laboratoire Mac et tests en binôme
Branche `codex/duo-lab-beta160`, base `beta.159`. Minecraft 26.3, Java 25,
Fabric 0.19.5 ; mêmes dépendances. Validation graphique Vulkan uniquement.
## Contrat
Le shader natif est OFF quand sa préférence est absente. Un choix sauvegardé
ON ou OFF reste respecté, ainsi que ses intensités. Aucun shader supprimé :
les ressources beta.151 restent identiques. Le client du laboratoire reçoit
explicitement `enabled:false`, indépendamment de l'installation personnelle.
Tout le laboratoire réside dans `build/duo/`, ignoré par Git. Le monde plat
`duo-flat-160`, graine 160, sert aux interfaces et aux échanges ; il ne valide
pas le terrain Sanctuary. Aucun monde personnel ouvert ou modifié.
Le module `sanctuary-test` fournit les personnages uniquement avec
`-Dsanctuary.duo=true`. Il ne fait pas partie du pack normal.
## Préparer et lancer
```sh
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
./gradlew :sanctuary-test:exportDuoLaunch --max-workers=1 -Dorg.gradle.jvmargs=-Xmx1G
python3 scripts/duo_lab.py prepare
python3 scripts/duo_lab.py server
# Dans un autre terminal :
python3 scripts/duo_lab.py client
```
L'export permet ensuite de lancer Java directement, sans conserver un processus
Gradle pendant la séance. Préparer à nouveau ne remplace aucun fichier existant.
Le client rejoint automatiquement `127.0.0.1:25575`. Au premier accès,
choisir la couleur et le familier dans HELLO_WORLD puis entrer dans Sanctuary.
Depuis la console serveur : `op KokaLab` donne les commandes au client de test.
Le serveur écoute seulement sur loopback et utilise des identités hors ligne
pour ce laboratoire. Ce profil ne doit pas être exposé sur le réseau. La liaison
authentifiée d'un compte web exige toujours un vrai serveur en mode en ligne ;
elle n'est pas contournée par les outils du laboratoire.
Réglages de départ : serveur 256–768 Mio de heap, vue 4 chunks, simulation 3 ;
client 512–2048 Mio, vue 6, simulation 4, 30 FPS, fenêtre 1280×720. La mémoire
native, graphique et macOS s'ajoute au heap. Aucun engagement de tenir dans
une consommation totale de 2,75 Gio. Ne pas exécuter la compilation en même
temps qu'une séance. Augmenter seulement après mesure :
```sh
python3 scripts/duo_lab.py server --memory 1024
python3 scripts/duo_lab.py client --memory 2560
```
Le mode fichier reste le défaut. Pour une préparation neuve avec le site local :
```sh
python3 scripts/duo_lab.py prepare --storage database --server-id UUID_DU_SERVEUR \
--jdbc jdbc:mariadb://127.0.0.1:33077/BASE_DE_TEST
SANCTUARY_COMMUNITY_DB_PASSWORD='' python3 scripts/duo_lab.py server
```
La base doit déjà avoir le schéma communautaire v3. Le site doit utiliser le
même identifiant de serveur. Aucun schéma ni sauvegarde n'est migré ici.
## Personnages et séances
```text
/duo spawn Alice
/duo spawn Bob
/duo list
/duo remove Alice
/duo trace start
/duo trace stop
```
Les personnages portent le préfixe `Lab_`, sont créatifs et apparaissent près
de la source de commande. Limite de quatre, noms uniques de 1–12 caractères
ASCII alphanumériques ou `_`. Ce sont des ServerPlayer sans transport réseau
ni rendu, pas des IA et pas des clients réseau supplémentaires. On peut les
cibler avec les commandes natives, par exemple `tp Lab_Bob ~2 ~ ~`.
Ils ne valident pas seuls le protocole d'un deuxième client réel.
La trace consigne noms de test, dimension, positions et angles toutes les deux
secondes dans `build/duo/server/duo-sessions/`. Activation explicite, arrêt au
bout de cinq minutes maximum ou à la fermeture du serveur. Pas de frappe clavier,
mot de passe, navigateur ou audio enregistré.
Pour la relecture visuelle, identifier la fenêtre Minecraft puis lancer :
```sh
python3 scripts/duo_lab.py windows
python3 scripts/duo_lab.py record --window ID_FENETRE_MINECRAFT --seconds 120
```
Une séquence PNG horodatée toutes les deux secondes, un index HTML et une fiche
de notes sont écrits dans `build/duo/sessions/`. Ctrl-C termine la capture ; limite
de cinq minutes. macOS peut demander l'autorisation de capture. Cette version
ne produit pas une vidéo continue et peut manquer une interaction très courte.
On ne démarre une capture qu'au début d'une scène annoncée.
## Trois premières scènes
1. Ouvrir la Gazette, retrouver un auteur, lire un article et répondre. Vérifier
que l'autre interface retrouve la réponse, puis noter les hésitations.
2. Créer une demande avec lieu et matériaux, la suivre, lire son infobulle sur
la carte, ouvrir la discussion puis arrêter le suivi.
3. Afficher deux figurants, tester les interactions de proximité et comparer
la fluidité à un seul joueur. Garder un véritable second client pour une
vérification réseau ultérieure si nécessaire.
Le récap Discord se trouve dans `discord-beta130-160.md` ; les originaux des
quatre illustrations sont copiés avec provenance dans `build/discord-beta130-160/`.
## Vérifications
- `./gradlew check build assemblePack` : 252 GameTests, 229 réussites et les
mêmes 23 échecs que beta.159. Aucun échec ajouté ; comparaison dans
`build/duo160-server-failures.json`, log `build/beta160-check-build.log`.
- Contrôles restants, compilation, pack et client natif Vulkan réussis avec la
suite serveur déjà exécutée exclue (`-x :sanctuary:runGameTest`) : 137 tâches,
`build/beta160-release.log`. Le test `ShaderDefaults160ClientChecks` vérifie
préférence absente, objet vide, ON explicite, OFF enregistré et réactivation,
avec intensité personnelle conservée. Marqueur `SHADER160_DEFAULTS_PASS`.
- Module de test reconstruit après ajustement du transport sans réseau : build
réussi. L'export de lancement inclut les arguments fournis par Loom et élimine
son argfile imbriqué, que Java ne peut pas réinterpréter depuis un autre argfile.
- Serveur réel à 768 Mio : démarrage sur loopback, monde plat neuf ; création de
quatre acteurs, doublon et cinquième refusés, retrait et acteur absent vérifiés.
Le journal produit 56 observations JSON valides puis s'arrête sur commande.
- Client réel séparé, Vulkan, 2048 Mio, shader OFF : connexion jusqu'à l'écran
HELLO_WORLD vérifiée visuellement. La création du profil et le parcours en jeu
avec le joueur restent à faire ensemble ; aucune session ergonomique humaine
terminée ni validation d'un deuxième client réseau revendiquée.
- Mesure ponctuelle avant l'entrée du joueur : serveur ~313 Mio de heap utilisé,
client au menu ~289 Mio. Ce ne sont ni des pics ni une mesure de RAM système
totale. Les figurants ne possèdent ni sockets ni fenêtre graphique.
- Capture ciblée macOS essayée sur quatre secondes : séquence PNG, manifeste,
index HTML et fiche de notes créés. Aucun enregistrement continu ou micro.
- Export MRpack : intégrité ZIP, version et JAR embarqué vérifiés. Les 29 ressources
de shaders sont inchangées depuis beta.159. JAR et packs beta.154–159 conservés.
- Skill personnel `sanctuary-session-notes` installé et validé séparément dans
`~/.codex/skills/` pour classer les dictées en Markdown. Il ne change pas le
modèle du tour courant ; Luna/low peut être choisi dans une tâche dédiée.
Le serveur local utilise la base de développement du site, schéma v3 déjà présent,
sous le même scope. Aucune validation de liaison de comptes hors ligne : cette
opération reste réservée au mode authentifié. Aucun déploiement public, canal
packwiz, serveur personnel ou instance Prism modifié.
## Artefacts locaux
- `mods/sanctuary/build/libs/sanctuary-beta.160.jar` — SHA-256
`4c19e620e989050488c9a96aaddaf897a74115a9fb4c5099e5518ae32db5b2e8`.
- `build/Sanctuary-beta.160.mrpack` — SHA-256
`272e3460f35c8d9a4de689d30e4d952c3121923ba5fe30a23c83b3e76bd23bac`.
- `build/duo/Jouer.command` et `Serveur.command` : raccourcis locaux de séance.
+96
View File
@@ -0,0 +1,96 @@
# beta.157 — photo obligatoire et galerie de captures
## Contrat de migration communautaire v2
La génération, les chunks et les sauvegardes Minecraft ne changent pas.
Seul le document communautaire évolue : champ `photo`, JPEG encodé en base64,
48 Kio binaires maximum, dimensions de 1 × 1 à 960 × 540 pixels.
Les captures originales ne sont jamais modifiées. Les copies destinées à la Gazette
sont redimensionnées et réencodées, sans métadonnées, avant transmission.
Les nouveaux articles exigent une photo valide. Les articles v1 restent lisibles ;
leur prochaine modification exige une photo. Avis, intendance et réponses restent
textuels. La modération ne nécessite pas d'ajouter une photo à un ancien article.
La photo est enregistrée atomiquement avec le texte, auteur et révision ; pas de
référence à un chemin local, d'URL distante ou de média orphelin persistant.
Fichier : v1 est lu sans modification. À la première écriture réussie, une copie
exacte `sanctuary-community.json.v1.bak` est créée sans écrasement, puis le document
v2 est écrit atomiquement. Un backup différent bloque la migration. La limite de
64 Mio du document reste appliquée, photos comprises. Un retour à beta.156 exige
de restaurer la copie v1 et perd les changements communautaires postérieurs.
Ne pas restaurer pendant que le serveur tourne.
MariaDB : arrêter les écritures du site et du serveur, sauvegarder la base, exécuter
explicitement `community/schema-v1-to-v2.sql` ou la migration Laravel équivalente,
puis lancer les deux applications v2. Migration additive : colonne `photo` nullable,
aucune suppression ni réécriture des articles existants. Le mod n'exécute aucune
migration SQL au démarrage. Les applications v1 refusent le schéma v2. Pas de
rollback automatique destructif ; restaurer la sauvegarde avec les deux logiciels
v1 si nécessaire. Aucun changement n'est appliqué aux bases ou mondes personnels
par la préparation de cette livraison.
Réseau : canaux v1 conservés, action `photo` par fragments de 8 000 caractères,
au plus 9 fragments et 65 536 caractères, accusés un par un. Une seule photo
transitoire par joueur, liée à l'UUID de publication, expire après deux minutes ;
limite globale de 128 transferts. Les listes ne transportent pas les photos ;
seul le détail d'un article les contient. Le plafond des réponses
passe à 262 144 caractères pour une photo et une discussion complète ; les
clients et le serveur doivent être mis à jour ensemble vers beta.157. Les
anciens clients ne peuvent plus créer d'article sans photo.
Galerie : grille de 2 ou 3 colonnes selon la taille d'interface, molette et barre
native, 24 captures par page parmi les 4 096 PNG les plus récents par nom, lecture
en arrière-plan. Aperçu puis assignation explicite ; Annuler conserve le brouillon.
Les captures illisibles restent visibles mais désactivées. Limites sources :
32 Mio, 8 192 pixels par axe et 32 millions de pixels. Textures libérées à la
fermeture. Le site accepte PNG/JPEG et nécessite PHP GD ; le mod propose les PNG
du dossier `screenshots` de son instance.
## Vérifications
- Tests natifs Vulkan Minecraft 26.3 : galerie FR/EN aux échelles 2/3/4,
molette, pagination, aperçu, annulation avec brouillon conservé, bouton Publier
désactivé sans photo, envoi fragmenté, photo visible et relecture après
réouverture de l'adaptateur. Parcours réussi en fichier et MariaDB.
- Tests métier : photo manquante/invalide, révision, auteur, conservation après
modification, reprise idempotente, redémarrage fichier/SQL, ancien article v1,
copie de migration exacte, fragments hors ordre/incomplets/expirés.
- Site : 17 tests HTTP, 97 assertions réussies ; photo obligatoire, faux fichier,
dimensions refusées, réduction, lecture, isolement serveur, auteur, masquage,
anciennes publications et conservation après modification.
- Échange réel Java/PHP sur une MariaDB isolée : création en jeu lue par le site,
modification et réponse Web relues en Java, article/photo du site décodés par
Java. Migration Laravel et alternative SQL testées sur bases de développement.
- `./gradlew check build assemblePack` : 252 GameTests serveur terminés en
3,457 minutes, 229 réussis et les mêmes 23 échecs que la baseline beta.154/155
(liste comparée intégralement, aucun nouvel identifiant en échec). La commande
générale échoue donc après 6 min 59 s ; cette limite préexistante reste ouverte.
- Régression native du menu pause : FR/EN, échelles 2/3/4, carte, défilements
indépendants, pagination, article et avis longs, réponse, options et reprise :
réussie avec les articles munis d'une photo.
- Construction et assemblage séparés, sans relancer les 23 GameTests connus :
`check build assemblePack -x :sanctuary:runGameTest` réussit (avec le parcours
client natif du menu pause), 2 min 56 s.
Les captures d'essai sont des mires générées dans une instance de développement,
sans accès aux captures ou sauvegardes personnelles. Pas de déploiement sur le
site public, serveur personnel, canal packwiz ou Prism. Les JAR beta.154–156
restent présents ; JAR et MRpack beta.156 gardent leurs empreintes antérieures.
Le site correspondant est sur `codex/community-photos`, commits `e3ae3eb` et
`9626e95`, sans push ni publication. Logs de développement :
`build/photos157-client-file.log`, `build/photos157-client-database.log`,
`build/photos157-interop-*.log`, `build/photos157-check-build.log` et
`build/photos157-final-build.log`.
## Artefacts locaux
JAR `mods/sanctuary/build/libs/sanctuary-beta.157.jar` et pack
`build/Sanctuary-beta.157.mrpack`, versions et JAR embarqué vérifiés.
Les 29 ressources de shaders sont identiques à beta.156 (socle beta.151).
Reçu complet : `build/photos157-validation.json`.
- SHA-256 mods/sanctuary/build/libs/sanctuary-beta.157.jar: `96ff6d22ab593eb113cde63239ff4839f6a75b9a165fbec93a0aaab3c09dee7c`.
- SHA-256 build/Sanctuary-beta.157.mrpack: `b03da5f67a3dcf8051666641dccd379a52b8c3c09308e1654b00440a37693e96`.
+26
View File
@@ -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.
+120
View File
@@ -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.
+66
View File
@@ -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.
+8
View File
@@ -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 :
+25
View File
@@ -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`.
+27
View File
@@ -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.
+33
View File
@@ -0,0 +1,33 @@
# PAUSE-156 — reprendre la partie sous la colonne droite
Branche `codex/pause-resume-right-beta156`, livraison locale `beta.156`.
Suite de [PAUSE-155](pause-redesign-beta155.md).
Le bouton natif Reprendre la partie se trouve en bas à droite, sous la colonne
Gazette / tableau, avec le même alignement et la même largeur. Il conserve son
action native et les libellés français / anglais de Minecraft.
La modification concerne uniquement sa géométrie. Les options restent en bas
à gauche ; les publications, le stockage, la carte et les shaders conservent
leur fonctionnement de beta.155. Aucune migration ni installation personnelle.
## Vérifications
Le scénario client Vulkan existant passe en français et anglais, GUI 2/3/4 à
1280 × 960 : alignement du bouton avec la colonne droite, largeur identique,
absence de chevauchement avec le tableau, reprise effective du jeu. Les
interactions communautaires du scénario passent également en mode fichier.
Log : `build/pause156-client.log`, aperçu `build/pause156-preview.png`.
`./gradlew check build assemblePack -x :sanctuary:runGameTest` réussit.
Export packwiz, intégrité ZIP, versions du mod / pack et contenu du JAR
embarqué vérifiés. Les shaders sont inchangés et les artefacts beta.155
conservés avec leurs empreintes. Logs : `build/pause156-build-pack.log`,
`build/pause156-export.log`, reçu `build/pause156-artifacts.json`.
La suite serveur complète de beta.155 compte 229 réussites / 252, avec les
23 échecs historiques. Elle n’est pas relancée pour ce déplacement de bouton.
## Artefacts locaux
- `mods/sanctuary/build/libs/sanctuary-beta.156.jar` — SHA-256 `c325a757cff6063fe3702e755a57a3abc13ad86e071762354a3c449504d51c6f`.
- `build/Sanctuary-beta.156.mrpack` — SHA-256 `0537273dbfecb071d481f3f22982fbbb9a8e8a75581e38128ded87926ee699d0`.
+44
View File
@@ -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.
+91
View File
@@ -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.
+78
View File
@@ -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
View File
@@ -9,8 +9,8 @@ loom_version=1.17.20
fabric_api_version=0.160.5+26.3
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
mod_version=beta.155
pack_version=beta.155
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
+34
View File
@@ -16,5 +16,39 @@ processResources {
filesMatching('fabric.mod.json') { expand(values) }
}
loom { runs { client { runDir 'run/quick-test' } } }
loom {
runs {
duoServer {
server()
name 'Sanctuary duo lab'
runDir '../../build/duo/server'
vmArg '-Xms256m'
vmArg '-Xmx768m'
vmArg '-Dsanctuary.duo=true'
}
duoClient {
client()
name 'Sanctuary duo client'
runDir '../../build/duo/client'
vmArg '-Xms512m'
vmArg '-Xmx2G'
programArgs '--graphicsBackend', 'vulkan', '--username', 'KokaLab', '--width', '1280', '--height', '720'
}
}
}
// Export the Loom launcher so play sessions do not retain a Gradle JVM.
tasks.register('exportDuoLaunch') {
dependsOn tasks.named('runDuoServer').get().taskDependencies.getDependencies(tasks.named('runDuoServer').get())
dependsOn tasks.named('runDuoClient').get().taskDependencies.getDependencies(tasks.named('runDuoClient').get())
doLast {
['server': 'runDuoServer', 'client': 'runDuoClient'].each { kind, taskName ->
def launch = tasks.named(taskName).get()
def destination = rootProject.layout.buildDirectory.file("duo/${kind}.args").get().asFile
destination.parentFile.mkdirs()
def arguments = launch.allJvmArgs.findAll { !it.toString().startsWith('@') } + ['-classpath', launch.classpath.asPath, launch.mainClass.get()] + launch.args + launch.argumentProviders.collectMany { it.asArguments().toList() }
destination.text = arguments.collect { '"' + it.toString().replace('\\', '\\\\').replace('"', '\\"') + '"' }.join('\n') + '\n'
}
}
}
tasks.named('test') { failOnNoDiscoveredTests=false }
tasks.named('jar') { from(rootProject.file('LICENSE')) { rename { 'LICENSE_sanctuary_test' } } }
@@ -0,0 +1,136 @@
package fr.koka.sanctuarytest;
import com.mojang.authlib.GameProfile;
import com.mojang.brigadier.arguments.StringArgumentType;
import fr.koka.sanctuary.operator.OperatorService;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.Connection;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.PacketFlow;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ClientInformation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.network.CommonListenerCookie;
import net.minecraft.world.level.GameType;
/** Optional development module only. No sockets, renderer or additional JVM per actor. */
final class DuoLab {
private static final Map<String, ServerPlayer> ACTORS = new LinkedHashMap<>();
private static java.io.BufferedWriter trace;
private static int ticks;
private static long traceStarted;
private DuoLab() { }
static void register() {
// An explicit JVM opt-in keeps the ordinary quick-test pack unchanged.
if (!Boolean.getBoolean("sanctuary.duo")) return;
CommandRegistrationCallback.EVENT.register((dispatcher, registries, environment) ->
dispatcher.register(Commands.literal("duo").requires(OperatorService::canCommand)
.then(Commands.literal("trace")
.then(Commands.literal("start").executes(c -> trace(c.getSource(), true)))
.then(Commands.literal("stop").executes(c -> trace(c.getSource(), false))))
.then(Commands.literal("spawn").then(Commands.argument("name", StringArgumentType.word())
.executes(c -> spawn(c.getSource(), StringArgumentType.getString(c, "name")))))
.then(Commands.literal("remove").then(Commands.argument("name", StringArgumentType.word())
.executes(c -> remove(c.getSource(), StringArgumentType.getString(c, "name")))))
.then(Commands.literal("list").executes(c -> {
c.getSource().sendSuccess(() -> Component.translatable("sanctuary_test.duo.list", String.join(", ", ACTORS.keySet())), false);
return ACTORS.size();
}))));
ServerTickEvents.END_SERVER_TICK.register(server -> {
if (trace != null && System.nanoTime() - traceStarted >= 300_000_000_000L) closeTrace();
if (trace == null || ++ticks % 40 != 0) return;
try {
for (var player : server.getPlayerList().getPlayers()) {
var row = new com.google.gson.JsonObject();
row.addProperty("time", java.time.Instant.now().toString());
row.addProperty("player", player.getGameProfile().name());
row.addProperty("actor", ACTORS.containsValue(player));
row.addProperty("dimension", player.level().dimension().identifier().toString());
row.addProperty("x", player.getX()); row.addProperty("y", player.getY()); row.addProperty("z", player.getZ());
row.addProperty("yaw", player.getYRot()); row.addProperty("pitch", player.getXRot());
trace.write(row.toString()); trace.newLine();
}
trace.flush();
} catch (java.io.IOException error) {
org.slf4j.LoggerFactory.getLogger("sanctuary-duo").error("Trace stopped: cannot write session", error);
closeTrace();
}
});
ServerLifecycleEvents.SERVER_STOPPED.register(server -> { ACTORS.clear(); closeTrace(); });
}
private static int trace(CommandSourceStack source, boolean start) {
closeTrace();
if (start) try {
var folder = java.nio.file.Path.of("duo-sessions");
java.nio.file.Files.createDirectories(folder);
var path = folder.resolve("positions-" + System.currentTimeMillis() + ".jsonl");
trace = java.nio.file.Files.newBufferedWriter(path, StandardCharsets.UTF_8, java.nio.file.StandardOpenOption.CREATE_NEW);
traceStarted = System.nanoTime(); ticks = 0;
} catch (java.io.IOException error) {
source.sendFailure(Component.translatable("sanctuary_test.duo.trace_failed")); return 0;
}
source.sendSuccess(() -> Component.translatable("sanctuary_test.duo.trace_" + (start ? "started" : "stopped")), false);
return 1;
}
private static void closeTrace() {
if (trace != null) try { trace.close(); } catch (java.io.IOException ignored) { }
trace = null;
}
private static int spawn(CommandSourceStack source, String name) {
if (!name.matches("[A-Za-z0-9_]{1,12}") || ACTORS.size() >= 4
|| source.getServer().getPlayerList().getPlayerByName("Lab_" + name) != null) {
source.sendFailure(Component.translatable("sanctuary_test.duo.invalid"));
return 0;
}
String fullName = "Lab_" + name;
var profile = new GameProfile(UUID.nameUUIDFromBytes(("SanctuaryDuo:" + fullName).getBytes(StandardCharsets.UTF_8)), fullName);
var player = new ServerPlayer(source.getServer(), source.getLevel(), profile, ClientInformation.createDefault());
source.getServer().getPlayerList().placeNewPlayer(new SinkConnection(), player, CommonListenerCookie.createInitial(profile, false));
var position = source.getPosition();
player.teleportTo(source.getLevel(), position.x + 2, position.y, position.z, java.util.Set.of(), 0, 0, false);
player.setGameMode(GameType.CREATIVE);
ACTORS.put(name, player);
source.sendSuccess(() -> Component.translatable("sanctuary_test.duo.spawned", fullName), false);
return 1;
}
private static int remove(CommandSourceStack source, String name) {
var player = ACTORS.remove(name);
if (player == null) {
source.sendFailure(Component.translatable("sanctuary_test.duo.missing", name));
return 0;
}
source.getServer().getPlayerList().remove(player);
source.sendSuccess(() -> Component.translatable("sanctuary_test.duo.removed", name), false);
return 1;
}
private static final class SinkConnection extends Connection {
private net.minecraft.network.PacketListener listener;
SinkConnection() { super(PacketFlow.SERVERBOUND); }
@Override public <T extends net.minecraft.network.PacketListener> void setupInboundProtocol(net.minecraft.network.ProtocolInfo<T> protocol, T listener) { this.listener = listener; }
@Override public net.minecraft.network.PacketListener getPacketListener() { return listener; }
@Override public void flushChannel() { }
@Override public void send(Packet<?> packet) { }
@Override public void send(Packet<?> packet, io.netty.channel.ChannelFutureListener listener) { }
@Override public void send(Packet<?> packet, io.netty.channel.ChannelFutureListener listener, boolean flush) { }
@Override public boolean isConnected() { return true; }
@Override public boolean isConnecting() { return false; }
@Override public boolean isMemoryConnection() { return true; }
@Override public SocketAddress getRemoteAddress() { return new InetSocketAddress("127.0.0.1", 0); }
}
}
@@ -16,10 +16,11 @@ public final class QuickTestMod implements ModInitializer {
}
public void onInitialize(){
QuickTestWorld.enable();
DuoLab.register();
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")));
});
}
}
@@ -1,3 +1,11 @@
{
"generator.sanctuary_test.flat": "Sanctuary — Quick Test"
"generator.sanctuary_test.flat": "Sanctuary — Quick Test",
"sanctuary_test.duo.list": "Lab actors: %s",
"sanctuary_test.duo.invalid": "Name: 1–12 letters/digits/_; up to 4 actors, unique names.",
"sanctuary_test.duo.spawned": "%s spawned nearby (test actor).",
"sanctuary_test.duo.missing": "Lab actor not found: %s",
"sanctuary_test.duo.removed": "Lab actor removed: %s",
"sanctuary_test.duo.trace_failed": "Cannot write session log.",
"sanctuary_test.duo.trace_started": "Position log started (one sample every 2 seconds).",
"sanctuary_test.duo.trace_stopped": "Position log stopped."
}
@@ -1,3 +1,11 @@
{
"generator.sanctuary_test.flat": "Sanctuary — Test rapide"
"generator.sanctuary_test.flat": "Sanctuary — Test rapide",
"sanctuary_test.duo.list": "Personnages labo : %s",
"sanctuary_test.duo.invalid": "Nom : 1–12 lettres/chiffres/_ ; 4 personnages maximum, noms uniques.",
"sanctuary_test.duo.spawned": "%s créé à proximité (personnage de test).",
"sanctuary_test.duo.missing": "Personnage labo introuvable : %s",
"sanctuary_test.duo.removed": "Personnage labo retiré : %s",
"sanctuary_test.duo.trace_failed": "Impossible d’écrire le journal de séance.",
"sanctuary_test.duo.trace_started": "Journal des positions démarré (une mesure toutes les 2 secondes).",
"sanctuary_test.duo.trace_stopped": "Journal des positions arrêté."
}
+39
View File
@@ -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') }
@@ -128,6 +131,12 @@ tasks.named('processGametestResources') {
def sunshaft137Client = providers.gradleProperty('sanctuarySunshaft137ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySunshaft137ClientTests', sunshaft137Client)
if (sunshaft137Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Sunshafts137ClientChecks') }
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') }
@@ -258,6 +267,12 @@ tasks.named('processGametestResources') {
def multiblocks084Client = providers.gradleProperty('sanctuaryMultiblocks084ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryMultiblocks084ClientTests', multiblocks084Client)
if (multiblocks084Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Multiblocks084ClientChecks') }
def cards158Client = providers.gradleProperty('sanctuaryCards158ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryCards158ClientTests', cards158Client)
if (cards158Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Community158ClientChecks') }
def photos157Client = providers.gradleProperty('sanctuaryPhotos157ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPhotos157ClientTests', photos157Client)
if (photos157Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Photos157ClientChecks') }
def pause155Client = providers.gradleProperty('sanctuaryPause155ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPause155ClientTests', pause155Client)
if (pause155Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Pause155ClientChecks') }
@@ -1150,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') }
}
@@ -1235,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'
}
@@ -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);}
}
}
}
@@ -69,7 +69,7 @@ public final class Community154ClientChecks implements FabricClientGameTest {
c.waitTicks(25);
c.runOnClient(m -> { press(m.gui.screen(),"sanctuary.community.actions"); press(m.gui.screen(),"sanctuary.community.close"); });
c.waitFor(m -> !CommunityClient.pending() && has(m.gui.screen(),"sanctuary.community.reopen"),300);
c.runOnClient(m -> { check(!has(m.gui.screen(),"sanctuary.community.reply"),"Closed announcement hides reply action"); CommunityClient.open(new PauseScreen(true),"article","",true); edit(m.gui.screen()).setValue("Brouillon"); body(m.gui.screen()).setValue("§Texte refusé"); press(m.gui.screen(),"sanctuary.community.publish"); });
c.runOnClient(m -> { check(!has(m.gui.screen(),"sanctuary.community.reply"),"Closed announcement hides reply action"); CommunityClient.DRAFTS.computeIfAbsent("new:article",unused->new CommunityClient.Draft()).photo=testPhoto(); CommunityClient.open(new PauseScreen(true),"article","",true); edit(m.gui.screen()).setValue("Brouillon"); body(m.gui.screen()).setValue("§Texte refusé"); press(m.gui.screen(),"sanctuary.community.publish"); });
c.waitFor(m -> !CommunityClient.pending() && (has(m.gui.screen(),"sanctuary.community.invalid") || has(m.gui.screen(),"sanctuary.community.busy")),300);
c.runOnClient(m -> { check(body(m.gui.screen()).getValue().equals("§Texte refusé"),"Refused draft retained"); m.gui.screen().onClose(); CommunityClient.open(m.gui.screen(),"article","",true); check(edit(m.gui.screen()).getValue().equals("Brouillon"),"Draft survives closing screen"); m.gui.screen().onClose(); });
c.runOnClient(m -> { CommunityClient.open(m.gui.screen(),"article","",false); }); c.waitTicks(4);
@@ -80,6 +80,7 @@ public final class Community154ClientChecks implements FabricClientGameTest {
c.runOnClient(m -> m.gui.setScreen(null));
} finally { intro.set(false); }
}
static String testPhoto() {try {return fr.koka.sanctuary.community.CommunityPhoto.encode(new java.awt.image.BufferedImage(16,9,java.awt.image.BufferedImage.TYPE_INT_RGB),960,540);}catch(Exception e){throw new AssertionError(e);}}
static java.util.stream.Stream<GuiEventListener> widgets(GuiEventListener e) { return java.util.stream.Stream.concat(java.util.stream.Stream.of(e), e instanceof ContainerEventHandler h ? h.children().stream().flatMap(Community154ClientChecks::widgets) : java.util.stream.Stream.empty()); }
static boolean has(Screen s,String key) { return s!=null && widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w -> w.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key)); }
static boolean text(Screen s,String value) { return widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w -> w.getMessage().getString().equals(value)); }
@@ -0,0 +1,282 @@
package fr.koka.sanctuary.client;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import fr.koka.sanctuary.community.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
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.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Screenshot selection and article round trip through actual client/server packets. */
public final class Community158ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
boolean database=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_DATABASE"));
var intro=new AtomicBoolean(true);
var folder=new java.util.concurrent.atomic.AtomicReference<Path>();
try {
var path=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json");
var config=new CommunityConfig(database?"database":"file",UUID.randomUUID().toString(),database?System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC"):"jdbc:mariadb://localhost/sanctuary","root","SANCTUARY_COMMUNITY_DB_PASSWORD");
Files.createDirectories(path.getParent());Files.writeString(path,new com.google.gson.Gson().toJson(config));
} catch(Exception e){throw new AssertionError(e);}
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);folder.set(m.gameDirectory.toPath().resolve("screenshots"));});
try {
Files.createDirectories(folder.get());
var image=new java.awt.image.BufferedImage(1200,675,java.awt.image.BufferedImage.TYPE_INT_RGB);
var random=new Random(157);
for(int y=0;y<675;y++)for(int x=0;x<1200;x++)image.setRGB(x,y,((x/50)%2==0 && (y/50)%2==0)?0x2a6b52:random.nextInt());
for(int i=0;i<28;i++)javax.imageio.ImageIO.write(image,"png",folder.get().resolve(String.format("z-photo157-%02d.png",i)).toFile());
Files.writeString(folder.get().resolve("z-photo157-29.png"),"not a PNG");
}catch(Exception e){throw new AssertionError(e);}
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get() && m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
var noticeId=UUID.randomUUID().toString();
net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents.SERVER_STARTED.register(server->{try {
var config=CommunityConfig.load(net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
CommunityStore store=database?new SqlCommunityStore(config.jdbcUrl(),config.user(),"",config.serverId()):new FileCommunityStore(server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
var author=new Community.Actor(UUID.randomUUID().toString(),"Voisin",true);
store.change(author,new Community.Change("create",UUID.randomUUID().toString(),"","bulletin","info","Annonce officielle159","Le serveur vous souhaite la bienvenue.",0));
for(int i=0;i<13;i++) {
store.change(author,new Community.Change("create",UUID.randomUUID().toString(),"","article","info","Gazette "+i,"Le village raconte. ".repeat(90)+(i==0?" Unique159":""),0,testPhoto()));
store.change(author,new Community.Change("create",i==0?noticeId:UUID.randomUUID().toString(),"","notice",List.of("info","event","work","need").get(i%4),"Demande "+i,"Rendez-vous au village. ".repeat(40),0,null,i==0?new CommunityTask(new CommunityTask.Location("minecraft:overworld",0,64,0),List.of(new CommunityTask.Material("minecraft:oak_log",128)),"shared","custom",List.of(),"Un repas au village"):CommunityTask.EMPTY));
}
}catch(Exception error){throw new AssertionError(error);}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Cards158 "+UUID.randomUUID());s.setSeed("157");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&&!IntroSpawnFlash.active(),1200);
world.getServer().runOnServer(s->{
var player=s.getPlayerList().getPlayers().getFirst();player.getAbilities().invulnerable=true;player.setExperienceLevels(20);
check(fr.koka.sanctuary.progression.ProgressionService.purchase(player,"atlas",fr.koka.sanctuary.progression.ProgressEffects.get(player).revision()),"Atlas unlocked through progression");
fr.koka.sanctuary.progression.ProgressionService.send(player,"");
for(int z=-2;z<=2;z++)for(int x=-2;x<=2;x++) {
var tile=fr.koka.sanctuary.atlas.AtlasSampler.sample(player.level(),player.chunkPosition().x()+x,player.chunkPosition().z()+z);
if(tile!=null)fr.koka.sanctuary.atlas.AtlasService.observations(player).observe(tile);
}
});
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4))for(int windowWidth:List.of(1280,1920)) {
c.runOnClient(m->{
m.getWindow().setWindowed(windowWidth,windowWidth==1280?960:1080);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
CommunityClient.open(new PauseScreen(true),"article","",true);
check(!button(m.gui.screen(),"publish").active,"Photo is mandatory in the composer");
edit(m.gui.screen()).setValue("Une journée au village");body(m.gui.screen()).setValue("La photo rejoint la Gazette.");
press(m.gui.screen(),"sanctuary.community.photo_choose");
});
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
c.runOnClient(m->{
var s=m.gui.screen();check(s instanceof CommunityScreenshotsScreen,"Dedicated screenshot view");
var a=widgets(s).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow();
if(a.maxScrollAmount()>0){s.mouseScrolled(a.getX()+5,a.getY()+5,0,-4);check(a.scrollAmount()>0,"Wheel moves screenshots");a.setScrollAmount(0);}
var photos=widgets(s).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).toList();
check(photos.stream().mapToInt(AbstractWidget::getRight).max().orElseThrow()>s.width*0.8,"Gallery uses GUI width");
for(var child:s.children())if(child instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Gallery bounds "+w.getMessage());
});
c.takeScreenshot("search159-gallery-"+locale+"-"+scale+"-"+windowWidth+(database?"-database":"-file"));
c.runOnClient(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow().onPress(enter()));
c.runOnClient(m->{var s=m.gui.screen();var image=widgets(s).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow();check(image.getWidth()>s.width*0.8&&image.getHeight()>s.height*0.5,"Preview fills available GUI");check(button(s,"photo_assign").getBottom()<s.height-30,"Preview controls remain visible");});
c.takeScreenshot("search159-preview-"+locale+"-"+scale+"-"+windowWidth);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.screenshots_back"));
c.runOnClient(m->m.gui.screen().onClose());
c.runOnClient(m->{check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Cancel retains text");check(!button(m.gui.screen(),"publish").active,"Cancel leaves photo unassigned");});
}
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.options.guiScale().set(2);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());press(m.gui.screen(),"sanctuary.community.photo_choose");});
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.next"));
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).count()>0&&button(m.gui.screen(),"previous").active,500);
c.runOnClient(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow().onPress(enter()));
c.takeScreenshot("cards158-preview");
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.photo_assign"));
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"));
CommunityStore store=database?new SqlCommunityStore(config.jdbcUrl(),config.user(),"",config.serverId()):new FileCommunityStore(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
var posts=store.list("article","",0).entries();check(posts.size()==10&&posts.getFirst().title().equals("Une journée au village"),"Newest publication among seeded cards");check(CommunityPhoto.validate(posts.getFirst().photo()).length()>8000,"Photo transported in multiple chunks and survives adapter restart");
}catch(Exception e){throw new AssertionError(e);}});
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4))for(String kind:List.of("article","notice")){
c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());CommunityClient.open(new PauseScreen(true),kind,"",false);});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==10&&!CommunityClient.pending(),300);
c.runOnClient(m->{var screen=m.gui.screen();var cards=widgets(screen).filter(CommunityCard.class::isInstance).map(CommunityCard.class::cast).toList();
check(cards.get(0).getY()==cards.get(1).getY()&&cards.get(0).getRight()<cards.get(1).getX(),"Two-column grid");
check(cards.get(2).getY()>cards.get(0).getY(),"Rows continue vertically");
check(!has(screen,"sanctuary.community.link")&&has(screen,"sanctuary.community.server_tab"),"Public navigation restores read-only server history");
check(widgets(screen).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).anyMatch(a->a.maxScrollAmount()>0),"Grid scrolls");
});
c.takeScreenshot("cards158-grid-"+kind+"-"+locale+"-"+scale+(database?"-database":"-file"));
}
c.runOnClient(m->{var a=widgets(m.gui.screen()).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow();a.setScrollAmount(a.maxScrollAmount());press(m.gui.screen(),"sanctuary.community.more");});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==13&&!CommunityClient.pending(),300);
c.runOnClient(m->check(widgets(m.gui.screen()).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).anyMatch(a->a.scrollAmount()>0),"Appending cards retains scroll"));
c.runOnClient(m->{CommunityClient.open(new PauseScreen(true),"article","",false);edit(m.gui.screen()).setValue("unique159");});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==1&&!CommunityClient.pending(),300);
c.runOnClient(m->{check(text(m.gui.screen(),"Gazette 0"),"Search finds body beyond first page");edit(m.gui.screen()).setValue("VoISin");});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==10&&!CommunityClient.pending(),300);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.more"));
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==13&&!CommunityClient.pending(),300);
c.runOnClient(m->{edit(m.gui.screen()).setValue("absent159");m.gui.screen().setFocused(edit(m.gui.screen()));for(int tick=0;tick<12;tick++)m.gui.screen().tick();check(CommunityClient.pending(),"Old search is in flight");edit(m.gui.screen()).setValue("Gazette 12");});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==1&&!CommunityClient.pending(),300);
c.runOnClient(m->{check(text(m.gui.screen(),"Gazette 12"),"Latest search wins over prior pending request");check(edit(m.gui.screen()).isFocused(),"Search retains keyboard focus");});
c.takeScreenshot("search159-filtered");
c.runOnClient(m->edit(m.gui.screen()).setValue(""));
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==10&&!CommunityClient.pending(),300);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.server_tab"));
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==1&&!CommunityClient.pending(),300);
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.publish"),"Public server listing is read only");widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).map(CommunityCard.class::cast).findFirst().orElseThrow().onPress(enter());});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.back")&&!CommunityClient.pending(),300);
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.actions")&&!has(m.gui.screen(),"sanctuary.community.reply"),"Public bulletin has no write actions");check(text(m.gui.screen(),"Annonce officielle159"),"Server bulletin readable by ordinary player");});
c.takeScreenshot("search159-server-readonly");
world.getServer().runOnServer(server->{var inventory=server.getPlayerList().getPlayers().getFirst().getInventory();inventory.setItem(6,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.OAK_LOG,17));inventory.setItem(7,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.DIAMOND,3));});
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();CommunityClient.open(new PauseScreen(true),"notice","",true);edit(m.gui.screen()).setValue("Le coffre descriptif");body(m.gui.screen()).setValue("Du bois pour le pont");press(m.gui.screen(),"sanctuary.community.task_configure");press(m.gui.screen(),"sanctuary.community.location_current");press(m.gui.screen(),"sanctuary.community.materials");edit(m.gui.screen()).setValue("minecraft:oak_log");});
c.waitTicks(3);
c.runOnClient(m->{var screen=m.gui.screen();var slot=widgets(screen).filter(CommunityMaterialsScreen.Slot.class::isInstance).map(CommunityMaterialsScreen.Slot.class::cast).filter(w->w.getMessage().getString().startsWith("minecraft:oak_log")).findFirst().orElseThrow();slot.onPress(enter());var boxes=widgets(m.gui.screen()).filter(EditBox.class::isInstance).map(EditBox.class::cast).toList();boxes.getLast().setValue("0");check(!button(m.gui.screen(),"save").active,"Invalid quantity cannot be saved");boxes.getLast().setValue("128");check(button(m.gui.screen(),"save").active,"Valid quantity restores save");});
c.takeScreenshot("cards158-materials");
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);
c.runOnClient(m->{NotificationClient.options().pinnedAdvancements=true;press(m.gui.screen(),"sanctuary.community.follow");});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.unfollow")&&!CommunityClient.pending(),300);
c.runOnClient(m->check(NotificationClient.tracked().stream().anyMatch(t->t.title().getString().equals("Demande 0")),"Server-backed quest appears in HUD"));
c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(5);c.takeScreenshot("cards158-follow-hud");
c.runOnClient(m->{CommunityClient.open(new PauseScreen(true),"notice",noticeId,false);});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.map_show")&&!CommunityClient.pending(),300);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.map_show"));
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen a&&!a.questPins().isEmpty(),200);
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();var pin=a.questPins().getFirst();check(pin.marker().id().equals(noticeId),"Followed location appears on atlas");check(CommunityMapMarkers.project("minecraft:the_nether",x->100,z->100,0,0,300,300).isEmpty(),"Other dimension has no pin");
var tooltip=new StringBuilder();for(var line:CommunityMapMarkers.tooltip(pin.marker())){line.accept((index,style,point)->{tooltip.appendCodePoint(point);return true;});tooltip.append(' ');}
check(tooltip.toString().contains("Voisin")&&tooltip.toString().contains("128")&&tooltip.toString().contains("Un repas")&&tooltip.toString().contains("Rendez-vous"),"Hover includes author, summary, materials and reward");
hover(m,pin.x(),pin.y());
});
c.waitTicks(3);c.takeScreenshot("search159-map-hover"+(database?"-database":"-file"));
for(int scale:List.of(3,4)) {
c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();var pin=((SanctuaryAtlasScreen)m.gui.screen()).questPins().getFirst();hover(m,pin.x(),pin.y());});
c.waitTicks(3);c.takeScreenshot("search159-map-hover-gui"+scale);
}
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();});
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();var pin=a.questPins().getFirst();check(a.mouseClicked(new net.minecraft.client.input.MouseButtonEvent(pin.x(),pin.y(),new net.minecraft.client.input.MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_LEFT,0)),false),"Quest marker click handled");});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.unfollow")&&!CommunityClient.pending(),300);
world.getServer().runOnServer(server->server.getPlayerList().getPlayers().getFirst().teleportTo(20.5,65,20.5));
c.waitTicks(5);c.runOnClient(m->m.gui.setScreen(new PauseScreen(true)));c.waitTicks(20);
c.runOnClient(m->{var map=((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap();var pin=map.questPins().getFirst();check(pin.marker().id().equals(noticeId),"Pause map includes followed location");hover(m,pin.x(),pin.y());});
c.waitTicks(3);c.takeScreenshot("search159-pause-map-hover"+(database?"-database":"-file"));
c.runOnClient(m->{var map=((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap();var pin=map.questPins().getFirst();map.mouseClicked(new net.minecraft.client.input.MouseButtonEvent(pin.x(),pin.y(),new net.minecraft.client.input.MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_LEFT,0)),false);});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.unfollow")&&!CommunityClient.pending(),300);c.waitTicks(25);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.unfollow"));
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.follow")&&!CommunityClient.pending(),300);
c.runOnClient(m->check(CommunityMapMarkers.project("minecraft:overworld",x->100,z->100,0,0,300,300).isEmpty(),"Unfollowing removes the map marker immediately"));
c.runOnClient(m->m.gui.setScreen(null));
c.runOnClient(m->m.getConnection().sendCommand("sanctuary community admin"));c.waitTicks(5);
c.runOnClient(m->check(m.gui.screen()==null,"Non-operator cannot open administration"));
c.runOnClient(m->{m.getLanguageManager().setSelected("en_us");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new WorldOptionsScreen(null,m.level));});c.waitTicks(3);
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);});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==2&&!CommunityClient.pending(),250);
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.publish"),"Public bulletin remains read only for operators");widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).map(CommunityCard.class::cast).findFirst().orElseThrow().onPress(enter());});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.back")&&!CommunityClient.pending(),250);
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.actions"),"Operator uses administration to edit official messages");m.gui.setScreen(new PauseScreen(true));});
c.waitFor(m->CommunityClient.overview.has("bulletins")&&!CommunityClient.pending(),200);
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
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().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("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();}
}
@@ -16,6 +16,7 @@ import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Native pause regions, independently scrollable feeds, paginated posts and long conversations. */
public final class Pause155ClientChecks implements FabricClientGameTest {
static String photo() throws Exception {return CommunityPhoto.encode(new java.awt.image.BufferedImage(32,18,java.awt.image.BufferedImage.TYPE_INT_RGB),960,540);}
public void runTest(ClientGameTestContext c) {
boolean database=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_DATABASE"));
var intro=new AtomicBoolean(true);
@@ -41,7 +42,7 @@ public final class Pause155ClientChecks implements FabricClientGameTest {
var config=CommunityConfig.load(net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
CommunityStore store=database?new SqlCommunityStore(config.jdbcUrl(),config.user(),System.getenv(config.passwordEnv()),config.serverId()):new FileCommunityStore(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
var actor=new Community.Actor(p.getStringUUID(),"Koka",true);
for(String kind:List.of("article","notice")) for(int i=0;i<13;i++)store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"",kind,"work",(kind.equals("article")?"Gazette — ":"La passerelle — ")+String.format("%02d",i),"Rendez-vous sur la place du village.\n".repeat(60)+"FIN DU MESSAGE",0));
for(String kind:List.of("article","notice")) for(int i=0;i<13;i++)store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"",kind,"work",(kind.equals("article")?"Gazette — ":"La passerelle — ")+String.format("%02d",i),"Rendez-vous sur la place du village.\n".repeat(60)+"FIN DU MESSAGE",0,kind.equals("article")?photo():null));
store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"","bulletin","info","Bienvenue au village","Le marché ouvrira ce soir à 20 h.",0));
} catch(Exception e) {throw new AssertionError(e);}
});
@@ -53,13 +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(Math.abs(resume.getX()+resume.getWidth()/2-s.width/2)<=1 && resume.getBottom()==s.height-8,"Resume below central map");
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(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);
@@ -0,0 +1,84 @@
package fr.koka.sanctuary.client;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import fr.koka.sanctuary.community.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
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.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Screenshot selection and article round trip through actual client/server packets. */
public final class Photos157ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
boolean database=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_DATABASE"));
var intro=new AtomicBoolean(true);
var folder=new java.util.concurrent.atomic.AtomicReference<Path>();
try {
var path=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json");
var config=new CommunityConfig(database?"database":"file",UUID.randomUUID().toString(),database?System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC"):"jdbc:mariadb://localhost/sanctuary","root","SANCTUARY_COMMUNITY_DB_PASSWORD");
Files.createDirectories(path.getParent());Files.writeString(path,new com.google.gson.Gson().toJson(config));
} catch(Exception e){throw new AssertionError(e);}
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);folder.set(m.gameDirectory.toPath().resolve("screenshots"));});
try {
Files.createDirectories(folder.get());
var image=new java.awt.image.BufferedImage(1200,675,java.awt.image.BufferedImage.TYPE_INT_RGB);
var random=new Random(157);
for(int y=0;y<675;y++)for(int x=0;x<1200;x++)image.setRGB(x,y,((x/50)%2==0 && (y/50)%2==0)?0x2a6b52:random.nextInt());
for(int i=0;i<28;i++)javax.imageio.ImageIO.write(image,"png",folder.get().resolve(String.format("z-photo157-%02d.png",i)).toFile());
Files.writeString(folder.get().resolve("z-photo157-29.png"),"not a PNG");
}catch(Exception e){throw new AssertionError(e);}
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get() && m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Photos157 "+UUID.randomUUID());s.setSeed("157");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&&!IntroSpawnFlash.active(),1200);
world.getServer().runOnServer(s->s.getPlayerList().getPlayers().getFirst().getAbilities().invulnerable=true);
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{
m.getWindow().setWindowed(1280,960);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
CommunityClient.open(new PauseScreen(true),"article","",true);
check(!button(m.gui.screen(),"publish").active,"Photo is mandatory in the composer");
edit(m.gui.screen()).setValue("Une journée au village");body(m.gui.screen()).setValue("La photo rejoint la Gazette.");
press(m.gui.screen(),"sanctuary.community.photo_choose");
});
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
c.runOnClient(m->{
var s=m.gui.screen();check(s instanceof CommunityScreenshotsScreen,"Dedicated screenshot view");
var a=widgets(s).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow();
check(a.maxScrollAmount()>0,"Gallery scrolls");s.mouseScrolled(a.getX()+5,a.getY()+5,0,-4);check(a.scrollAmount()>0,"Wheel moves screenshots");a.setScrollAmount(0);
for(var child:s.children())if(child instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Gallery bounds "+w.getMessage());
});
c.takeScreenshot("photos157-gallery-"+locale+"-"+scale+(database?"-database":"-file"));
c.runOnClient(m->m.gui.screen().onClose());
c.runOnClient(m->{check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Cancel retains text");check(!button(m.gui.screen(),"publish").active,"Cancel leaves photo unassigned");});
}
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());press(m.gui.screen(),"sanctuary.community.photo_choose");});
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.next"));
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).count()>0&&!button(m.gui.screen(),"next").active,500);
c.runOnClient(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow().onPress(enter()));
c.takeScreenshot("photos157-preview");
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.photo_assign"));
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->check(widgets(m.gui.screen()).anyMatch(CommunityArticle.class::isInstance),"Published photo displayed"));
c.takeScreenshot("photos157-article"+(database?"-database":"-file"));
world.getServer().runOnServer(s->{try {
var config=CommunityConfig.load(net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
CommunityStore store=database?new SqlCommunityStore(config.jdbcUrl(),config.user(),"",config.serverId()):new FileCommunityStore(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
var posts=store.list("article","",0).entries();check(posts.size()==1,"Single publication");check(CommunityPhoto.validate(posts.getFirst().photo()).length()>8000,"Photo transported in multiple chunks and survives adapter restart");
}catch(Exception e){throw new AssertionError(e);}});
c.runOnClient(m->m.gui.setScreen(null));
System.out.println("PHOTOS157_CLIENT_PASS mode="+(database?"database":"file"));
} finally {intro.set(false);}
}
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();}
}
@@ -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);}
}
@@ -0,0 +1,40 @@
package fr.koka.sanctuary.client.shader;
import java.nio.file.Files;
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;
/** Exercise the real preference loader in the isolated native client. */
public final class ShaderDefaults160ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext context) {
context.runOnClient(client -> {
var path = FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");
try {
byte[] previous = Files.exists(path) ? Files.readAllBytes(path) : null;
var cache = VanillaLight.class.getDeclaredField("preferences");
cache.setAccessible(true);
try {
Files.deleteIfExists(path);
cache.set(null, null);
check(!VanillaLight.enabled(), "Fresh install disables shaders");
Files.createDirectories(path.getParent());
Files.writeString(path, "{}"); cache.set(null, null);
check(!VanillaLight.enabled(), "Missing setting uses OFF");
Files.writeString(path, "{\"enabled\":true,\"pbrIntensity\":73}"); cache.set(null, null);
check(VanillaLight.enabled() && VanillaLight.pbrIntensity() == 73, "Explicit ON and tuning retained");
VanillaLight.setEnabled(false); cache.set(null, null);
check(!VanillaLight.enabled() && VanillaLight.pbrIntensity() == 73, "OFF persists without losing tuning");
VanillaLight.setEnabled(true); cache.set(null, null);
check(VanillaLight.enabled(), "Manual reactivation persists");
System.out.println("SHADER160_DEFAULTS_PASS");
} finally {
if (previous == null) Files.deleteIfExists(path); else Files.write(path, previous);
cache.set(null, null);
}
} catch (Exception error) { throw new AssertionError(error); }
});
context.takeScreenshot("shader160-native-client");
}
private static void check(boolean value, String message) { if (!value) throw new AssertionError(message); }
}
@@ -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) {
@@ -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));
@@ -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");
@@ -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()));
@@ -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);
@@ -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();
@@ -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");
@@ -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));
@@ -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())));
@@ -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);
}
}
@@ -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();
@@ -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,
@@ -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");
@@ -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();
@@ -0,0 +1,40 @@
package fr.koka.sanctuary.client;
import com.google.gson.JsonObject;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
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 static fr.koka.sanctuary.client.CommunityClient.*;
/** Full-width hero photo, headline and description share one dark article container. */
final class CommunityArticle extends AbstractWidget implements AutoCloseable {
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")));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-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,imageHeight,fr.koka.sanctuary.community.CommunityPhoto.decode(Base64.getDecoder().decode(str(entry,"photo")),960,540),text("photo"),false,null);
}catch(Exception ignored){}
active=false;
}
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int mx,int my,float delta) {
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);
for(int i=0;i<title.size();i++){g.pose().pushMatrix();g.pose().translate(tx,top+i*15);g.pose().scale(titleScale,titleScale);g.text(font,title.get(i),0,0,0xfff4f0e8,false);g.pose().popMatrix();}
top+=title.size()*15+12;for(int i=0;i<body.size();i++)g.text(font,body.get(i),tx,top+i*11,0xffd7dce0,false);
}
@Override protected void updateWidgetNarration(NarrationElementOutput output){defaultButtonNarrationText(output);}
@Override public void close(){if(photo!=null){photo.close();photo=null;}}
}
@@ -0,0 +1,60 @@
package fr.koka.sanctuary.client;
import com.google.gson.JsonObject;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.narration.NarrationElementOutput;
import net.minecraft.client.input.InputWithModifiers;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.component.ResolvableProfile;
import static fr.koka.sanctuary.client.CommunityClient.*;
/** A single accessible card, with a face/title row and a category-coloured frame. */
final class CommunityCard extends AbstractButton implements AutoCloseable {
private final JsonObject entry;
private final Runnable action;
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->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"))));
setTooltip(Tooltip.create(getMessage().copy().append("\n").append(str(entry,"authorName")).append("\n").append(CommunityScreen.date(entry))));
if(article&&!compact) {
height=width*9/16+78;
try {
String thumbnail=str(entry,"thumbnail");
if(!thumbnail.isEmpty())photo=new CommunityPhotoWidget(width-12,width*9/16,fr.koka.sanctuary.community.CommunityPhoto.decode(Base64.getDecoder().decode(thumbnail),160,90),text("photo"),false,null);
}catch(Exception ignored){}
}
}
PlayerFaceWidget face(){return face;}
@Override public void onPress(InputWithModifiers input){action.run();}
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
int x=getX(),y=getY(),accent=article?0xffaebfae:color(str(entry,"category"));var font=Minecraft.getInstance().font;
g.fill(x,y,getRight(),getBottom(),article?0xff202720:background(str(entry,"category")));
g.fill(x,y,x+3,getBottom(),accent);
if(isHoveredOrFocused()){g.fill(x,y,getRight(),y+1,accent);g.fill(x,getBottom()-1,getRight(),getBottom(),accent);g.fill(getRight()-1,y,getRight(),getBottom(),accent);}
int top=y+6;
if(article&&!compact) {
if(photo!=null){photo.setPosition(x+6,top);photo.extractRenderState(g,mx,my,delta);}else g.text(font,text("photo_unavailable"),x+10,top+12,0xffaaaaaa,false);
top+=width*9/16+6;
}
face.setPosition(x+7,top);face.extractRenderState(g,mx,my,delta);
int tx=x+(compact?33:37),tw=width-(compact?40:44);
var lines=font.split(getMessage(),tw);
for(int i=0;i<Math.min(compact?1:2,lines.size());i++)g.text(font,lines.get(i),tx,top+(compact?6:i*10),0xfff4f0e8,false);
if(compact)g.text(font,font.plainSubstrByWidth(text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))).getString(),width-14),x+7,y+31,accent,false);
else {
g.text(font,font.plainSubstrByWidth(str(entry,"authorName"),width-14),x+7,top+30,0xffd6d4c9,false);
g.text(font,font.plainSubstrByWidth(CommunityScreen.date(entry),width-14),x+7,top+42,0xffa2aaa4,false);
if(!article)g.text(font,font.plainSubstrByWidth(text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))).getString(),width-14),x+7,top+59,accent,false);
}
}
@Override protected void updateWidgetNarration(NarrationElementOutput output){defaultButtonNarrationText(output);}
@Override public void close(){if(photo!=null){photo.close();photo=null;}}
}
@@ -14,28 +14,31 @@ public final class CommunityClient {
public static JsonObject overview = new JsonObject();
public static boolean stale = true;
public static String overviewError = "";
private static int sequence, pending, ticks;
private static int sequence, pending, ticks, followTicks;
private static Screen owner;
private static Consumer<JsonObject> callback;
private static boolean overviewRequest;
static final Map<String,Draft> DRAFTS = new HashMap<>();
static final class Draft { String operation = UUID.randomUUID().toString(), title = "", body = "", category = "info"; }
static final class Draft { String operation = UUID.randomUUID().toString(), title = "", body = "", category = "info", photo = ""; fr.koka.sanctuary.community.CommunityTask task=fr.koka.sanctuary.community.CommunityTask.EMPTY; }
private CommunityClient() { }
public static net.minecraft.network.chat.MutableComponent text(String key, Object... args) { return Component.translatable("sanctuary.community." + key,args); }
public static boolean available() { return ClientPlayNetworking.canSend(CommunityNetwork.Request.TYPE); }
public static boolean pending() { return pending != 0; }
public static void register() {
ClientPlayNetworking.registerGlobalReceiver(CommunityNetwork.Response.TYPE,(r,c) -> c.client().execute(() -> {
if(r.sequence()==0 && r.json().equals("{\"admin\":true}")){c.client().gui.setScreen(CommunityScreen.admin(null));return;}
if (r.sequence() != pending) return;
var result = JsonParser.parseString(r.json()).getAsJsonObject(); finish(result);
}));
ClientPlayConnectionEvents.DISCONNECT.register((h,c) -> { pending = 0; owner = null; callback = null; overview = new JsonObject(); stale = true; overviewError = ""; DRAFTS.clear(); });
ClientTickEvents.END_CLIENT_TICK.register(client -> {
if(++followTicks%100==0 && pending==0 && client.player!=null && (client.gui.screen()==null || client.gui.screen() instanceof SanctuaryAtlasScreen || client.gui.screen() instanceof PauseAtlasAccess) && available())request(null,"following","","","notice","info","","",0,0,r->{});
if (pending != 0 && ++ticks > 200) { var result = new JsonObject(); result.addProperty("error","unavailable"); finish(result); }
});
}
private static void finish(JsonObject result) {
pending = 0;
if(result.has("following")){NotificationClient.community(result.getAsJsonArray("following"));CommunityMapMarkers.accept(result.getAsJsonArray("following"));}
if (overviewRequest) {
if (!result.has("error")) { overview = result; stale = false; overviewError = ""; }
else { stale = true; overviewError = str(result,"error"); }
@@ -0,0 +1,84 @@
package fr.koka.sanctuary.client;
import com.google.gson.JsonArray;
import fr.koka.sanctuary.community.CommunityTask;
import java.util.*;
import java.util.function.DoubleToIntFunction;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.network.chat.Component;
/** Locations deliberately shared by followed notices; these never reveal terrain or generate chunks. */
final class CommunityMapMarkers {
record Marker(String id,String title,String author,String summary,CommunityTask task,int color) { }
record Pin(Marker marker,int x,int y) {
boolean contains(double mx,double my){return Math.abs(mx-x)<=7 && Math.abs(my-y)<=8;}
}
private static List<Marker> markers=List.of();
private CommunityMapMarkers() { }
static void clear(){markers=List.of();}
static void accept(JsonArray entries) {
var next=new ArrayList<Marker>();
for(var value:entries) {
var e=value.getAsJsonObject();
if(!CommunityClient.str(e,"status").equals("open") || !e.has("task") || e.get("task").isJsonNull())continue;
var task=new com.google.gson.Gson().fromJson(e.get("task"),CommunityTask.class);
if(task.location()!=null)next.add(new Marker(CommunityClient.str(e,"id"),CommunityClient.str(e,"title"),CommunityClient.str(e,"authorName"),CommunityClient.str(e,"summary"),task,CommunityCard.color(CommunityClient.str(e,"category"))));
}
markers=List.copyOf(next);
}
static List<Pin> project(String dimension,DoubleToIntFunction screenX,DoubleToIntFunction screenZ,int left,int top,int right,int bottom) {
var pins=new ArrayList<Pin>();
for(var marker:markers) {
var location=marker.task().location();if(!location.dimension().equals(dimension))continue;
int x=screenX.applyAsInt(location.x()+.5),y=screenZ.applyAsInt(location.z()+.5);
if(x>=left+7 && x<right-7 && y>=top+8 && y<bottom-8)pins.add(new Pin(marker,x,y));
}
return List.copyOf(pins);
}
static Pin at(List<Pin> pins,double x,double y){return pins.stream().filter(p->p.contains(x,y)).findFirst().orElse(null);}
static boolean render(GuiGraphicsExtractor g,List<Pin> pins,int mouseX,int mouseY) {
var font=Minecraft.getInstance().font;
for(var pin:pins) {
int x=pin.x(),y=pin.y();
g.fill(x-7,y-8,x+8,y+9,pin.contains(mouseX,mouseY)?0xfff4e7b4:pin.marker().color());
g.fill(x-6,y-7,x+7,y+8,0xff20251e);
g.fill(x-1,y-5,x+2,y+2,0xffffd35a);
g.fill(x-1,y+4,x+2,y+6,0xffffd35a);
}
var hovered=at(pins,mouseX,mouseY);
if(hovered==null)return false;
g.setTooltipForNextFrame(font,tooltip(hovered.marker()),mouseX,mouseY);
return true;
}
static List<net.minecraft.util.FormattedCharSequence> tooltip(Marker marker) {
var client=Minecraft.getInstance();var font=client.font;var task=marker.task();var location=task.location();
int width=Math.min(230,client.getWindow().getGuiScaledWidth()-28);
var lines=new ArrayList<net.minecraft.util.FormattedCharSequence>();
lines.addAll(font.split(Component.literal(marker.title()).withStyle(net.minecraft.ChatFormatting.BOLD),width).stream().limit(2).toList());
lines.add(Component.literal(font.plainSubstrByWidth(marker.author(),width)).getVisualOrderText());
lines.addAll(font.split(Component.literal(marker.summary()),width).stream().limit(3).toList());
lines.addAll(font.split(Component.literal("X "+location.x()+" Y "+location.y()+" Z "+location.z()),width));
if(!task.materials().isEmpty()) {
lines.add(CommunityClient.text("materials").getVisualOrderText());
itemLines(lines,task.materials(),width,2);
}
lines.addAll(font.split(CommunityClient.text("reward."+task.reward()),width));
if(task.reward().equals("items"))itemLines(lines,task.rewards(),width,2);
if(task.reward().equals("custom"))lines.addAll(font.split(Component.literal(task.customReward()),width).stream().limit(2).toList());
lines.addAll(font.split(CommunityClient.text("audience."+task.audience()),width));
int limit=Math.max(5,(client.getWindow().getGuiScaledHeight()-34)/font.lineHeight-2);
if(lines.size()>limit){lines=new ArrayList<>(lines.subList(0,limit-1));lines.add(Component.literal("…").getVisualOrderText());}
lines.addAll(font.split(CommunityClient.text("map_followed"),width));
return List.copyOf(lines);
}
private static void itemLines(List<net.minecraft.util.FormattedCharSequence> lines,List<CommunityTask.Material> items,int width,int limit) {
var font=Minecraft.getInstance().font;
for(var item:items.stream().limit(limit).toList()) {
var name=net.minecraft.core.registries.BuiltInRegistries.ITEM.getOptional(net.minecraft.resources.Identifier.parse(item.item()))
.map(i->i.getDefaultInstance().getHoverName()).orElse(Component.literal(item.item()));
lines.addAll(font.split(Component.literal(item.count()+" × ").append(name),width));
}
if(items.size()>limit)lines.addAll(font.split(CommunityClient.text("map_more_materials",items.size()-limit),width));
}
}
@@ -0,0 +1,90 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.community.CommunityTask.Material;
import java.util.*;
import java.util.function.Consumer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.InputWithModifiers;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemStack;
import static fr.koka.sanctuary.client.CommunityClient.text;
/** A virtual chest: selections are descriptors and never touch a player inventory. */
final class CommunityMaterialsScreen extends BlocodexFrame {
private final List<Material> selected;
private final Consumer<List<Material>> save;
private final Map<String,Slot> selectedSlots=new HashMap<>();
private String search="",chosen="";
private boolean searchChanged,quantityValid=true;
private Button saveButton;
private LinearLayout results;
private ScrollableLayout scroll;
CommunityMaterialsScreen(Screen parent,List<Material> items,Consumer<List<Material>> save){super(parent);selected=new ArrayList<>(items);this.save=save;}
@Override protected void init(){
quantityValid=true;selectedSlots.clear();initFrame("history",text("materials"));
int w=Math.min(400,contentWidth-24),x=(width-w)/2;
var layout=LinearLayout.vertical().spacing(5);
var query=layout.addChild(new EditBox(font,w,20,text("material_search")));query.setValue(search);
query.setResponder(value->{search=value;searchChanged=true;});
if(searchChanged){setInitialFocus(query);query.moveCursorToEnd(false);searchChanged=false;}
results=LinearLayout.vertical().spacing(2);
scroll=new ScrollableLayout(minecraft,results,Math.max(40,Math.min(100,(contentBottom-contentTop)/3)),ScrollableLayout.ReserveStrategy.RIGHT);
layout.addChild(scroll);refreshResults(w);
layout.addChild(new StringWidget(text("selected_materials",selected.size(),27),font));
for(int row=0;row<3;row++){
var slots=layout.addChild(LinearLayout.horizontal().spacing(2));
for(int col=0;col<9;col++){
int index=row*9+col;
Material item=index<selected.size()?selected.get(index):null;
var slot=slots.addChild(new Slot(item==null?"minecraft:air":item.item(),item==null?0:item.count(),()->{if(item!=null){chosen=item.item();rebuildWidgets();}}));if(item!=null)selectedSlots.put(item.item(),slot);
}
}
if(!chosen.isEmpty()){
var material=selected.stream().filter(i->i.item().equals(chosen)).findFirst().orElse(null);
if(material!=null){
var row=layout.addChild(LinearLayout.horizontal().spacing(4));
row.addChild(new StringWidget(text("quantity"),font));
var count=row.addChild(new EditBox(font,64,20,text("quantity")));count.setMaxLength(6);count.setValue(""+material.count());
count.setTooltip(Tooltip.create(Component.literal("1–999999")));count.setResponder(value->{quantityValid=value.matches("[0-9]{1,6}")&&Integer.parseInt(value)>0;if(saveButton!=null)saveButton.active=quantityValid;if(quantityValid){int index=selected.indexOf(selected.stream().filter(i->i.item().equals(chosen)).findFirst().orElseThrow());selected.set(index,new Material(chosen,Integer.parseInt(value)));var slot=selectedSlots.get(chosen);slot.count=Integer.parseInt(value);slot.setMessage(Component.literal(chosen+" × "+value));slot.setTooltip(Tooltip.create(slot.stack.isEmpty()?slot.getMessage():slot.stack.getHoverName().copy().append(" × "+value)));}});
row.addChild(Button.builder(text("remove_material"),b->{selected.removeIf(i->i.item().equals(chosen));chosen="";rebuildWidgets();}).width(110).build());
}
}
saveButton=layout.addChild(Button.builder(text("save"),b->{if(quantityValid){save.accept(List.copyOf(selected));onClose();}}).width(w).build());saveButton.active=quantityValid;
var outer=new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop),ScrollableLayout.ReserveStrategy.RIGHT);outer.setPosition(x,contentTop);outer.arrangeElements();outer.visitWidgets(this::addRenderableWidget);
}
@Override public void tick(){if(searchChanged)rebuildWidgets();}
private void refreshResults(int w){
if(results==null)return;
String query=search.toLowerCase(Locale.ROOT);int count=0;LinearLayout row=null;
for(var item:BuiltInRegistries.ITEM){
String id=BuiltInRegistries.ITEM.getKey(item).toString();
if(id.length()>100||id.equals("minecraft:air")||!(id.contains(query)||item.getDefaultInstance().getHoverName().getString().toLowerCase(Locale.ROOT).contains(query)))continue;
if(count%9==0)row=results.addChild(LinearLayout.horizontal().spacing(2));
row.addChild(new Slot(id,1,()->{if(selected.stream().noneMatch(i->i.item().equals(id))&&selected.size()<27)selected.add(new Material(id,1));chosen=id;rebuildWidgets();}));
if(++count>=180)break;
}
if(count==0)results.addChild(new StringWidget(text("empty"),font));
scroll.arrangeElements();
}
static void showItems(LinearLayout layout,int width,List<Material> items){
int columns=Math.max(1,Math.min(9,width/24));LinearLayout row=null;int index=0;
for(var item:items){if(index++%columns==0)row=layout.addChild(LinearLayout.horizontal().spacing(2));row.addChild(new Slot(item.item(),item.count(),null));}
}
static final class Slot extends AbstractButton {
private final ItemStack stack;private int count;private final Runnable action;
Slot(String id,int count,Runnable action){super(0,0,22,22,Component.literal(id+" × "+count));this.count=count;this.action=action;stack=BuiltInRegistries.ITEM.getOptional(Identifier.parse(id)).map(i->i.getDefaultInstance()).orElse(ItemStack.EMPTY);setTooltip(Tooltip.create(stack.isEmpty()?getMessage():stack.getHoverName().copy().append(" × "+count)));}
@Override public void onPress(InputWithModifiers input){if(action!=null)action.run();}
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta){
var font=Minecraft.getInstance().font;int x=getX(),y=getY();g.fill(x,y,x+22,y+22,isHoveredOrFocused()?0xff536957:0xff20242b);
if(!stack.isEmpty()){g.item(stack,x+3,y+2);if(count>1){String label=count>999?"999+":""+count;g.text(font,label,x+22-font.width(label),y+14,0xffffffff,true);}}
}
@Override protected void updateWidgetNarration(net.minecraft.client.gui.narration.NarrationElementOutput out){defaultButtonNarrationText(out);}
}
@Override public boolean isPauseScreen(){return false;}
}
@@ -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,66 +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"),()->{
var list=items("bulletins");open(screen,"bulletin",list.isEmpty()?"":str(list.get(0).getAsJsonObject(),"id"),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",(screen.width-Math.min(180,centerWidth))/2,screen.height-28,Math.min(180,centerWidth));
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",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")))));
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();
@@ -0,0 +1,43 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.NativeImage;
import java.awt.image.BufferedImage;
import java.util.UUID;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.AbstractButton;
import net.minecraft.client.gui.narration.NarrationElementOutput;
import net.minecraft.client.input.InputWithModifiers;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
/** A locally owned texture; never installed under a user-controlled resource identifier. */
final class CommunityPhotoWidget extends AbstractButton implements AutoCloseable {
private final Identifier texture=Identifier.fromNamespaceAndPath("sanctuary","community/"+UUID.randomUUID());
private final int imageWidth,imageHeight;
private final Runnable action;
private final boolean caption;
private boolean closed;
CommunityPhotoWidget(int width,int height,BufferedImage image,Component label,boolean caption,Runnable action) {
super(0,0,width,height,label);this.action=action;this.caption=caption;
imageWidth=image.getWidth();imageHeight=image.getHeight();
var nativeImage=new NativeImage(imageWidth,imageHeight,false);
for(int y=0;y<imageHeight;y++)for(int x=0;x<imageWidth;x++)nativeImage.setPixel(x,y,0xff000000|image.getRGB(x,y));
Minecraft.getInstance().getTextureManager().register(texture,new DynamicTexture(()->"Gazette photo",nativeImage));
active=action!=null;
}
@Override public void onPress(InputWithModifiers input) {if(action!=null)action.run();}
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
if(closed)return;
int x=getX(),y=getY(),available=height-(caption?18:0);
g.fill(x,y,x+width,y+height,isHoveredOrFocused()?0xff536957:0xff1b2028);
double scale=Math.min((double)(width-4)/imageWidth,(double)(available-4)/imageHeight);
int w=(int)(imageWidth*scale),h=(int)(imageHeight*scale);
g.blit(RenderPipelines.GUI_TEXTURED,texture,x+(width-w)/2,y+(available-h)/2,0,0,w,h,imageWidth,imageHeight,imageWidth,imageHeight);
if(caption)g.text(Minecraft.getInstance().font,Minecraft.getInstance().font.plainSubstrByWidth(getMessage().getString(),width-10),x+5,y+height-13,0xffeeeeee,false);
}
@Override protected void updateWidgetNarration(NarrationElementOutput output) {defaultButtonNarrationText(output);}
@Override public void close() {if(!closed){closed=true;Minecraft.getInstance().getTextureManager().release(texture);}}
}
@@ -9,23 +9,53 @@ import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import static fr.koka.sanctuary.client.CommunityClient.*;
/** Native text-only pages: user content is always a literal Minecraft component. */
/** Native community pages: user content is always a literal Minecraft component. */
public final class CommunityScreen extends BlocodexFrame {
private final String kind;
private final boolean admin;
private String search="";
private int searchAfter;
private boolean rebuildSearch;
private EditBox searchBox;
private String id, editingId = "", message = "", expandedId = "";
private int page, revision;
private boolean composing, needsLoad = true, moderator;
private JsonObject data = new JsonObject();
private Draft draft;
private int ticks;
private final java.util.List<CommunityPhotoWidget> photos=new java.util.ArrayList<>();
private final java.util.List<AutoCloseable> cards=new java.util.ArrayList<>();
private final java.util.List<JsonObject> entries=new java.util.ArrayList<>();
private boolean more,taskSent;
private ScrollableLayout contentScroll;
private double restoreScroll;
private boolean publishing;
private int photoPart, sendAfter;
private String outgoingPhoto="";
private Runnable commit;
private void closePhotos() {photos.forEach(CommunityPhotoWidget::close);photos.clear();cards.forEach(c->{try{c.close();}catch(Exception ignored){}});cards.clear();}
@Override public void removed() {closePhotos();publishing=false;commit=null;}
public CommunityScreen(Screen parent,String kind,String id,boolean compose) {
super(parent); this.kind = kind; this.id = id; this.composing = compose;
this(parent,kind,id,compose,false);
}
static CommunityScreen admin(Screen parent){return new CommunityScreen(parent,"bulletin","",false,true);}
private CommunityScreen(Screen parent,String kind,String id,boolean compose,boolean admin) {
super(parent); this.kind = kind; this.admin=admin; this.id = id; this.composing = compose && (!kind.equals("bulletin") || admin);
moderator = flag(overview,"moderator");
if (compose) useDraft("new:" + kind);
}
private void useDraft(String key) { draft = DRAFTS.computeIfAbsent(key, unused -> new Draft()); }
@Override protected void rebuildWidgets() {
boolean restoreFocus=searchBox!=null && searchBox.isFocused() && !composing && id.isEmpty();
int cursor=restoreFocus?searchBox.getCursorPosition():0;
super.rebuildWidgets();
if(restoreFocus && searchBox!=null){setInitialFocus(searchBox);searchBox.moveCursorTo(cursor,false);}
}
@Override protected void init() {
initFrame("history",text(kind)); contentWidth=Math.min(520,contentWidth); contentX=(width-contentWidth)/2; int w = contentWidth - 28;
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"));
@@ -34,51 +64,108 @@ public final class CommunityScreen extends BlocodexFrame {
else listing(layout,w);
var scroll = new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop),ScrollableLayout.ReserveStrategy.RIGHT);
scroll.setPosition(contentX+8,contentTop); scroll.arrangeElements(); scroll.visitWidgets(this::addRenderableWidget);
contentScroll=scroll;if(restoreScroll>0){scroll.visitChildren(child->{if(child instanceof net.minecraft.client.gui.components.AbstractScrollArea area)area.setScrollAmount(restoreScroll);});restoreScroll=0;}
}
private void listing(LinearLayout layout,int w) {
var tabs = layout.addChild(LinearLayout.horizontal().spacing(4));
for (String target : new String[]{"article","notice","bulletin"}) tabs.addChild(Button.builder(text(target), b -> open(parent,target,"",false)).width((w-8)/3).build());
var toolbar=layout.addChild(LinearLayout.horizontal().spacing(4));
if (!kind.equals("bulletin") || moderator) button(toolbar,(w-8)/3,"publish",() -> { composing = true; useDraft("new:"+kind); rebuildWidgets(); });
button(toolbar,(w-8)/3,"refresh",() -> needsLoad = true);
button(toolbar,(w-8)/3,"link",() -> minecraft.gui.setScreen(new LinkScreen(this)));
if (!data.has("page")) { line(layout,w,text("loading")); return; }
var listing = data.getAsJsonObject("page");
if (listing.getAsJsonArray("entries").isEmpty()) line(layout,w,text("empty"));
for (var item : listing.getAsJsonArray("entries")) {
var entry = item.getAsJsonObject();
layout.addChild(Button.builder(Component.literal((flag(entry,"pinned") ? "★ " : "") + str(entry,"title")), b -> { id=str(entry,"id"); page=0; needsLoad=true; }).width(w).build());
author(layout,w,entry,false);
if (kind.equals("notice")) line(layout,w,text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))));
var tabs=layout.addChild(LinearLayout.horizontal().spacing(6));
if(!admin)for(String target:new String[]{"article","notice","bulletin"})tabs.addChild(Button.builder(text(target.equals("bulletin")?"server_tab":target.equals("notice")?"notice_tab":target),b->open(parent,target,"",false)).width((w-12)/3).build());
searchBox=layout.addChild(new EditBox(font,w,20,text("search")));
searchBox.setMaxLength(120);searchBox.setHint(text("search"));searchBox.setValue(search);
searchBox.setResponder(value->{search=value;page=0;more=false;entries.clear();restoreScroll=0;needsLoad=true;searchAfter=ticks+6;rebuildSearch=true;});
var toolbar=layout.addChild(LinearLayout.horizontal().spacing(6));
if(!kind.equals("bulletin") || admin)button(toolbar,(w-6)/2,"publish",()->{composing=true;useDraft("new:"+kind);rebuildWidgets();});
button(toolbar,(w-6)/2,"refresh",()->{page=0;needsLoad=true;});
if(entries.isEmpty())line(layout,w,text(needsLoad?"loading":"empty"));
int cardWidth=(w-8)/2;
for(int i=0;i<entries.size();i+=2) {
var row=layout.addChild(LinearLayout.horizontal().spacing(8));
for(int j=i;j<Math.min(i+2,entries.size());j++) {
var entry=entries.get(j);
var card=new CommunityCard(cardWidth,entry,false,()->{id=str(entry,"id");page=0;data=new JsonObject();needsLoad=true;});
cards.add(card);row.addChild(card);
}
}
pages(layout,w,listing);
if(more)button(layout,w,"more",()->{restoreScroll=contentScroll.getScrollAmount();page++;needsLoad=true;});
}
private void detail(LinearLayout layout,int w,JsonObject thread) {
var entry = thread.getAsJsonObject("entry");
var toolbar=layout.addChild(LinearLayout.horizontal().spacing(5));
button(toolbar,90,"back",() -> { id=""; page=0; data=new JsonObject(); needsLoad=true; rebuildWidgets(); });
button(toolbar,90,"refresh",() -> needsLoad=true);
line(layout,w,Component.literal(str(entry,"title")).withStyle(net.minecraft.ChatFormatting.BOLD));
message(layout,w,entry);
if(kind.equals("article")) {
var article=new CommunityArticle(w,entry);cards.add(article);layout.addChild(article);
articleAuthor(layout,w,entry);
if(expandedId.equals(id))controls(layout,w,entry);
} else {
line(layout,w,Component.literal(str(entry,"title")).withStyle(net.minecraft.ChatFormatting.BOLD));message(layout,w,entry);
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) {
var row=layout.addChild(LinearLayout.horizontal().spacing(6));
row.addChild(new PlayerFaceWidget(24,net.minecraft.world.item.component.ResolvableProfile.createUnresolved(java.util.UUID.fromString(str(entry,"authorId")))));
String when=date(entry),name=font.plainSubstrByWidth(str(entry,"authorName"),Math.max(20,w-76-font.width(when)));
var metadata=Component.literal(name+" · "+when);
row.addChild(new StringWidget(Math.min(w-58,font.width(metadata)),24,metadata,font));
if(own(entry)||moderator)row.addChild(Button.builder(text("actions"),b->{expandedId=expandedId.equals(id)?"":id;rebuildWidgets();}).size(20,24).tooltip(Tooltip.create(text("actions_for",str(entry,"authorName")))).build());
}
private void author(LinearLayout layout,int w,JsonObject entry,boolean actions) {
var row=layout.addChild(LinearLayout.horizontal().spacing(6));
row.addChild(new PlayerFaceWidget(24,net.minecraft.world.item.component.ResolvableProfile.createUnresolved(java.util.UUID.fromString(str(entry,"authorId")))));
@@ -90,16 +177,17 @@ public final class CommunityScreen extends BlocodexFrame {
}).size(20,18).tooltip(Tooltip.create(text("actions_for",str(entry,"authorName")))).build());
}
private void message(LinearLayout layout,int w,JsonObject entry) {
author(layout,w,entry,true);
author(layout,w,entry,!kind.equals("bulletin") || admin);
layout.addChild(new CommunityBubble(w,str(entry,"body"),own(entry)));
if(expandedId.equals(str(entry,"id"))) controls(layout,w,entry);
}
private void controls(LinearLayout layout,int w,JsonObject entry) {
if(kind.equals("bulletin") && !admin)return;
boolean own = own(entry);
var row=layout.addChild(LinearLayout.horizontal().spacing(4)); int size=Math.min(100,(w-12)/4);
if (own) button(row,size,"edit",() -> {
editingId=str(entry,"id"); revision=number(entry,"revision"); composing=true; useDraft("edit:"+editingId);
if (draft.body.isEmpty()) { draft.title=str(entry,"title"); draft.body=str(entry,"body"); draft.category=str(entry,"category"); }
if (draft.body.isEmpty()) { draft.title=str(entry,"title"); draft.body=str(entry,"body"); draft.category=str(entry,"category"); draft.photo=str(entry,"photo"); draft.task=readTask(entry); }
rebuildWidgets();
});
if (own || moderator) button(row,size,"hide",() -> minecraft.gui.setScreen(new net.minecraft.client.gui.screens.ConfirmScreen(
@@ -114,26 +202,80 @@ public final class CommunityScreen extends BlocodexFrame {
if (!reply) {
line(layout,w,text("title_label")); var titleBox=layout.addChild(new EditBox(font,w,20,text("title_label")));
titleBox.setMaxLength(120); titleBox.setValue(draft.title); titleBox.setResponder(value -> draft.title=value);
if (kind.equals("notice") && editingId.isEmpty()) button(layout,w,"category."+draft.category,() -> {
if (kind.equals("notice")) button(layout,w,"category."+draft.category,() -> {
String[] categories={"info","work","need","event"}; int i=java.util.Arrays.asList(categories).indexOf(draft.category);
draft.category=categories[(i+1)%categories.length]; rebuildWidgets();
});
}
if(!reply && kind.equals("article")) {
photo(layout,w,draft.photo);
button(layout,w,draft.photo.isEmpty()?"photo_choose":"photo_change",()->minecraft.gui.setScreen(new CommunityScreenshotsScreen(this,value->draft.photo=value)));
if(draft.photo.isEmpty())line(layout,w,text("photo_required"));
}
if(!reply && kind.equals("notice")) {
button(layout,w,"task_configure",()->minecraft.gui.setScreen(new CommunityTaskScreen(this,draft.task,value->draft.task=value)));
line(layout,w,text("task_summary",draft.task.materials().size(),text("audience."+draft.task.audience())));
}
line(layout,w,text("body_label"));
var bodyBox=layout.addChild(MultiLineEditBox.builder().setPlaceholder(text("body_hint")).build(font,w,Math.max(60,Math.min(150,height/3)),text("body_label")));
bodyBox.setCharacterLimit(reply?1000:4000); bodyBox.setValue(draft.body); bodyBox.setValueListener(value -> draft.body=value);
button(layout,110,editingId.isEmpty()?"publish":"save",() -> {
var publish=layout.addChild(Button.builder(text(publishing?"photo_uploading":editingId.isEmpty()?"publish":"save"),b -> {
if(pending() || publishing)return;
String action=editingId.isEmpty()?"create":"edit";
request(this,action,editingId.isEmpty()?draft.operation:editingId,reply?id:"",reply?"reply":kind,draft.category,
commit=()->request(this,action,editingId.isEmpty()?draft.operation:editingId,reply?id:"",reply?"reply":kind,draft.category,
reply?"":draft.title,draft.body,revision,0,result -> {
publishing=false;commit=null;
if (result.has("error")) { message=str(result,"error"); rebuildWidgets(); return; }
String saved=str(result.getAsJsonObject("result"),"id");
DRAFTS.values().removeIf(value -> value==draft); composing=false; editingId="";
if (!reply) id=saved; page=0; needsLoad=true; message="saved"; stale=true; rebuildWidgets();
});
});
if(!reply && (kind.equals("article")||kind.equals("notice"))) {outgoingPhoto=kind.equals("article")?draft.photo:"";photoPart=0;taskSent=!kind.equals("notice");publishing=true;sendAfter=ticks+4;rebuildWidgets();}
else {var actionToRun=commit;commit=null;actionToRun.run();}
}).width(150).build());
publish.active=!publishing && (reply || !kind.equals("article") || !draft.photo.isEmpty());
button(layout,Math.min(w,220),"back_draft",() -> { composing=false; editingId=""; needsLoad=true; rebuildWidgets(); });
}
private void photo(LinearLayout layout,int w,String value) {
if(value.isEmpty())return;
try {
var image=fr.koka.sanctuary.community.CommunityPhoto.decode(java.util.Base64.getDecoder().decode(value),960,540);
var widget=new CommunityPhotoWidget(Math.min(w,160),Math.max(24,Math.min(72,160*image.getHeight()/image.getWidth())),image,text("photo"),false,null);
photos.add(widget);layout.addChild(widget);
} catch(Exception e) {line(layout,w,text("photo_invalid"));}
}
private static fr.koka.sanctuary.community.CommunityTask readTask(JsonObject entry) {
return entry.has("task")&&!entry.get("task").isJsonNull()?new Gson().fromJson(entry.get("task"),fr.koka.sanctuary.community.CommunityTask.class):fr.koka.sanctuary.community.CommunityTask.EMPTY;
}
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());
map.active=minecraft.level!=null && task.location().dimension().equals(minecraft.level.dimension().identifier().toString());
if(!map.active)map.setTooltip(Tooltip.create(text("map_other_dimension")));
}
if(!task.materials().isEmpty()) {line(layout,w,text("materials"));CommunityMaterialsScreen.showItems(layout,w,task.materials());}
line(layout,w,text("audience."+task.audience()));line(layout,w,text("reward."+task.reward()));
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")));
rebuildWidgets();
})).width(w).build());
follow.active=subscribed || (!own(entry)&&str(entry,"status").equals("open")&&(!task.audience().equals("single")||count==0));
}
private void mutate(String action,JsonObject entry) {
request(this,action,str(entry,"id"),"",str(entry,"kind"),str(entry,"category"),"","",number(entry,"revision"),0,result -> {
message=result.has("error")?str(result,"error"):"saved";
@@ -151,28 +293,41 @@ public final class CommunityScreen extends BlocodexFrame {
}
private void line(LinearLayout layout,int w,Component value) { layout.addChild(new MultiLineTextWidget(value,font).setMaxWidth(w)); }
private void button(LinearLayout layout,int w,String key,Runnable action) {
layout.addChild(Button.builder(text(key),b -> { if (!pending()) action.run(); }).width(w).build());
layout.addChild(Button.builder(text(key),b -> { if (!publishing) action.run(); }).width(w).build());
}
private static String date(JsonObject e) { return DateTimeFormatter.ofPattern("dd/MM HH:mm 'UTC'").withZone(ZoneOffset.UTC).format(Instant.ofEpochMilli(e.get("createdAt").getAsLong())); }
static String date(JsonObject e) { return DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm 'UTC'").withZone(ZoneOffset.UTC).format(Instant.ofEpochMilli(e.get("createdAt").getAsLong())); }
@Override public void tick() {
super.tick(); ticks++;
if (!composing && needsLoad && ticks%4==0 && request(this,id.isEmpty()?"list":"thread",id,"",kind,"info","","",0,page,result -> {
if(rebuildSearch){rebuildSearch=false;rebuildWidgets();}
if(publishing && !pending() && ticks>=sendAfter) {
if(!taskSent) {
request(this,"task",editingId.isEmpty()?draft.operation:editingId,"","notice","info","",new GsonBuilder().disableHtmlEscaping().create().toJson(draft.task),0,0,result->{
if(result.has("error")){publishing=false;commit=null;message=str(result,"error");rebuildWidgets();}
else {taskSent=true;sendAfter=ticks+4;}
});return;
}
int parts=(outgoingPhoto.length()+7999)/8000;
if(photoPart<parts) {
int part=photoPart;
request(this,"photo",editingId.isEmpty()?draft.operation:editingId,"","article","info","",outgoingPhoto.substring(part*8000,Math.min(outgoingPhoto.length(),(part+1)*8000)),parts,part,result->{
if(result.has("error")){publishing=false;commit=null;message=str(result,"error");rebuildWidgets();}
else {photoPart++;sendAfter=ticks+4;}
});
} else if(commit!=null) {var actionToRun=commit;commit=null;actionToRun.run();}
}
String requestedSearch=search,requestedId=id;int requestedPage=page;
if (!composing && needsLoad && ticks>=searchAfter && ticks%4==0 && request(this,id.isEmpty()?(kind.equals("bulletin")?"list":"cards"):"thread",id,"",kind,"info","",id.isEmpty()?search:"",0,page,result -> {
if(!search.equals(requestedSearch) || !id.equals(requestedId) || page!=requestedPage || composing)return;
if (result.has("error")) message=str(result,"error");
else { data=result; moderator=flag(result,"moderator"); }
else {data=result;moderator=flag(result,"moderator");
if(id.isEmpty()&&result.has("page")) {
if(page==0)entries.clear();
for(var entry:result.getAsJsonObject("page").getAsJsonArray("entries"))if(entries.stream().noneMatch(e->str(e,"id").equals(str(entry.getAsJsonObject(),"id"))))entries.add(entry.getAsJsonObject());
more=flag(result.getAsJsonObject("page"),"more");
}
}
rebuildWidgets();
})) needsLoad=false;
}
@Override public boolean isPauseScreen() { return false; }
private static final class LinkScreen extends BlocodexFrame {
String code="", message="link_help";
LinkScreen(Screen parent) { super(parent); }
@Override protected void init() {
initFrame("history",text("link")); int w=contentWidth-28; var layout=LinearLayout.vertical().spacing(8);
layout.addChild(new MultiLineTextWidget(text(message),font).setMaxWidth(w));
var box=layout.addChild(new EditBox(font,w,20,text("code"))); box.setMaxLength(32); box.setValue(code); box.setResponder(value -> code=value);
layout.addChild(Button.builder(text("link"),b -> request(this,"link","","","article","info","",code,0,0,r -> { message=r.has("error")?str(r,"error"):"linked"; rebuildWidgets(); })).width(w).build());
layout.setPosition(contentX+8,contentTop); layout.arrangeElements(); layout.visitWidgets(this::addRenderableWidget);
}
@Override public boolean isPauseScreen() { return false; }
}
}
@@ -0,0 +1,110 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.community.CommunityPhoto;
import java.awt.image.BufferedImage;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Consumer;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import static fr.koka.sanctuary.client.CommunityClient.text;
/** Read-only screenshot library, paged and scrollable; no disk access or source-image decoding on the render thread. */
public final class CommunityScreenshotsScreen extends BlocodexFrame {
private static final int PAGE_SIZE=24;
private static final ExecutorService LOADER=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"Sanctuary screenshots");t.setDaemon(true);return t;});
private record Shot(String name,String photo,BufferedImage thumbnail) { }
private record Library(List<Path> paths,List<Shot> shots,String error) { }
private final Consumer<String> assign;
private final List<CommunityPhotoWidget> textures=new ArrayList<>();
private List<Path> paths=List.of();
private List<Shot> shots=List.of();
private CompletableFuture<Library> loading;
private int page;
private boolean loaded;
private String error="";
private Shot selected;
public CommunityScreenshotsScreen(Screen parent,Consumer<String> assign) {super(parent);this.assign=assign;}
private void load(boolean refresh) {
loaded=true;
Path folder=minecraft.gameDirectory.toPath().resolve("screenshots");
List<Path> known=paths;int offset=page*PAGE_SIZE;
loading=CompletableFuture.supplyAsync(()->{
try {
List<Path> all=known;
if(refresh) {
if(!Files.isDirectory(folder))return new Library(List.of(),List.of(),"");
try(var files=Files.list(folder)) {
all=files.filter(p->p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".png")&&Files.isRegularFile(p,LinkOption.NOFOLLOW_LINKS))
.sorted(Comparator.comparing((Path p)->p.getFileName().toString()).reversed()).limit(4096).toList();
}
}
var found=new ArrayList<Shot>();
for(Path file:all.stream().skip(offset).limit(PAGE_SIZE).toList()) {
try {
if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS) || Files.size(file)>32*1024*1024)throw new java.io.IOException("Screenshot size");
byte[] bytes;
try(var in=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)) {bytes=in.readNBytes(32*1024*1024+1);}
if(bytes.length>32*1024*1024)throw new java.io.IOException("Screenshot size");
var source=CommunityPhoto.decode(bytes,8192,8192);
String photo=CommunityPhoto.encode(source,CommunityPhoto.WIDTH,CommunityPhoto.HEIGHT);
String thumb=CommunityPhoto.encode(source,240,135);
found.add(new Shot(file.getFileName().toString(),photo,CommunityPhoto.decode(Base64.getDecoder().decode(thumb),240,135)));
} catch(Exception e) {found.add(new Shot(file.getFileName().toString(),"",null));}
}
return new Library(all,found,"");
} catch(Exception e) {return new Library(List.of(),List.of(),"screenshots_error");}
},LOADER);
}
@Override protected void init() {
closeTextures();initFrame("history",text("screenshots"));
contentWidth=Math.max(240,width-24);contentX=(width-contentWidth)/2;
if(!loaded)load(true);
if(selected!=null) {preview();return;}
int w=contentWidth-24;
var controls=LinearLayout.horizontal().spacing(4);
controls.addChild(Button.builder(text("previous"),b->{page--;load(false);rebuildWidgets();}).width(72).build()).active=page>0 && loading==null;
controls.addChild(new StringWidget(Math.max(30,w-228),20,text("page",page+1),font));
controls.addChild(Button.builder(text("next"),b->{page++;load(false);rebuildWidgets();}).width(72).build()).active=(page+1)*PAGE_SIZE<paths.size() && loading==null;
controls.addChild(Button.builder(text("refresh"),b->{page=0;load(true);rebuildWidgets();}).width(76).build()).active=loading==null;
controls.setPosition(contentX+8,contentTop+Math.max(24,contentBottom-contentTop-28)+8);controls.arrangeElements();controls.visitWidgets(this::addRenderableWidget);
var layout=LinearLayout.vertical().spacing(8);
if(loading!=null)layout.addChild(new StringWidget(text("loading"),font));
else if(!error.isEmpty() || shots.isEmpty())layout.addChild(new MultiLineTextWidget(text(error.isEmpty()?"screenshots_empty":error),font).setMaxWidth(w));
else {
int columns=Math.max(2,w/112),cell=(w-(columns-1)*8)/columns;
for(int i=0;i<shots.size();i+=columns) {
var row=layout.addChild(LinearLayout.horizontal().spacing(8));
for(int j=i;j<Math.min(i+columns,shots.size());j++) {
Shot shot=shots.get(j);
if(shot.thumbnail()==null)row.addChild(Button.builder(Component.literal(shot.name()),b->{}).size(cell,cell*9/16+18).tooltip(Tooltip.create(text("photo_invalid"))).build()).active=false;
else {var widget=new CommunityPhotoWidget(cell,cell*9/16+18,shot.thumbnail(),Component.literal(shot.name()),true,()->{selected=shot;rebuildWidgets();});textures.add(widget);row.addChild(widget);}
}
}
}
var scroll=new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop-28),ScrollableLayout.ReserveStrategy.RIGHT);
scroll.setPosition(contentX+8,contentTop);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
}
private void preview() {
int w=contentWidth-16,h=Math.max(24,contentBottom-contentTop-34);
int left=(width-w)/2;
try {
var image=CommunityPhoto.decode(Base64.getDecoder().decode(selected.photo()),CommunityPhoto.WIDTH,CommunityPhoto.HEIGHT);
var widget=new CommunityPhotoWidget(w,h,image,Component.literal(selected.name()),false,null);
textures.add(widget);widget.setPosition(left,contentTop);addRenderableWidget(widget);
} catch(Exception e) {error="photo_invalid";selected=null;return;}
addRenderableWidget(Button.builder(text("screenshots_back"),b->{selected=null;rebuildWidgets();}).bounds(left,contentTop+h+8,(w-6)/2,20).build());
addRenderableWidget(Button.builder(text("photo_assign"),b->{assign.accept(selected.photo());minecraft.gui.setScreen(parent);}).bounds(left+(w+6)/2,contentTop+h+8,(w-6)/2,20).build());
}
@Override public void tick() {
if(loading!=null && loading.isDone()) {
var result=loading.join();loading=null;paths=result.paths();shots=result.shots();error=result.error();rebuildWidgets();
}
}
private void closeTextures() {textures.forEach(CommunityPhotoWidget::close);textures.clear();}
@Override public void removed() {closeTextures();}
@Override public boolean isPauseScreen() {return false;}
}
@@ -0,0 +1,38 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.community.CommunityTask;
import java.util.*;
import java.util.function.Consumer;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.screens.Screen;
import static fr.koka.sanctuary.client.CommunityClient.text;
final class CommunityTaskScreen extends BlocodexFrame {
private final Consumer<CommunityTask> save;
private CommunityTask.Location location;
private List<CommunityTask.Material> materials,rewards;
private String audience,reward,custom,error="";
CommunityTaskScreen(Screen parent,CommunityTask task,Consumer<CommunityTask> save){super(parent);this.save=save;location=task.location();materials=task.materials();rewards=task.rewards();audience=task.audience();reward=task.reward();custom=task.customReward();}
@Override protected void init(){
initFrame("history",text("task_configure"));int w=Math.min(400,contentWidth-24);
var layout=LinearLayout.vertical().spacing(8);
if(!error.isEmpty())layout.addChild(new MultiLineTextWidget(text(error),font).setMaxWidth(w));
layout.addChild(new MultiLineTextWidget(text("exchange_help"),font).setMaxWidth(w));
button(layout,w,"location_current",()->{if(minecraft.player!=null){var p=minecraft.player.blockPosition();location=new CommunityTask.Location(minecraft.player.level().dimension().identifier().toString(),p.getX(),p.getY(),p.getZ());rebuildWidgets();}});
if(location!=null){
layout.addChild(new MultiLineTextWidget(text("location_value",location.dimension(),location.x(),location.y(),location.z()),font).setMaxWidth(w));
button(layout,w,"location_remove",()->{location=null;rebuildWidgets();});
}
button(layout,w,"materials",()->minecraft.gui.setScreen(new CommunityMaterialsScreen(this,materials,value->materials=value)));
CommunityMaterialsScreen.showItems(layout,w,materials);
button(layout,w,"audience."+audience,()->{audience=audience.equals("shared")?"single":"shared";rebuildWidgets();});
button(layout,w,"reward."+reward,()->{reward=switch(reward){case "none"->"items";case "items"->"custom";default->"none";};rebuildWidgets();});
if(reward.equals("items")){button(layout,w,"rewards_choose",()->minecraft.gui.setScreen(new CommunityMaterialsScreen(this,rewards,value->rewards=value)));CommunityMaterialsScreen.showItems(layout,w,rewards);}
if(reward.equals("custom")){var box=layout.addChild(new EditBox(font,w,20,text("reward.custom")));box.setMaxLength(240);box.setValue(custom);box.setResponder(value->custom=value);}
button(layout,w,"save",()->{try{save.accept(new CommunityTask(location,materials,audience,reward,reward.equals("items")?rewards:List.of(),reward.equals("custom")?custom:""));onClose();}catch(fr.koka.sanctuary.community.Community.Refused e){error="task_invalid";rebuildWidgets();}});
var scroll=new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop),ScrollableLayout.ReserveStrategy.RIGHT);scroll.setPosition((width-w)/2,contentTop);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
}
private void button(LinearLayout layout,int w,String key,Runnable action){layout.addChild(Button.builder(text(key),b->action.run()).width(w).build());}
@Override public boolean isPauseScreen(){return false;}
}
@@ -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(() -> {
@@ -30,6 +30,7 @@ public final class NotificationClient {
private static double animatedBlocks,animatedItems,animatedRecipes;
private static boolean hasRoom=true;
private static String trackingScope;
private static List<Track> communityTracks=List.of();
private NotificationClient(){}
public static NotificationOptions options(){if(config==null)config=NotificationOptions.load();return config;}
public static Component label(String key,Object...args){return Component.translatable("sanctuary.notifications."+key,args);}
@@ -40,10 +41,24 @@ public final class NotificationClient {
ClientPlayConnectionEvents.DISCONNECT.register((h,m)->reset());
ClientTickEvents.END_CLIENT_TICK.register(NotificationClient::tick);
}
public static void reset(){FEED.clear();OBJECTIVES.reset();inhabitant=null;trackingScope=null;hasRoom=true;recipes=null;blocks=totalBlocks=totalItems=totalRecipes=ticks=0;lastClock=0;animatedBlocks=animatedItems=animatedRecipes=0;}
public static void reset(){CommunityMapMarkers.clear();communityTracks=List.of();FEED.clear();OBJECTIVES.reset();inhabitant=null;trackingScope=null;hasRoom=true;recipes=null;blocks=totalBlocks=totalItems=totalRecipes=ticks=0;lastClock=0;animatedBlocks=animatedItems=animatedRecipes=0;}
public static void clearFeed(){FEED.clear();}
public static List<NotificationFeed.Entry<Message>> messages(){return FEED.entries();}
public static List<Track> tracked(){return OBJECTIVES.shown(options().pinnedAdvancements,options().advancements);}
public static List<Track> tracked(){var tracks=new ArrayList<>(OBJECTIVES.shown(options().pinnedAdvancements,options().advancements));if(options().pinnedAdvancements)tracks.addAll(communityTracks);return tracks.stream().distinct().limit(6).toList();}
public static void communitySubscribed(Component title){push("community:follow",2,CommunityClient.text("followed",title),0x80c1a4);}
public static void community(com.google.gson.JsonArray entries){
var tracks=new ArrayList<Track>();var snapshots=new ArrayList<ObjectiveTracker.Objective<Track>>();
for(var value:entries){
var e=value.getAsJsonObject();var task=new com.google.gson.Gson().fromJson(e.get("task"),fr.koka.sanctuary.community.CommunityTask.class);
boolean closed=CommunityClient.str(e,"status").equals("closed");
Component detail=CommunityClient.text(closed?"closed":"open");
if(task.location()!=null)detail=detail.copy().append(" · "+task.location().x()+", "+task.location().y()+", "+task.location().z());
if(!task.materials().isEmpty()) {var item=task.materials().getFirst();var name=BuiltInRegistries.ITEM.getOptional(Identifier.parse(item.item())).map(i->i.getDefaultInstance().getHoverName()).orElse(Component.literal(item.item()));detail=detail.copy().append(" · "+item.count()+" × ").append(name);}
var track=new Track(Component.literal(CommunityClient.str(e,"title")),detail,closed?1:0,CommunityCard.color(CommunityClient.str(e,"category"))&0xffffff);
snapshots.add(new ObjectiveTracker.Objective<>(CommunityClient.str(e,"id"),track,closed?1:0,closed));if(!closed&&tracks.size()<3)tracks.add(track);
}
objectives("community",snapshots);communityTracks=List.copyOf(tracks);
}
public static boolean isPinned(Identifier id){return OBJECTIVES.isPinned(new ObjectiveTracker.Key("advancement",id.toString()));}
/** Presentation entry point for validated server snapshots, on the Minecraft client thread.
* A quest adapter can use source "quest" and its own IDs without a second HUD. */
@@ -111,7 +126,7 @@ public final class NotificationClient {
long now=System.nanoTime()/1_000_000L,elapsed=lastClock==0?0:now-lastClock;lastClock=now;
if(m.player==null||m.level==null)return;
if(m.player!=inhabitant||!m.player.isAlive()){
inhabitant=m.player;FEED.clear();OBJECTIVES.clearSnapshots();
inhabitant=m.player;FEED.clear();OBJECTIVES.clearSnapshots();communityTracks=List.of();
}
if(!m.player.isAlive())return;
ensureTracking(m);
@@ -69,6 +69,16 @@ public final class PauseAtlasWidget extends AbstractButton implements AutoClosea
if(--refresh<=0)request();
}
public int knownPixels() {return pixels;}
private double zoom(){return Math.max(.75,Math.max(width,height)/992.);}
java.util.List<CommunityMapMarkers.Pin> questPins() {
return CommunityMapMarkers.project(dimension,wx->getX()+width/2+(int)Math.round((wx-centerX)*zoom()),
wz->getY()+height/2+(int)Math.round((wz-centerZ)*zoom()),getX(),getY()+20,getRight(),getBottom()-20);
}
@Override public boolean mouseClicked(net.minecraft.client.input.MouseButtonEvent event,boolean doubleClick) {
var pin=CommunityMapMarkers.at(questPins(),event.x(),event.y());
if(event.button()==com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_LEFT && pin!=null){CommunityClient.open(owner,"notice",pin.marker().id(),false);return true;}
return super.mouseClicked(event,doubleClick);
}
@Override public void onPress(InputWithModifiers input) {ProgressionClient.open(owner,"history");}
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
if(texture==null)return;
@@ -77,7 +87,7 @@ public final class PauseAtlasWidget extends AbstractButton implements AutoClosea
g.fill(x,y,getRight(),getBottom(),0x90151B19);
g.enableScissor(x,y,getRight(),getBottom());
if(readable) {
double zoom=Math.max(.75,Math.max(width,height)/992.);
double zoom=zoom();
int left=x+width/2+(int)((originX-centerX)*zoom),top=y+height/2+(int)((originZ-centerZ)*zoom);
g.blit(RenderPipelines.GUI_TEXTURED,TEXTURE,left,top,0,0,(int)(SIZE*zoom),(int)(SIZE*zoom),SIZE,SIZE,SIZE,SIZE);
int px=x+width/2,pz=y+height/2;
@@ -89,6 +99,8 @@ public final class PauseAtlasWidget extends AbstractButton implements AutoClosea
int color=ProgressionClient.identity==null?0xFFFFFFFF:0xFF000000|ProgressionClient.identity.color();
g.fill(px-3,pz-3,px+4,pz+4,0xFF141818);g.fill(px-2,pz-2,px+3,pz+3,color);
}
boolean questHovered=CommunityMapMarkers.render(g,questPins(),mx,my);
setTooltip(questHovered?null:Tooltip.create(Component.translatable("sanctuary.pause.map_hint")));
g.text(font,"N ↑",x+7,y+7,0xFFFFFFFF);
if(pixels==0)g.textWithWordWrap(font,SanctuaryAtlasScreen.label(readable?"empty":status),x+8,y+height/2+12,width-16,0xFFCCCCCC);
g.text(font,font.plainSubstrByWidth(getMessage().getString(),width-14),x+7,getBottom()-16,0xFFF0EEE6);
@@ -62,7 +62,7 @@ final class PauseCommunityFeed {
var face=row.addChild(new PlayerFaceWidget(20,net.minecraft.world.item.component.ResolvableProfile.createUnresolved(UUID.fromString(str(entry,"authorId")))));
face.setTooltip(Tooltip.create(Component.literal(str(entry,"authorName"))));
row.addChild(Button.builder(title,b->action.run()).width(width-25).tooltip(Tooltip.create(title.copy().append("\n").append(str(entry,"authorName")))).build());
} else rows.addChild(new NoticeCard(width,entry,action));
} else rows.addChild(new CommunityCard(width,entry,true,action));
}
if(more && error.isEmpty())rows.addChild(Button.builder(text("more"),b->{page++;needsLoad=true;b.active=false;}).width(width).build());
scroll.arrangeElements();
@@ -73,22 +73,4 @@ final class PauseCommunityFeed {
}
void refresh() {if(page==0 && scroll.getScrollAmount()==0 && error.isEmpty())needsLoad=true;}
void close() {closed=true;}
private static final class NoticeCard extends AbstractButton {
private final JsonObject entry;
private final Runnable action;
NoticeCard(int width,JsonObject entry,Runnable action) {super(0,0,width,84,Component.literal(str(entry,"title")+"\n"+str(entry,"body")));this.entry=entry;this.action=action;}
@Override public void onPress(InputWithModifiers input) {action.run();}
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
var font=Minecraft.getInstance().font;int x=getX(),y=getY(),border=isHoveredOrFocused()?0xFFC8C0A6:0xFF727365;
g.fill(x,y,getRight(),getBottom(),0xBA202720);
g.fill(x,y,getRight(),y+1,border);g.fill(x,getBottom()-1,getRight(),getBottom(),border);
g.fill(x,y,x+1,getBottom(),border);g.fill(getRight()-1,y,getRight(),getBottom(),border);
g.text(font,font.plainSubstrByWidth(str(entry,"title"),width-14),x+7,y+7,0xFFF0EEE6);
var lines=font.split(Component.literal(str(entry,"body")),width-14);
for(int i=0;i<Math.min(3,lines.size());i++)g.text(font,lines.get(i),x+7,y+23+i*10,0xFFD2D6CF,false);
g.text(font,font.plainSubstrByWidth(str(entry,"authorName"),width-14),x+7,y+height-23,0xFFAABAAA);
g.text(font,font.plainSubstrByWidth(text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))).getString(),width-14),x+7,y+height-12,0xFFAABAAA);
}
@Override protected void updateWidgetNarration(NarrationElementOutput output) {defaultButtonNarrationText(output);}
}
}
@@ -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;
}
}
@@ -38,7 +38,9 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
private String status="loading";
private String dimension="";
public SanctuaryAtlasScreen(Screen parent) {super(parent);}
private final fr.koka.sanctuary.community.CommunityTask.Location initialTarget;
public SanctuaryAtlasScreen(Screen parent) {this(parent,null);}
SanctuaryAtlasScreen(Screen parent,fr.koka.sanctuary.community.CommunityTask.Location target) {super(parent);initialTarget=target;}
public static Component label(String key,Object... args) {return Component.translatable("sanctuary.atlas."+key,args);}
@Override protected void init() {
dragging=false;
@@ -75,7 +77,10 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
minecraft.getTextureManager().register(TEXTURE,texture);
footprintTexture=new DynamicTexture("Sanctuary atlas footprints",SIZE,SIZE,false);
minecraft.getTextureManager().register(FOOTPRINT_TEXTURE,footprintTexture);
clear();recenter();
clear();
if(initialTarget!=null && minecraft.level!=null && initialTarget.dimension().equals(minecraft.level.dimension().identifier().toString())) {
centerX=initialTarget.x()+.5;centerZ=initialTarget.z()+.5;request();
} else recenter();
}
}
private void clear() {
@@ -193,6 +198,8 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
@Override public boolean mouseClicked(MouseButtonEvent event,boolean doubleClick) {
// Disabled buttons also own their hit area; clicking one must never start a map gesture.
if (overControl(event.x(),event.y())) return super.mouseClicked(event,doubleClick);
var quest=CommunityMapMarkers.at(questPins(),event.x(),event.y());
if(event.button()==InputConstants.MOUSE_BUTTON_LEFT && quest!=null){CommunityClient.open(this,"notice",quest.marker().id(),false);return true;}
if (event.button()==InputConstants.MOUSE_BUTTON_LEFT && inside(event.x(),event.y()) && readable()) {
setFocused(null);setDragging(false);dragging=true;return true;
}
@@ -267,13 +274,18 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
graphics.textWithWordWrap(font,message,x,y,textWidth,0xFFE0E6EE);
}
renderCoordinates(graphics,mouseX,mouseY);
if (!renderMarkerHint(graphics,mouseX,mouseY)) renderFootprintHint(graphics,mouseX,mouseY);
boolean questHint=CommunityMapMarkers.render(graphics,questPins(),mouseX,mouseY);
if (!questHint && !renderMarkerHint(graphics,mouseX,mouseY)) renderFootprintHint(graphics,mouseX,mouseY);
}
public void accept(fr.koka.sanctuary.atlas.AtlasMarkerPayload payload) {
if (payload.token()!=token || !readable() || minecraft.level==null
|| !dimension.equals(minecraft.level.dimension().identifier().toString())) return;
markers=payload.markers();
}
java.util.List<CommunityMapMarkers.Pin> questPins() {
return CommunityMapMarkers.project(dimension,this::screenX,this::screenZ,0,contentTop,width,height-62).stream()
.filter(pin->!overControl(pin.x(),pin.y())).toList();
}
private Component markerName(fr.koka.sanctuary.atlas.AtlasMarker marker) {
return marker.name().isBlank() ? label("marker_"+marker.kind()) : Component.literal(marker.name());
}
@@ -15,7 +15,8 @@ public final class VanillaLight {
private static Preferences preferences;
private VanillaLight() { }
private static final class Preferences {
boolean enabled=true;
// Opt-in on fresh installations; explicit saved choices remain authoritative.
boolean enabled=false;
Boolean naturalWater; // Legacy OFF is an explicit opt-out in both world types.
Boolean sanctuaryNaturalWater;
Boolean ordinaryNaturalWater;
@@ -4,7 +4,7 @@ import java.util.*;
/** Versioned community rules shared by both persistence adapters. */
public final class Community {
public static final int VERSION = 1, PAGE_SIZE = 10;
public static final int VERSION = 3, PAGE_SIZE = 10;
public static final Set<String> KINDS = Set.of("article", "notice", "bulletin", "reply");
public static final Set<String> CATEGORIES = Set.of("info", "work", "need", "event");
private Community() { }
@@ -13,8 +13,12 @@ public final class Community {
}
public record Entry(String id, String parentId, String kind, String category,
String authorId, String authorName, String title, String body,
String status, boolean pinned, int revision, long createdAt, long updatedAt) {
String status, boolean pinned, int revision, long createdAt, long updatedAt, String photo, CommunityTask task) {
public Entry {
task = kind.equals("notice")?Objects.requireNonNullElse(task,CommunityTask.EMPTY):task;
if(!kind.equals("notice") && task!=null)fail("task_invalid");
photo = Objects.requireNonNullElse(photo, "");
if (photo.length() > CommunityPhoto.MAX_TEXT || (!photo.isEmpty() && !kind.equals("article"))) fail("photo_invalid");
uuid(id); uuid(authorId); if (!parentId.isEmpty()) uuid(parentId);
if (!KINDS.contains(kind) || !CATEGORIES.contains(category)
|| !Set.of("open", "closed", "hidden").contains(status) || revision < 1
@@ -25,7 +29,14 @@ public final class Community {
}
}
public record Change(String action, String id, String parentId, String kind, String category,
String title, String body, int revision) { }
String title, String body, int revision, String photo, CommunityTask task) {
public Change(String action,String id,String parentId,String kind,String category,String title,String body,int revision,String photo) {
this(action,id,parentId,kind,category,title,body,revision,photo,null);
}
public Change(String action,String id,String parentId,String kind,String category,String title,String body,int revision) {
this(action,id,parentId,kind,category,title,body,revision,null,null);
}
}
public record Page(List<Entry> entries, int page, boolean more) { }
public record ThreadView(Entry entry, Page replies) { }
public static final class Refused extends RuntimeException {
@@ -33,6 +44,12 @@ public final class Community {
public Refused(String code) { super(code); this.code = code; }
}
public static void fail(String code) { throw new Refused(code); }
public static String search(String value) {
if(value==null || value.codePointCount(0,value.length())>120)fail("invalid");
value=value.strip();
if(!value.isEmpty())text(value,120);
return value.toLowerCase(Locale.ROOT);
}
public static String uuid(String value) {
if (value == null || !value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) fail("invalid");
return value;
@@ -59,21 +76,30 @@ public final class Community {
if (existing != null) {
if (!existing.authorId().equals(actor.id()) || !existing.kind().equals(change.kind())
|| !existing.parentId().equals(parent) || !existing.category().equals(change.category())
|| !existing.title().equals(change.title()) || !existing.body().equals(change.body())) fail("conflict");
|| !existing.title().equals(change.title()) || !existing.body().equals(change.body())
|| !existing.photo().equals(Objects.requireNonNullElse(change.photo(),""))
|| !Objects.equals(existing.task(),change.kind().equals("notice")?Objects.requireNonNullElse(change.task(),CommunityTask.EMPTY):change.task())) fail("conflict");
return visible(existing);
}
if (!parent.isEmpty() && !tx.get(parent).status().equals("open")) fail("closed");
Entry entry = new Entry(change.id(), parent, change.kind(), change.category(), actor.id(), actor.name(),
change.title(), change.body(), "open", false, 1, now, now);
change.title(), change.body(), "open", false, 1, now, now, photo(change.kind(),change.photo()),change.task());
tx.save(entry, true); return entry;
}
Entry old = visible(tx.get(change.id()));
if(old.kind().equals("bulletin")&&!actor.moderator())fail("forbidden");
if (!old.parentId().isEmpty()) visible(tx.get(old.parentId()));
if (old.revision() != change.revision()) fail("conflict");
boolean own = old.authorId().equals(actor.id());
String title = old.title(), body = old.body(), status = old.status(); boolean pinned = old.pinned();
CommunityTask task=old.task();String category=old.category();
String title = old.title(), body = old.body(), status = old.status(), photo = old.photo(); boolean pinned = old.pinned();
switch (change.action()) {
case "edit" -> { if (!own) fail("forbidden"); title = change.title(); body = change.body(); }
case "edit" -> { if (!own) fail("forbidden"); title = change.title(); body = change.body(); photo = photo(old.kind(),change.photo()==null?old.photo():change.photo());
if(old.kind().equals("notice")) {
task=change.task()==null?old.task():change.task();category=change.category();
if(task.audience().equals("single") && tx.followers(old.id())>1)fail("notice_full");
}
}
case "hide" -> { if (!own && !actor.moderator()) fail("forbidden"); status = "hidden"; }
case "close", "reopen" -> {
if ((!own && !actor.moderator()) || !old.kind().equals("notice")) fail("forbidden");
@@ -82,10 +108,15 @@ public final class Community {
case "pin" -> { if (!actor.moderator() || old.kind().equals("reply")) fail("forbidden"); pinned = !pinned; }
default -> fail("invalid");
}
Entry next = new Entry(old.id(), old.parentId(), old.kind(), old.category(), old.authorId(), old.authorName(),
title, body, status, pinned, Math.addExact(old.revision(), 1), old.createdAt(), Math.max(now, old.updatedAt()));
Entry next = new Entry(old.id(), old.parentId(), old.kind(), category, old.authorId(), old.authorName(),
title, body, status, pinned, Math.addExact(old.revision(), 1), old.createdAt(), Math.max(now, old.updatedAt()), photo, task);
tx.save(next, false); return next;
}
private static String photo(String kind,String photo) {
if(kind.equals("article"))return CommunityPhoto.validate(photo);
if(photo!=null && !photo.isEmpty())fail("photo_invalid");
return "";
}
public static Entry visible(Entry entry) {
if (entry == null || entry.status().equals("hidden")) fail("missing");
return entry;
@@ -33,20 +33,31 @@ public final class CommunityNetwork {
public record Response(int sequence, String json) implements CustomPacketPayload {
public static final Type<Response> TYPE = new Type<>(SanctuaryMod.id("community_response_v1"));
public static final StreamCodec<RegistryFriendlyByteBuf, Response> CODEC = CustomPacketPayload.codec(
(p,b) -> { b.writeVarInt(p.sequence); b.writeUtf(p.json,131072); }, b -> new Response(b.readVarInt(),b.readUtf(131072)));
(p,b) -> { b.writeVarInt(p.sequence); b.writeUtf(p.json,262144); }, b -> new Response(b.readVarInt(),b.readUtf(262144)));
public Type<Response> type() { return TYPE; }
}
private CommunityNetwork() { }
public static void register() {
net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback.EVENT.register((dispatcher,registries,environment)->
dispatcher.register(net.minecraft.commands.Commands.literal("sanctuary").then(net.minecraft.commands.Commands.literal("community").then(net.minecraft.commands.Commands.literal("link").then(net.minecraft.commands.Commands.argument("code",com.mojang.brigadier.arguments.StringArgumentType.word()).executes(context->{
request(context.getSource().getPlayerOrException(),new Request(0,"link","","","article","info","",com.mojang.brigadier.arguments.StringArgumentType.getString(context,"code"),0,0));return 1;
}))))));
net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback.EVENT.register((dispatcher,registries,environment)->
dispatcher.register(net.minecraft.commands.Commands.literal("sanctuary").then(net.minecraft.commands.Commands.literal("community").then(net.minecraft.commands.Commands.literal("admin").requires(OperatorService::canCommand).executes(context->{
var player=context.getSource().getPlayerOrException();
if(ServerPlayNetworking.canSend(player,Response.TYPE))ServerPlayNetworking.send(player,new Response(0,"{\"admin\":true}"));
return 1;
})))));
PayloadTypeRegistry.serverboundPlay().register(Request.TYPE, Request.CODEC);
PayloadTypeRegistry.clientboundPlay().register(Response.TYPE, Response.CODEC);
ServerPlayNetworking.registerGlobalReceiver(Request.TYPE, (r,c) -> c.server().execute(() -> request(c.player(),r)));
ServerLifecycleEvents.SERVER_STOPPED.register(server -> { var runtime = RUNTIMES.remove(server); if (runtime != null) runtime.stop(); });
ServerPlayConnectionEvents.DISCONNECT.register((handler,server) -> {
var runtime = RUNTIMES.get(server); if (runtime != null) runtime.rates.remove(handler.player.getUUID());
var runtime = RUNTIMES.get(server); if (runtime != null) { runtime.rates.remove(handler.player.getUUID()); runtime.photos.remove(handler.player.getUUID()); runtime.tasks.remove(handler.player.getUUID()); }
});
}
private static void send(ServerPlayer player, Request request, JsonObject data) {
if(request.sequence()==0&&request.action().equals("link")){player.sendSystemMessage(net.minecraft.network.chat.Component.translatable("sanctuary.community."+(data.has("error")?data.get("error").getAsString():"linked")));return;}
if (!player.isRemoved() && ServerPlayNetworking.canSend(player, Response.TYPE))
ServerPlayNetworking.send(player, new Response(request.sequence(), data.toString()));
}
@@ -54,7 +65,7 @@ public final class CommunityNetwork {
private static void request(ServerPlayer player, Request request) {
var server = player.level().getServer();
var runtime = RUNTIMES.computeIfAbsent(server, Runtime::new);
long now = System.nanoTime(); boolean reading = Set.of("overview","list","thread").contains(request.action());
long now = System.nanoTime(); boolean reading = Set.of("overview","list","thread","photo","cards","following","task").contains(request.action());
long[] rates = runtime.rates.computeIfAbsent(player.getUUID(), key -> new long[2]); int index = reading ? 0 : 1;
if (runtime.pending.contains(player.getUUID()) || now - rates[index] < (reading ? 100_000_000L : 1_000_000_000L)) { send(player,request,error("busy")); return; }
rates[index] = now;
@@ -68,17 +79,61 @@ public final class CommunityNetwork {
if (System.nanoTime() - now > 10_000_000_000L) fail("busy");
var store = runtime.store(); result = new JsonObject(); result.addProperty("moderator",actor.moderator());
result.addProperty("serverTime",System.currentTimeMillis()); result.addProperty("mode",runtime.config.mode());
if(request.kind().equals("bulletin") && !Set.of("overview","following","list","cards","thread").contains(request.action()) && !actor.moderator())fail("forbidden");
switch (request.action()) {
case "overview" -> {
result.add("articles", JSON.toJsonTree(store.list("article","",0).entries().stream().limit(3).toList()));
result.add("notices", JSON.toJsonTree(store.list("notice","",0).entries().stream().limit(3).toList()));
result.add("bulletins", JSON.toJsonTree(store.list("bulletin","",0).entries().stream().limit(1).toList()));
}
case "list" -> result.add("page",JSON.toJsonTree(store.list(request.kind(),"",request.page())));
case "thread" -> result.add("thread",JSON.toJsonTree(store.thread(request.id(),request.page())));
case "list" -> result.add("page",JSON.toJsonTree(store.list(request.kind(),"",request.page(),request.body())));
case "cards" -> {
if(!Set.of("article","notice").contains(request.kind()))fail("invalid");
var page=store.list(request.kind(),"",request.page(),request.body());var cards=JSON.toJsonTree(page).getAsJsonObject();
for(var value:cards.getAsJsonArray("entries")) {
var entry=value.getAsJsonObject();String photo=entry.has("photo")?entry.get("photo").getAsString():"";
if(!photo.isEmpty())entry.addProperty("thumbnail",runtime.thumbnail(entry.get("id").getAsString()+":"+entry.get("revision").getAsInt(),photo));
entry.remove("body");entry.remove("task");
}
omitPhotos(cards);result.add("page",cards);
}
case "thread" -> {
var thread=store.thread(request.id(),request.page());
result.add("thread",JSON.toJsonTree(thread));
if(thread.entry().kind().equals("notice")) {
var members=store.members(request.id());result.add("members",JSON.toJsonTree(members));
result.addProperty("subscribed",members.stream().anyMatch(f->f.playerId().equals(actor.id())));
}
}
case "follow","unfollow" -> {
store.follow(actor,request.id(),request.action().equals("follow"));
result.add("following",following(store,actor));result.addProperty("saved",true);
}
case "following" -> result.add("following",following(store,actor));
case "task" -> {
runtime.tasks.values().removeIf(t->System.currentTimeMillis()-t.at()>120000);
if(runtime.tasks.size()>=128 && !runtime.tasks.containsKey(player.getUUID()))fail("busy");
if(request.body().length()>16000)fail("task_invalid");
var task=JSON.fromJson(request.body(),CommunityTask.class);validateTask(server,task);
runtime.tasks.put(player.getUUID(),new TaskDraft(uuid(request.id()),task,System.currentTimeMillis()));
result.addProperty("saved",true);
}
case "photo" -> {
long instant=System.currentTimeMillis();
runtime.photos.values().removeIf(p -> instant-p.started>120000);
if(request.page()==0) {
if(runtime.photos.size()>=128 && !runtime.photos.containsKey(player.getUUID()))fail("busy");
runtime.photos.put(player.getUUID(),new CommunityPhoto.Transfer(request.id(),request.revision(),instant));
}
var transfer=runtime.photos.get(player.getUUID());
if(transfer==null)fail("photo_required");
transfer.append(request.id(),request.page(),request.revision(),request.body(),instant);
result.addProperty("uploaded",request.page()+1);
}
case "link" -> { store.link(actor,request.body()); result.addProperty("linked",true); }
default -> result.add("result",JSON.toJsonTree(store.change(actor,new Change(request.action(),request.id(),request.parentId(),request.kind(),request.category(),request.title(),request.body(),request.revision()))));
default -> result.add("result",JSON.toJsonTree(store.change(actor,new Change(request.action(),request.id(),request.parentId(),request.kind(),request.category(),request.title(),request.body(),request.revision(),runtime.photo(player.getUUID(),request),runtime.task(player.getUUID(),request)))));
}
if(request.action().equals("list") || request.action().equals("overview")) {omitPhotos(result);omitTasks(result);}
} catch (Refused e) { result = error(e.code); }
catch (Exception e) { result = error("unavailable"); LOG.warn("Community operation unavailable ({})",e.getClass().getSimpleName()); }
JsonObject response = result;
@@ -89,12 +144,58 @@ public final class CommunityNetwork {
});
} catch (RejectedExecutionException e) { runtime.pending.remove(player.getUUID()); send(player,request,error("busy")); }
}
private record TaskDraft(String id,CommunityTask task,long at) { }
private static void validateTask(MinecraftServer server,CommunityTask task) {
if(task==null)fail("task_invalid");
for(var item:java.util.stream.Stream.concat(task.materials().stream(),task.rewards().stream()).toList())
if(net.minecraft.core.registries.BuiltInRegistries.ITEM.getOptional(net.minecraft.resources.Identifier.parse(item.item())).isEmpty())fail("task_invalid");
if(task.location()!=null && server.levelKeys().stream().noneMatch(k->k.identifier().toString().equals(task.location().dimension())))fail("task_invalid");
}
private static JsonElement following(CommunityStore store,Actor actor) throws Exception {
var result=JSON.toJsonTree(store.following(actor));omitPhotos(result);
result.getAsJsonArray().forEach(e->{
var entry=e.getAsJsonObject();String body=entry.remove("body").getAsString();
int count=body.codePointCount(0,body.length());
entry.addProperty("summary",count>240?body.substring(0,body.offsetByCodePoints(0,240))+"…":body);
});return result;
}
private static void omitTasks(JsonElement value) {
if(value.isJsonArray())value.getAsJsonArray().forEach(CommunityNetwork::omitTasks);
else if(value.isJsonObject()) {var object=value.getAsJsonObject();object.remove("task");object.entrySet().forEach(e->omitTasks(e.getValue()));}
}
public static void omitPhotos(JsonElement value) {
if(value.isJsonArray())value.getAsJsonArray().forEach(CommunityNetwork::omitPhotos);
else if(value.isJsonObject()) {
var object=value.getAsJsonObject();
if(object.has("photo")) {object.addProperty("hasPhoto",!object.get("photo").getAsString().isEmpty());object.remove("photo");}
object.entrySet().forEach(e -> omitPhotos(e.getValue()));
}
}
private static final class Runtime {
final MinecraftServer server;
final ThreadPoolExecutor executor = new ThreadPoolExecutor(1,1,0,TimeUnit.SECONDS,new ArrayBlockingQueue<>(32),
task -> { var thread = new Thread(task,"Sanctuary community"); thread.setDaemon(true); return thread; });
final Map<UUID,long[]> rates = new HashMap<>();
final Set<UUID> pending = new HashSet<>();
final Map<UUID,CommunityPhoto.Transfer> photos=new ConcurrentHashMap<>();
String photo(UUID player,Request request) {
if(!Set.of("create","edit").contains(request.action()) || !request.kind().equals("article"))return null;
var transfer=photos.get(player);
if(transfer==null || !transfer.id.equals(request.id()))return null;
return transfer.value(request.id(),System.currentTimeMillis());
}
final Map<UUID,TaskDraft> tasks=new ConcurrentHashMap<>();
final Map<String,String> thumbnails=new LinkedHashMap<>();
String thumbnail(String key,String photo) throws Exception {
if(thumbnails.containsKey(key))return thumbnails.get(key);
String result=CommunityPhoto.thumbnail(photo);if(thumbnails.size()>=128)thumbnails.remove(thumbnails.keySet().iterator().next());
thumbnails.put(key,result);return result;
}
CommunityTask task(UUID player,Request request) {
if(!Set.of("create","edit").contains(request.action()) || !request.kind().equals("notice"))return null;
var draft=tasks.get(player);if(draft==null || !draft.id().equals(request.id()))return null;
if(System.currentTimeMillis()-draft.at()>120000)fail("task_expired");return draft.task();
}
CommunityConfig config;
CommunityStore store;
Runtime(MinecraftServer server) { this.server = server; }
@@ -0,0 +1,87 @@
package fr.koka.sanctuary.community;
import java.awt.image.BufferedImage;
import java.io.*;
import java.util.Base64;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.stream.MemoryCacheImageInputStream;
import javax.imageio.stream.MemoryCacheImageOutputStream;
import static fr.koka.sanctuary.community.Community.fail;
/** Bounded, portable JPEG copies; originals and their metadata never leave the screenshot folder. */
public final class CommunityPhoto {
public static final int MAX_BYTES=48*1024, MAX_TEXT=65536, WIDTH=960, HEIGHT=540;
private CommunityPhoto() { }
public static final class Transfer {
public final String id;
public final long started;
private final int parts;
private int next;
private final StringBuilder data=new StringBuilder();
public Transfer(String id,int parts,long now) {
this.id=Community.uuid(id);this.parts=parts;this.started=now;
if(parts<1 || parts>9)fail("photo_invalid");
}
public void append(String id,int part,int total,String value,long now) {
if(!this.id.equals(id) || part!=next || part>=parts || total!=parts || now-started>120000
|| value.isEmpty() || value.length()>8000 || data.length()+value.length()>MAX_TEXT)fail("photo_invalid");
data.append(value);next++;
}
public String value(String id,long now) {
if(!this.id.equals(id) || next!=parts || now-started>120000)fail("photo_required");
return data.toString();
}
}
public static BufferedImage decode(byte[] bytes,int maxWidth,int maxHeight) throws IOException {
try(var stream=new MemoryCacheImageInputStream(new ByteArrayInputStream(bytes))) {
var readers=ImageIO.getImageReaders(stream);
if(!readers.hasNext())throw new IOException("Invalid image");
var reader=readers.next();
try {
reader.setInput(stream,true,true);
int w=reader.getWidth(0),h=reader.getHeight(0);
if(w<1 || h<1 || w>maxWidth || h>maxHeight || (long)w*h>32_000_000)throw new IOException("Image dimensions");
var image=reader.read(0);
if(image==null)throw new IOException("Invalid image");
return image;
} finally {reader.dispose();}
}
}
public static String validate(String value) {
if(value==null || value.isEmpty())fail("photo_required");
try {
if(value.length()>MAX_TEXT)throw new IOException("Image size");
byte[] bytes=Base64.getDecoder().decode(value);
if(bytes.length>MAX_BYTES || bytes.length<4 || bytes[0]!=(byte)0xff || bytes[1]!=(byte)0xd8
|| bytes[bytes.length-2]!=(byte)0xff || bytes[bytes.length-1]!=(byte)0xd9
|| !Base64.getEncoder().encodeToString(bytes).equals(value))throw new IOException("Invalid JPEG");
decode(bytes,WIDTH,HEIGHT);
return value;
} catch(IOException | IllegalArgumentException e) {fail("photo_invalid");return "";}
}
public static String thumbnail(String photo) throws IOException {
if(photo.isEmpty())return "";
return encode(decode(Base64.getDecoder().decode(photo),WIDTH,HEIGHT),160,90,6144);
}
public static String encode(BufferedImage source,int maxWidth,int maxHeight) throws IOException {return encode(source,maxWidth,maxHeight,MAX_BYTES);}
private static String encode(BufferedImage source,int maxWidth,int maxHeight,int limit) throws IOException {
double scale=Math.min(1,Math.min((double)maxWidth/source.getWidth(),(double)maxHeight/source.getHeight()));
var image=new BufferedImage(Math.max(1,(int)(source.getWidth()*scale)),Math.max(1,(int)(source.getHeight()*scale)),BufferedImage.TYPE_INT_RGB);
var graphics=image.createGraphics();
graphics.setRenderingHint(java.awt.RenderingHints.KEY_INTERPOLATION,java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR);
graphics.drawImage(source,0,0,image.getWidth(),image.getHeight(),null);graphics.dispose();
for(float quality:new float[]{.85f,.7f,.55f,.4f,.25f}) {
var bytes=new ByteArrayOutputStream();
var writer=ImageIO.getImageWritersByFormatName("jpeg").next();
try(var out=new MemoryCacheImageOutputStream(bytes)) {
writer.setOutput(out);var params=writer.getDefaultWriteParam();
params.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);params.setCompressionQuality(quality);
writer.write(null,new javax.imageio.IIOImage(image,null,null),params);
} finally {writer.dispose();}
if(bytes.size()<=limit)return Base64.getEncoder().encodeToString(bytes.toByteArray());
}
if(maxWidth>60)return encode(image,maxWidth/2,maxHeight/2,limit);
throw new IOException("Image size");
}
}
@@ -7,10 +7,15 @@ public interface CommunityStore extends AutoCloseable {
interface Transaction {
Entry get(String id) throws Exception;
void save(Entry entry, boolean insert) throws Exception;
int followers(String id) throws Exception;
}
Page list(String kind, String parent, int page) throws Exception;
default Page list(String kind, String parent, int page) throws Exception { return list(kind,parent,page,""); }
Page list(String kind, String parent, int page, String search) throws Exception;
ThreadView thread(String id, int page) throws Exception;
Entry change(Actor actor, Change change) throws Exception;
java.util.List<CommunityTask.Follow> members(String id) throws Exception;
java.util.List<Entry> following(Actor actor) throws Exception;
void follow(Actor actor,String id,boolean join) throws Exception;
default void link(Actor actor, String code) throws Exception { fail("database_only"); }
@Override default void close() throws Exception { }
}
@@ -0,0 +1,46 @@
package fr.koka.sanctuary.community;
import java.util.*;
import static fr.koka.sanctuary.community.Community.*;
/** Descriptive requests only. No ItemStack, inventory mutation, delivery or reward ledger. */
public record CommunityTask(Location location,List<Material> materials,String audience,String reward,
List<Material> rewards,String customReward) {
public static final int MAX_ITEMS=27, MAX_FOLLOWS=16, MAX_MEMBERS=64;
public static final CommunityTask EMPTY=new CommunityTask(null,List.of(),"shared","none",List.of(),"");
public record Material(String item,int count) {
public Material {identifier(item);if(item.equals("minecraft:air") || count<1 || count>999999)fail("task_invalid");}
}
public record Location(String dimension,int x,int y,int z) {
public Location {identifier(dimension);if(Math.abs((long)x)>29999984 || Math.abs((long)z)>29999984 || y< -4096 || y>4096)fail("task_invalid");}
}
public CommunityTask {
materials=items(materials);rewards=items(rewards);
if(audience==null || reward==null || !Set.of("shared","single").contains(audience) || !Set.of("none","items","custom").contains(reward))fail("task_invalid");
if(customReward==null)fail("task_invalid");
if(reward.equals("custom"))text(customReward,240);else if(!customReward.isEmpty())fail("task_invalid");
if(reward.equals("items")?rewards.isEmpty():!rewards.isEmpty())fail("task_invalid");
}
private static List<Material> items(List<Material> items) {
if(items==null || items.size()>MAX_ITEMS)fail("task_invalid");
var ids=new HashSet<String>();for(var item:items)if(item==null || !ids.add(item.item()))fail("task_invalid");
return List.copyOf(items);
}
public static String identifier(String id) {
if(id==null || id.length()>100 || !id.matches("[a-z0-9_.-]+:[a-z0-9_./-]+"))fail("task_invalid");
return id;
}
public record Follow(String noticeId,String playerId,String playerName,long since) {
public Follow {uuid(noticeId);uuid(playerId);text(playerName,64);if(since<0)fail("invalid");}
}
public static void canFollow(Entry entry,Actor actor,boolean join,List<Follow> members,int ownCount) {
if(!entry.kind().equals("notice"))fail("invalid");
if(!join)return;
if(entry.status().equals("hidden"))fail("missing");
if(members.stream().anyMatch(f->f.playerId().equals(actor.id())))return;
if(!entry.status().equals("open"))fail("closed");
if(entry.authorId().equals(actor.id()))fail("own_notice");
if(ownCount>=MAX_FOLLOWS)fail("follow_limit");
if(members.size()>=MAX_MEMBERS || entry.task().audience().equals("single")&&!members.isEmpty())fail("notice_full");
}
}
@@ -15,6 +15,8 @@ public final class FileCommunityStore implements CommunityStore {
private final String serverId;
private final Clock clock;
private byte[] previous;
private int legacy;
private java.util.List<CommunityTask.Follow> followers=new ArrayList<>();
private Map<String, Entry> entries = new LinkedHashMap<>();
public FileCommunityStore(Path path, String serverId) throws Exception { this(path, serverId, Clock.systemUTC()); }
public FileCommunityStore(Path path, String serverId, Clock clock) throws Exception {
@@ -22,46 +24,75 @@ public final class FileCommunityStore implements CommunityStore {
previous = AtomicJson.read(path, LIMIT);
if (previous != null) {
var root = AtomicJson.parse(new String(previous, StandardCharsets.UTF_8)).getAsJsonObject();
AtomicJson.fields(root, "version", "serverId", "entries");
if (root.get("version").getAsInt() != VERSION || !root.get("serverId").getAsString().equals(serverId)) fail("schema");
legacy = root.get("version").getAsInt();
if(legacy<3)AtomicJson.fields(root,"version","serverId","entries");else AtomicJson.fields(root,"version","serverId","entries","followers");
if ((legacy<1 || legacy>VERSION) || !root.get("serverId").getAsString().equals(serverId)) fail("schema");
if(root.has("followers"))for(var f:root.getAsJsonArray("followers"))followers.add(JSON.fromJson(f,CommunityTask.Follow.class));
for (var value : root.getAsJsonArray("entries")) {
Entry entry = JSON.fromJson(value, Entry.class);
if (entries.put(entry.id(), entry) != null) fail("invalid");
}
var unique=new HashSet<String>();
for(var f:followers)if(!unique.add(f.noticeId()+f.playerId()) || !entries.containsKey(f.noticeId()) || !entries.get(f.noticeId()).kind().equals("notice"))fail("invalid");
for (Entry entry : entries.values()) if (!entry.parentId().isEmpty()) {
Entry parent = entries.get(entry.parentId());
if (parent == null || !Set.of("article", "notice").contains(parent.kind())) fail("invalid");
}
}
}
@Override public synchronized Page list(String kind, String parent, int page) {
@Override public synchronized Page list(String kind, String parent, int page, String search) {
String needle=Community.search(search);
page(page); if (!KINDS.contains(kind)) fail("invalid");
if (!parent.isEmpty()) visible(entries.get(parent));
Comparator<Entry> order = Comparator.comparingLong(Entry::createdAt).thenComparing(Entry::id);
if (kind.equals("bulletin")) order = order.reversed();
else if (!kind.equals("reply")) order = Comparator.comparing(Entry::pinned).reversed().thenComparing(order.reversed());
var found = entries.values().stream().filter(e -> e.kind().equals(kind) && e.parentId().equals(parent)
&& !e.status().equals("hidden")).sorted(order).skip((long) page * PAGE_SIZE).limit(PAGE_SIZE + 1).toList();
&& !e.status().equals("hidden") && (needle.isEmpty() || e.authorName().toLowerCase(Locale.ROOT).contains(needle)
|| e.title().toLowerCase(Locale.ROOT).contains(needle) || e.body().toLowerCase(Locale.ROOT).contains(needle))).sorted(order).skip((long) page * PAGE_SIZE).limit(PAGE_SIZE + 1).toList();
return new Page(found.subList(0, Math.min(PAGE_SIZE, found.size())), page, found.size() > PAGE_SIZE);
}
@Override public synchronized ThreadView thread(String id, int page) {
Entry entry = visible(entries.get(uuid(id)));
if (entry.kind().equals("reply")) fail("missing");
return new ThreadView(entry, list("reply", id, page));
return new ThreadView(entry, list("reply", id, page, ""));
}
@Override public synchronized Entry change(Actor actor, Change change) throws Exception {
var candidate = new LinkedHashMap<>(entries);
boolean[] dirty = {false};
Entry result = Community.apply(new Transaction() {
public int followers(String id) {return members(id).size();}
public Entry get(String id) { return candidate.get(id); }
public void save(Entry entry, boolean insert) { candidate.put(entry.id(), entry); dirty[0] = true; }
}, actor, change, clock.millis());
if (dirty[0]) {
var root = new JsonObject(); root.addProperty("version", VERSION); root.addProperty("serverId", serverId);
root.add("entries", JSON.toJsonTree(candidate.values()));
previous = AtomicJson.write(path, previous, root.toString(), LIMIT);
entries = candidate;
persist(candidate,result.status().equals("hidden")?followers.stream().filter(f->!f.noticeId().equals(result.id())).toList():followers);
}
return result;
}
private void persist(Map<String,Entry> candidate,List<CommunityTask.Follow> nextFollowers) throws Exception {
var root=new JsonObject();root.addProperty("version",VERSION);root.addProperty("serverId",serverId);
root.add("entries",JSON.toJsonTree(candidate.values()));root.add("followers",JSON.toJsonTree(nextFollowers));
if(previous!=null && legacy<VERSION) {
Path backup=path.resolveSibling(path.getFileName()+".v"+legacy+".bak");
if(Files.exists(backup)) {if(!Arrays.equals(AtomicJson.read(backup,LIMIT),previous))throw new java.io.IOException("Different migration backup");}
else Files.write(backup,previous,StandardOpenOption.CREATE_NEW);
}
previous=AtomicJson.write(path,previous,root.toString(),LIMIT);entries=candidate;followers=nextFollowers;legacy=VERSION;
}
@Override public synchronized List<CommunityTask.Follow> members(String id) {return followers.stream().filter(f->f.noticeId().equals(id)).toList();}
@Override public synchronized List<Entry> following(Actor actor) {
return followers.stream().filter(f->f.playerId().equals(actor.id())).sorted(Comparator.comparingLong(CommunityTask.Follow::since).reversed().thenComparing(CommunityTask.Follow::noticeId))
.map(f->entries.get(f.noticeId())).filter(e->e!=null&&!e.status().equals("hidden")).toList();
}
@Override public synchronized void follow(Actor actor,String id,boolean join) throws Exception {
Entry entry=entries.get(uuid(id));if(entry==null)fail("missing");
CommunityTask.canFollow(entry,actor,join,members(id),(int)followers.stream().filter(f->f.playerId().equals(actor.id())).count());
boolean exists=followers.stream().anyMatch(f->f.noticeId().equals(id)&&f.playerId().equals(actor.id()));
if(exists==join)return;
var next=new ArrayList<>(followers);
if(join)next.add(new CommunityTask.Follow(id,actor.id(),actor.name(),clock.millis()));
else next.removeIf(f->f.noticeId().equals(id)&&f.playerId().equals(actor.id()));
persist(entries,next);
}
}
@@ -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);
}
}
@@ -9,6 +9,7 @@ import static fr.koka.sanctuary.community.Community.*;
/** JDBC stays on the community worker. No credentials or raw SQL errors reach players. */
public final class SqlCommunityStore implements CommunityStore {
private static final com.google.gson.Gson JSON=new com.google.gson.Gson();
private final String url, user, password, serverId;
private final Clock clock;
public SqlCommunityStore(String url, String user, String password, String serverId) throws Exception {
@@ -35,33 +36,36 @@ public final class SqlCommunityStore implements CommunityStore {
return new Entry(r.getString("id"), Objects.requireNonNullElse(r.getString("parent_id"), ""), r.getString("kind"),
r.getString("category"), r.getString("author_id"), r.getString("author_name"), r.getString("title"),
r.getString("body"), r.getString("status"), r.getBoolean("pinned"), r.getInt("revision"),
r.getLong("created_at"), r.getLong("updated_at"));
r.getLong("created_at"), r.getLong("updated_at"), r.getString("photo"), r.getString("task")==null?null:JSON.fromJson(r.getString("task"),CommunityTask.class));
}
private Entry get(Connection c, String id, boolean lock) throws SQLException {
try (var s = query(c, "SELECT * FROM sanctuary_community_entries WHERE server_id=? AND id=?" + (lock ? " FOR UPDATE" : ""), serverId, id);
var r = s.executeQuery()) { return r.next() ? row(r) : null; }
}
private Page list(Connection c, String kind, String parent, int page) throws Exception {
private Page list(Connection c, String kind, String parent, int page, String search) throws Exception {
String needle=Community.search(search);
page(page); if (!KINDS.contains(kind)) fail("invalid");
if (!parent.isEmpty()) visible(get(c, uuid(parent), false));
String parentSql = parent.isEmpty() ? "parent_id IS NULL" : "parent_id=?";
String order = kind.equals("reply") ? "created_at ASC,id ASC" : kind.equals("bulletin") ? "created_at DESC,id DESC" : "pinned DESC,created_at DESC,id DESC";
var values = new ArrayList<Object>(List.of(serverId, kind)); if (!parent.isEmpty()) values.add(parent);
String filter=needle.isEmpty()?"":" AND (LOCATE(?,BINARY LOWER(author_name))>0 OR LOCATE(?,BINARY LOWER(title))>0 OR LOCATE(?,BINARY LOWER(body))>0)";
if(!needle.isEmpty())values.addAll(List.of(needle,needle,needle));
values.add(PAGE_SIZE + 1); values.add(page * PAGE_SIZE);
try (var s = query(c, "SELECT * FROM sanctuary_community_entries WHERE server_id=? AND kind=? AND " + parentSql
+ " AND status<>'hidden' ORDER BY " + order + " LIMIT ? OFFSET ?", values.toArray()); var r = s.executeQuery()) {
+ " AND status<>'hidden'" + filter + " ORDER BY " + order + " LIMIT ? OFFSET ?", values.toArray()); var r = s.executeQuery()) {
var entries = new ArrayList<Entry>(); while (r.next()) entries.add(row(r));
return new Page(List.copyOf(entries.subList(0, Math.min(PAGE_SIZE, entries.size()))), page, entries.size() > PAGE_SIZE);
}
}
@Override public Page list(String kind, String parent, int page) throws Exception {
try (var c = connect()) { c.setAutoCommit(false); var result = list(c, kind, parent, page); c.commit(); return result; }
@Override public Page list(String kind, String parent, int page, String search) throws Exception {
try (var c = connect()) { c.setAutoCommit(false); var result = list(c, kind, parent, page, search); c.commit(); return result; }
}
@Override public ThreadView thread(String id, int page) throws Exception {
try (var c = connect()) {
c.setAutoCommit(false); Entry entry = visible(get(c, uuid(id), false));
if (entry.kind().equals("reply")) fail("missing");
var result = new ThreadView(entry, list(c, "reply", id, page)); c.commit(); return result;
var result = new ThreadView(entry, list(c, "reply", id, page, "")); c.commit(); return result;
}
}
@Override public Entry change(Actor actor, Change change) throws Exception {
@@ -69,14 +73,16 @@ public final class SqlCommunityStore implements CommunityStore {
c.setAutoCommit(false);
try {
Entry result = Community.apply(new Transaction() {
public int followers(String id) throws Exception {return members(c,id).size();}
public Entry get(String id) throws Exception { return SqlCommunityStore.this.get(c, id, true); }
public void save(Entry e, boolean insert) throws Exception {
if(e.status().equals("hidden"))try(var s=query(c,"DELETE FROM sanctuary_community_followers WHERE server_id=? AND notice_id=?",serverId,e.id())){s.executeUpdate();}
if (insert) {
try (var s = query(c, "INSERT INTO sanctuary_community_entries (server_id,id,parent_id,kind,category,author_id,author_name,title,body,status,pinned,revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
serverId, e.id(), e.parentId().isEmpty() ? null : e.parentId(), e.kind(), e.category(), e.authorId(), e.authorName(), e.title(), e.body(), e.status(), e.pinned(), e.revision(), e.createdAt(), e.updatedAt())) { s.executeUpdate(); }
try (var s = query(c, "INSERT INTO sanctuary_community_entries (server_id,id,parent_id,kind,category,author_id,author_name,title,body,status,pinned,revision,created_at,updated_at,photo,task) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
serverId, e.id(), e.parentId().isEmpty() ? null : e.parentId(), e.kind(), e.category(), e.authorId(), e.authorName(), e.title(), e.body(), e.status(), e.pinned(), e.revision(), e.createdAt(), e.updatedAt(), e.photo().isEmpty()?null:e.photo(),e.task()==null?null:JSON.toJson(e.task()))) { s.executeUpdate(); }
} else {
try (var s = query(c, "UPDATE sanctuary_community_entries SET title=?,body=?,status=?,pinned=?,revision=?,updated_at=? WHERE server_id=? AND id=? AND revision=?",
e.title(), e.body(), e.status(), e.pinned(), e.revision(), e.updatedAt(), serverId, e.id(), e.revision() - 1)) {
try (var s = query(c, "UPDATE sanctuary_community_entries SET task=?,category=?,photo=?,title=?,body=?,status=?,pinned=?,revision=?,updated_at=? WHERE server_id=? AND id=? AND revision=?",
e.task()==null?null:JSON.toJson(e.task()), e.category(), e.photo().isEmpty()?null:e.photo(), e.title(), e.body(), e.status(), e.pinned(), e.revision(), e.updatedAt(), serverId, e.id(), e.revision() - 1)) {
if (s.executeUpdate() != 1) fail("conflict");
}
}
@@ -86,6 +92,35 @@ public final class SqlCommunityStore implements CommunityStore {
} catch (Exception error) { c.rollback(); throw error; }
}
}
private List<CommunityTask.Follow> members(Connection c,String id) throws Exception {
try(var s=query(c,"SELECT * FROM sanctuary_community_followers WHERE server_id=? AND notice_id=? ORDER BY subscribed_at,player_id",serverId,id);var r=s.executeQuery()) {
var result=new ArrayList<CommunityTask.Follow>();while(r.next())result.add(new CommunityTask.Follow(id,r.getString("player_id"),r.getString("player_name"),r.getLong("subscribed_at")));return result;
}
}
@Override public List<CommunityTask.Follow> members(String id) throws Exception {try(var c=connect()){return members(c,uuid(id));}}
@Override public List<Entry> following(Actor actor) throws Exception {
try(var c=connect();var s=query(c,"SELECT e.* FROM sanctuary_community_entries e JOIN sanctuary_community_followers f ON e.server_id=f.server_id AND e.id=f.notice_id WHERE e.server_id=? AND f.player_id=? AND e.status<>'hidden' ORDER BY f.subscribed_at DESC,e.id LIMIT 16",serverId,actor.id());var r=s.executeQuery()) {
var result=new ArrayList<Entry>();while(r.next())result.add(row(r));return List.copyOf(result);
}
}
@Override public void follow(Actor actor,String id,boolean join) throws Exception {
uuid(id);
try(var c=connect()) {
c.setAutoCommit(false);
try {
Entry entry=get(c,id,true);if(entry==null)fail("missing");
try(var s=query(c,"INSERT IGNORE INTO sanctuary_community_subscribers(server_id,player_id) VALUES (?,?)",serverId,actor.id())){s.executeUpdate();}
try(var s=query(c,"SELECT player_id FROM sanctuary_community_subscribers WHERE server_id=? AND player_id=? FOR UPDATE",serverId,actor.id());var r=s.executeQuery()){r.next();}
int count;
try(var s=query(c,"SELECT COUNT(*) FROM sanctuary_community_followers WHERE server_id=? AND player_id=?",serverId,actor.id());var r=s.executeQuery()){r.next();count=r.getInt(1);}
var members=members(c,id);CommunityTask.canFollow(entry,actor,join,members,count);
boolean exists=members.stream().anyMatch(f->f.playerId().equals(actor.id()));
if(join&&!exists)try(var s=query(c,"INSERT INTO sanctuary_community_followers(server_id,notice_id,player_id,player_name,subscribed_at) VALUES (?,?,?,?,?)",serverId,id,actor.id(),actor.name(),clock.millis())){s.executeUpdate();}
if(!join&&exists)try(var s=query(c,"DELETE FROM sanctuary_community_followers WHERE server_id=? AND notice_id=? AND player_id=?",serverId,id,actor.id())){s.executeUpdate();}
c.commit();
}catch(Exception e){c.rollback();throw e;}
}
}
@Override public void link(Actor actor, String code) throws Exception {
if (!code.matches("[0-9a-f]{32}")) fail("invalid");
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(code.getBytes(StandardCharsets.US_ASCII)));
@@ -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; }
}
}
@@ -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.",
@@ -2341,6 +2356,13 @@
"sanctuary.shaders.ssr_distance_help": "Independent SSR range: 16, 32, 64, 128 or 256 blocks. Default: 64 blocks. Limited to visible terrain and mesh budget.",
"sanctuary.community.article": "Gazette",
"sanctuary.community.notice": "Notice board",
"sanctuary.community.notice_tab": "Notice board",
"sanctuary.community.map_followed": "Followed request · click for discussion",
"sanctuary.community.map_more_materials": "+ %s more materials",
"sanctuary.community.map_show": "Show on map",
"sanctuary.community.map_other_dimension": "Visit this dimension to open its map.",
"sanctuary.community.search": "Search: player, title or text…",
"sanctuary.community.server_tab": "Server",
"sanctuary.community.bulletin": "Server notices",
"sanctuary.community.publish": "Publish",
"sanctuary.community.reply": "Reply",
@@ -2394,5 +2416,51 @@
"sanctuary.pause.map": "World map",
"sanctuary.pause.map_hint": "Open the map and explore",
"sanctuary.community.more": "Load more",
"sanctuary.community.browse": "All publications"
"sanctuary.community.browse": "All publications",
"sanctuary.community.photo": "Article photo",
"sanctuary.community.photo_choose": "Choose a screenshot…",
"sanctuary.community.photo_change": "Change photo…",
"sanctuary.community.photo_assign": "Assign to article",
"sanctuary.community.photo_required": "Choose a photo to publish this article.",
"sanctuary.community.photo_invalid": "Photo cannot be read or is too large.",
"sanctuary.community.photo_uploading": "Uploading photo…",
"sanctuary.community.screenshots": "Screenshots",
"sanctuary.community.screenshots_back": "Back to screenshots",
"sanctuary.community.screenshots_empty": "No screenshots. Take a photo in game with F2, then return here.",
"sanctuary.community.screenshots_error": "Could not read screenshots.",
"sanctuary.community.photo_unavailable": "Photo unavailable",
"sanctuary.community.task_configure": "Request and rewards",
"sanctuary.community.task_summary": "%s materials · %s",
"sanctuary.community.location_current": "Use my location",
"sanctuary.community.location_remove": "Remove location",
"sanctuary.community.location_value": "%s · %s, %s, %s",
"sanctuary.community.materials": "Requested materials",
"sanctuary.community.material_search": "Search materials",
"sanctuary.community.selected_materials": "Descriptive chest · %s / %s",
"sanctuary.community.quantity": "Quantity",
"sanctuary.community.remove_material": "Remove",
"sanctuary.community.audience.shared": "Multiple participants · shared reward",
"sanctuary.community.audience.single": "One participant",
"sanctuary.community.reward.none": "No reward",
"sanctuary.community.reward.items": "Item reward",
"sanctuary.community.reward.custom": "Custom reward",
"sanctuary.community.rewards_choose": "Choose reward items",
"sanctuary.community.exchange_help": "Descriptive tracking: players arrange exchanges and reward sharing themselves.",
"sanctuary.community.followers": "%s follower(s)",
"sanctuary.community.follow": "Follow request",
"sanctuary.community.unfollow": "Unfollow",
"sanctuary.community.followed": "Following request: %s",
"sanctuary.community.task_invalid": "Check materials, quantities and reward.",
"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.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.",
@@ -2341,6 +2356,13 @@
"sanctuary.shaders.ssr_distance_help": "Portée indépendante du SSR : 16, 32, 64, 128 ou 256 blocs. Défaut : 64 blocs. Limitée au terrain visible et au budget de maillage.",
"sanctuary.community.article": "Gazette",
"sanctuary.community.notice": "Tableau",
"sanctuary.community.notice_tab": "Tableau",
"sanctuary.community.map_followed": "Demande suivie · cliquer pour la discussion",
"sanctuary.community.map_more_materials": "+ %s autres matériaux",
"sanctuary.community.map_show": "Voir sur la carte",
"sanctuary.community.map_other_dimension": "Rejoignez cette dimension pour ouvrir sa carte.",
"sanctuary.community.search": "Rechercher : joueur, titre ou texte…",
"sanctuary.community.server_tab": "Serveur",
"sanctuary.community.bulletin": "Intendance",
"sanctuary.community.publish": "Publier",
"sanctuary.community.reply": "Répondre",
@@ -2393,6 +2415,52 @@
"sanctuary.community.reply_hint": "Écrire une réponse…",
"sanctuary.pause.map": "Carte du monde",
"sanctuary.pause.map_hint": "Ouvrir la carte et explorer",
"sanctuary.community.more": "Afficher la suite",
"sanctuary.community.browse": "Toutes les publications"
"sanctuary.community.more": "Charger la suite",
"sanctuary.community.browse": "Toutes les publications",
"sanctuary.community.photo": "Photo de l’article",
"sanctuary.community.photo_choose": "Choisir une capture…",
"sanctuary.community.photo_change": "Changer la photo…",
"sanctuary.community.photo_assign": "Assigner à l’article",
"sanctuary.community.photo_required": "Choisissez une photo pour publier cet article.",
"sanctuary.community.photo_invalid": "Photo illisible ou trop grande.",
"sanctuary.community.photo_uploading": "Envoi de la photo…",
"sanctuary.community.screenshots": "Captures d’écran",
"sanctuary.community.screenshots_back": "Retour aux captures",
"sanctuary.community.screenshots_empty": "Aucune capture. Prenez une photo en jeu avec F2, puis revenez ici.",
"sanctuary.community.screenshots_error": "Impossible de lire les captures.",
"sanctuary.community.photo_unavailable": "Photo indisponible",
"sanctuary.community.task_configure": "Demande et récompenses",
"sanctuary.community.task_summary": "%s matériaux · %s",
"sanctuary.community.location_current": "Utiliser ma position",
"sanctuary.community.location_remove": "Retirer la localisation",
"sanctuary.community.location_value": "%s · %s, %s, %s",
"sanctuary.community.materials": "Matériaux demandés",
"sanctuary.community.material_search": "Rechercher un matériau",
"sanctuary.community.selected_materials": "Coffre descriptif · %s / %s",
"sanctuary.community.quantity": "Quantité",
"sanctuary.community.remove_material": "Retirer",
"sanctuary.community.audience.shared": "Plusieurs participants · partage",
"sanctuary.community.audience.single": "Un seul participant",
"sanctuary.community.reward.none": "Sans récompense",
"sanctuary.community.reward.items": "Récompense en objets",
"sanctuary.community.reward.custom": "Récompense personnalisée",
"sanctuary.community.rewards_choose": "Choisir les objets proposés",
"sanctuary.community.exchange_help": "Suivi descriptif : les joueurs organisent les échanges et le partage des récompenses entre eux.",
"sanctuary.community.followers": "%s participant(s)",
"sanctuary.community.follow": "Suivre la demande",
"sanctuary.community.unfollow": "Ne plus suivre",
"sanctuary.community.followed": "Demande suivie : %s",
"sanctuary.community.task_invalid": "Vérifiez les matériaux, les quantités et la récompense.",
"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.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"
}
@@ -0,0 +1,3 @@
-- Stop both applications and back up the database first. Apply once to schema v1.
ALTER TABLE sanctuary_community_entries ADD COLUMN photo MEDIUMTEXT CHARACTER SET ascii COLLATE ascii_bin NULL;
UPDATE sanctuary_community_schema SET version=2 WHERE id=1 AND version=1;
@@ -0,0 +1,13 @@
-- Stop both writers, back up, and apply once to schema v2.
ALTER TABLE sanctuary_community_entries ADD COLUMN task TEXT NULL;
CREATE TABLE sanctuary_community_subscribers (
server_id CHAR(36) NOT NULL, player_id CHAR(36) NOT NULL,
PRIMARY KEY(server_id,player_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
CREATE TABLE sanctuary_community_followers (
server_id CHAR(36) NOT NULL, notice_id CHAR(36) NOT NULL,
player_id CHAR(36) NOT NULL, player_name VARCHAR(64) NOT NULL, subscribed_at BIGINT NOT NULL,
PRIMARY KEY(server_id,notice_id,player_id),
KEY community_followed(server_id,player_id,subscribed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
UPDATE sanctuary_community_schema SET version=3 WHERE id=1 AND version=2;
@@ -289,7 +289,8 @@
"client.NaturalWaterFogColorMixin",
"client.NaturalWaterSectionCopyAccess",
"client.PauseAtlasServerMixin",
"client.CommunityPauseMixin"
"client.CommunityPauseMixin",
"client.PlayerListKeyMixin"
],
"injectors": {
"defaultRequire": 1
@@ -15,9 +15,10 @@ public final class CommunityInterop {
JsonObject fixture;
if(phase.equals("seed")) {
fixture=new JsonObject();
for(String key:new String[]{"server","player","article"})fixture.addProperty(key,UUID.randomUUID().toString());
for(String key:new String[]{"server","player","article","notice"})fixture.addProperty(key,UUID.randomUUID().toString());
var store=new SqlCommunityStore(url,"root","",fixture.get("server").getAsString());
store.change(new Actor(fixture.get("player").getAsString(),"Minecraft 🌳",false), new Change("create",fixture.get("article").getAsString(),"","article","info","Depuis Minecraft","Un pont vers le site 🌳",0));
store.change(new Actor(fixture.get("player").getAsString(),"Minecraft 🌳",false), new Change("create",fixture.get("article").getAsString(),"","article","info","Depuis Minecraft","Un pont vers le site 🌳",0,CommunitySmoke.PHOTO));
store.change(new Actor(UUID.randomUUID().toString(),"Voisin",false),new Change("create",fixture.get("notice").getAsString(),"","notice","need","Le pont","Bois demandé",0,null,new CommunityTask(new CommunityTask.Location("minecraft:overworld",12,64,-4),java.util.List.of(new CommunityTask.Material("minecraft:oak_log",64)),"single","custom",java.util.List.of(),"Un repas")));
Files.writeString(file,json.toJson(fixture));
} else {
fixture=JsonParser.parseString(Files.readString(file)).getAsJsonObject();
@@ -30,8 +31,14 @@ public final class CommunityInterop {
CommunitySmoke.refuse("link_expired",()->store.link(actor,code));
CommunitySmoke.refuse("link_expired",()->new SqlCommunityStore(url,"root","",UUID.randomUUID().toString()).link(actor,code));
} else if(phase.equals("verify")) {
var notice=store.thread(fixture.get("notice").getAsString(),0).entry();
CommunitySmoke.check(store.following(actor).stream().anyMatch(e->e.id().equals(notice.id())),"Website subscription visible in Java");
var webNotice=store.thread(fixture.get("web_notice").getAsString(),0).entry();
CommunitySmoke.check(webNotice.task().materials().getFirst().count()==128 && webNotice.task().rewards().getFirst().item().equals("minecraft:diamond"),"Website task descriptors read in Java");
var thread=store.thread(fixture.get("article").getAsString(),0);
CommunitySmoke.check(thread.entry().revision()==2 && thread.entry().body().equals("Modifié depuis le site 🌳"),"Website edit visible in Java");
CommunityPhoto.validate(store.thread(fixture.get("web_article").getAsString(),0).entry().photo());
CommunitySmoke.check(!thread.entry().photo().isEmpty(),"Photo preserved by website edit");
CommunitySmoke.check(thread.replies().entries().size()==1 && thread.replies().entries().getFirst().body().equals("Réponse depuis le site"),"Website reply visible in Java");
CommunitySmoke.refuse("conflict",()->store.change(actor,new Change("edit",thread.entry().id(),"","article","info","Depuis Minecraft","Ancien brouillon",1)));
} else throw new IllegalArgumentException(phase);
@@ -8,11 +8,70 @@ public final class CommunitySmoke {
static Actor owner=new Actor(UUID.randomUUID().toString(),"Auteur",false);
static Actor other=new Actor(UUID.randomUUID().toString(),"Voisin",false);
static Actor moderator=new Actor(UUID.randomUUID().toString(),"Admin",true);
static Change create(String id,String parent,String kind,String title,String body) { return new Change("create",id,parent,kind,"info",title,body,0); }
public static String photo() {
try {var image=new java.awt.image.BufferedImage(960,540,java.awt.image.BufferedImage.TYPE_INT_RGB);
var random=new Random(157);for(int y=0;y<540;y++)for(int x=0;x<960;x++)image.setRGB(x,y,random.nextInt());
return CommunityPhoto.encode(image,960,540);
} catch(Exception e) {throw new AssertionError(e);}
}
static final String PHOTO=photo();
static Change create(String id,String parent,String kind,String title,String body) { return new Change("create",id,parent,kind,"info",title,body,0,kind.equals("article")?PHOTO:null); }
static Change action(String action,Entry e) { return new Change(action,e.id(),e.parentId(),e.kind(),e.category(),e.title(),e.body(),e.revision()); }
static void check(boolean ok,String why) { if(!ok)throw new AssertionError(why); }
interface Work { void run() throws Exception; }
static void refuse(String code,Work work) throws Exception { try { work.run(); throw new AssertionError("Expected "+code); } catch(Refused e) { check(e.code.equals(code),"Expected "+code+", got "+e.code); } }
static Actor tasks(CommunityStore store) throws Exception {
var task=new CommunityTask(new CommunityTask.Location("minecraft:overworld",12,64,-4),List.of(new CommunityTask.Material("minecraft:oak_log",128)),"single","items",List.of(new CommunityTask.Material("minecraft:diamond",2)),"");
var post=store.change(owner,new Change("create",UUID.randomUUID().toString(),"","notice","need","Le pont","Du bois",0,null,task));
refuse("own_notice",()->store.follow(owner,post.id(),true));
var third=new Actor(UUID.randomUUID().toString(),"Troisième",false);
try(var pool=java.util.concurrent.Executors.newFixedThreadPool(2)) {
var start=new java.util.concurrent.CountDownLatch(1);
var futures=new ArrayList<java.util.concurrent.Future<Boolean>>();
for(var actor:List.of(other,third))futures.add(pool.submit(()->{start.await();try{store.follow(actor,post.id(),true);return true;}catch(Refused e){check(e.code.equals("notice_full"),"Only full is expected");return false;}}));
start.countDown();int accepted=0;for(var future:futures)if(future.get())accepted++;
check(accepted==1 && store.members(post.id()).size()==1,"Atomic exclusive follow");
}
var winner=store.members(post.id()).getFirst().playerId().equals(other.id())?other:third;
store.follow(winner,post.id(),true);check(store.following(winner).stream().anyMatch(e->e.id().equals(post.id())&&e.task().equals(task)),"Persistent descriptors in following snapshot");
var closed=store.change(owner,action("close",post));check(store.members(post.id()).size()==1,"Close retains subscriptions");
refuse("closed",()->store.follow(winner==other?third:other,post.id(),true));
store.follow(winner,post.id(),false);check(store.members(post.id()).isEmpty(),"Unfollow closed notice");
var opened=store.change(owner,action("reopen",closed));
var shared=new CommunityTask(task.location(),task.materials(),"shared","none",List.of(),"");
var changed=store.change(owner,new Change("edit",opened.id(),"","notice","event",opened.title(),opened.body(),opened.revision(),null,shared));
store.follow(other,post.id(),true);store.follow(third,post.id(),true);
refuse("notice_full",()->store.change(owner,new Change("edit",changed.id(),"","notice","need",changed.title(),changed.body(),changed.revision(),null,task)));
store.change(owner,action("hide",changed));check(store.members(post.id()).isEmpty(),"Hidden removes subscriptions");
var limited=new Actor(UUID.randomUUID().toString(),"Limited",false);
for(int i=0;i<16;i++){var e=store.change(owner,create(UUID.randomUUID().toString(),"","notice","N"+i,"B"));store.follow(limited,e.id(),true);}
var extra=store.change(owner,create(UUID.randomUUID().toString(),"","notice","Extra","B"));refuse("follow_limit",()->store.follow(limited,extra.id(),true));
refuse("task_invalid",()->new CommunityTask.Material("minecraft:air",1));refuse("task_invalid",()->new CommunityTask.Material("minecraft:oak_log",0));
check(new com.google.gson.GsonBuilder().disableHtmlEscaping().create().toJson(task).length()<8000,"Task fits request envelope");
System.out.println("COMMUNITY158_TASKS_PASS "+store.getClass().getSimpleName());return limited;
}
static void search(CommunityStore store) throws Exception {
var author=new Actor(UUID.randomUUID().toString(),"Chercheur_159",false);
Entry first=null;
for(int i=0;i<13;i++) {
var e=store.change(author,create(UUID.randomUUID().toString(),"","notice","Recherche159 "+i,i==0?"Épingle 50%_exact":"Description159"));
if(i==0)first=e;
}
var pinned=store.change(moderator,action("pin",first));
check(store.list("notice","",0," CHERCHEUR_159 ").entries().getFirst().id().equals(pinned.id()),"Search author and retain pins");
check(store.list("notice","",0,"recherche159").more()&&store.list("notice","",1,"recherche159").entries().size()==3,"Search before pagination");
check(store.list("notice","",0,"50%_exact").entries().size()==1,"Literal SQL wildcard characters");
check(store.list("notice","",0,"ÉPINGLE").entries().size()==1,"Accented case-insensitive body search");
check(store.list("notice","",0,"%").entries().size()==1,"Percent is literal");
check(store.list("article","",0,"Chercheur_159").entries().isEmpty(),"Search stays within selected section");
store.change(moderator,action("hide",pinned));
check(store.list("notice","",0,"50%_exact").entries().isEmpty(),"Hidden search result excluded");
refuse("invalid",()->store.list("notice","",0,"x".repeat(121)));
var official=store.change(moderator,create(UUID.randomUUID().toString(),"","bulletin","Serveur159","Message officiel"));
check(store.list("bulletin","",0,"serveur159").entries().size()==1 && store.thread(official.id(),0).entry().equals(official),"Read server history");
refuse("forbidden",()->store.change(owner,action("hide",official)));
System.out.println("COMMUNITY159_SEARCH_PASS "+store.getClass().getSimpleName());
}
static Entry scenario(CommunityStore store) throws Exception {
var request=create(UUID.randomUUID().toString(),"","notice","Un pont","Du bois et des bras 🌳");
var post=store.change(owner,request);
@@ -37,15 +96,27 @@ public final class CommunitySmoke {
refuse("missing",()->store.change(other,action("edit",reply)));
refuse("invalid",()->store.change(owner,create(UUID.randomUUID().toString(),"","article","A","§ccommand")));
refuse("invalid",()->store.change(owner,create(UUID.randomUUID().toString(),"","article","A","x".repeat(4001))));
refuse("photo_required",()->store.change(owner,new Change("create",UUID.randomUUID().toString(),"","article","info","A","B",0)));
refuse("photo_invalid",()->store.change(owner,new Change("create",UUID.randomUUID().toString(),"","article","info","A","B",0,"not an image")));
var article=store.change(owner,create(UUID.randomUUID().toString(),"","article","🌳".repeat(120),"Récit"));
check(store.thread(article.id(),0).entry().equals(article),"Unicode text intact");
return article;
check(article.photo().equals(PHOTO),"Assigned photo persisted");
check(store.change(owner,new Change("create",article.id(),"","article","info",article.title(),article.body(),0,PHOTO)).equals(article),"Photo retry is idempotent");
refuse("conflict",()->store.change(owner,new Change("create",article.id(),"","article","info",article.title(),article.body(),0,"changed")));
refuse("photo_invalid",()->CommunityPhoto.validate("A".repeat(CommunityPhoto.MAX_TEXT+1)));
var edited=store.change(owner,new Change("edit",article.id(),"","article","info",article.title(),"Edition",1));
check(edited.photo().equals(PHOTO),"Edit retains photo");
refuse("photo_required",()->store.change(owner,new Change("edit",article.id(),"","article","info",article.title(),"Edition",2,"")));
refuse("forbidden",()->store.change(other,new Change("edit",article.id(),"","article","info",article.title(),"Edition",2,PHOTO)));
return edited;
}
public static void main(String[] args) throws Exception {
Path dir=Files.createTempDirectory("sanctuary-community-check-");
String server=UUID.randomUUID().toString();Path file=dir.resolve("community.json");
var store=new FileCommunityStore(file,server);var article=scenario(store);
var follower=tasks(store);search(store);
var reopened=new FileCommunityStore(file,server);
check(reopened.following(follower).equals(store.following(follower)) && reopened.following(follower).size()==16,"Follow state survives restart");
check(reopened.thread(article.id(),0).entry().equals(article),"Durable restart");
Files.writeString(file,"broken");
try { reopened.change(owner,create(UUID.randomUUID().toString(),"","article","A","B"));throw new AssertionError("Overwrite corrupt file"); }catch(java.io.IOException expected){}
@@ -53,7 +124,7 @@ public final class CommunitySmoke {
String url=System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC");
if(url!=null){
var sql=new SqlCommunityStore(url,"root","",server);
var value=scenario(sql);
var value=scenario(sql);tasks(sql);search(sql);
check(new SqlCommunityStore(url,"root","",server).thread(value.id(),0).entry().equals(value),"Database restart");
var isolated=new SqlCommunityStore(url,"root","",UUID.randomUUID().toString());
check(isolated.list("notice","",0).entries().isEmpty(),"Different server isolated");
@@ -72,6 +143,28 @@ public final class CommunitySmoke {
refuse("already_linked",()->sql.link(owner,reassignment));
System.out.println("COMMUNITY154_SQL_PASS");
}
String operation=UUID.randomUUID().toString();
var transfer=new CommunityPhoto.Transfer(operation,2,1000);
transfer.append(operation,0,2,"abc",1000);
refuse("photo_invalid",()->transfer.append(operation,0,2,"abc",1001));
refuse("photo_required",()->transfer.value(operation,1001));
transfer.append(operation,1,2,"def",1002);
check(transfer.value(operation,1003).equals("abcdef"),"Ordered transfer");
refuse("photo_required",()->transfer.value(UUID.randomUUID().toString(),1003));
refuse("photo_required",()->transfer.value(operation,121001));
for(int oldVersion:List.of(1,2)) {
Path legacy=dir.resolve("legacy"+oldVersion+".json");
var root=new com.google.gson.JsonObject();root.addProperty("version",oldVersion);root.addProperty("serverId",server);
var entries=new com.google.gson.JsonArray();var old=new com.google.gson.Gson().toJsonTree(article).getAsJsonObject();old.remove("photo");entries.add(old);root.add("entries",entries);
String before=root.toString();Files.writeString(legacy,before);
var migrated=new FileCommunityStore(legacy,server);
check(migrated.thread(article.id(),0).entry().photo().isEmpty(),"Old text article readable");
refuse("photo_required",()->migrated.change(owner,action("edit",migrated.thread(article.id(),0).entry())));
migrated.change(owner,new Change("edit",article.id(),"","article","info",article.title(),article.body(),article.revision(),PHOTO));
check(Files.readString(dir.resolve("legacy"+oldVersion+".json.v"+oldVersion+".bak")).equals(before),"Exact migration backup");
check(new FileCommunityStore(legacy,server).thread(article.id(),0).entry().photo().equals(PHOTO),"Migrated photo persists");
}
System.out.println("COMMUNITY157_PHOTOS_PASS");
System.out.println("COMMUNITY154_FILE_PASS");
}
}
@@ -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
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.155"
version = "beta.166"
pack-format = "packwiz:1.1.0"
[index]

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