Compare commits

...
Author SHA1 Message Date
koka 00d175cb98 Deliver beta.235 as a verified reversible Pause layout experiment
Build Sanctuary / build (push) Waiting to run
2026-10-06 14:21:31 +02:00
koka a7bdfe6998 Try parallel Pause groups before Atlas and a narrow stack beside the map 2026-10-06 14:16:40 +02:00
koka f9c2ce8835 Deliver beta.234 with verified Story catalogue and local pack
Build Sanctuary / build (push) Waiting to run
2026-10-06 04:50:30 +02:00
koka 3d56d7de7f Verify Story menus under Vulkan and document story authoring 2026-10-06 04:49:56 +02:00
koka f2ac23b4f4 Add Story catalogue with search and discovery filters 2026-10-06 04:46:02 +02:00
koka 9c096b2563 Deliver beta.233 with verified community forms and Vulkan layouts
Build Sanctuary / build (push) Canceled after 0s
2026-10-06 03:59:50 +02:00
koka bc27606b3e Simplify notices and place Gazette photos before the title 2026-10-06 03:57:17 +02:00
koka 8531bb3476 Group pause navigation consistently with and without the map 2026-10-06 03:57:17 +02:00
koka 8e9b8fbd62 Deliver beta.232 with verified solo gameplay and Vulkan client checks 2026-10-06 03:25:17 +02:00
koka 308c0dc2ff Center the locked Atlas pause menu between Gazette and Notice Board 2026-10-06 03:24:47 +02:00
koka 50aaa5c135 Exclude generated diamonds from initial island chest loot 2026-10-06 03:24:47 +02:00
koka cf5699cfe1 Continue held vein mining on the next visible group after server completion 2026-10-06 03:24:46 +02:00
koka d9979100a9 Align placement outlines with building reach from three to seven blocks 2026-10-06 03:24:46 +02:00
koka 5f3d110773 Allow the solo owner to choose a persistent world timezone 2026-10-06 03:24:33 +02:00
39 changed files with 1203 additions and 86 deletions
+51
View File
@@ -6,6 +6,57 @@ Créer un monde **Sanctuary** en Petit, Moyen ou Grand, puis activer les ancres
correspondantes. Le module Sanctuary Test n’est pas requis.
[Intégration et vérification du pack beta.224](docs/four-islands-base224.md).
## beta.235 — Essai réversible du menu Pause
[Disposition adaptative](docs/pause-columns-beta235.md) : avant Atlas, Aventure
à gauche et Communauté à droite, avec Partie / Game juste dessous. Avec Atlas,
une colonne étroite à gauche, Communauté → Aventure → Game, carte au centre.
Bloc centré verticalement, libellés de navigation entiers et ordre clavier
adapté. Parcours Pause et Story Vulkan FR/EN GUI 2/3/4, `check build assemblePack`
et quatre GameTests ciblés réussis.
Le commit de disposition `a7bdfe6` peut être annulé indépendamment de la
livraison. [MRpack local beta.235](build/Sanctuary-beta.235.mrpack) vérifié,
sans publication ni mise à jour personnelle. Le rendu reste à apprécier.
## beta.234 — Story, recherche et filtres
[Premier menu Story](docs/story-beta234.md), sous Progression dans Aventure :
100 histoires inconnues et désactivées, recherche instantanée et filtre
Toutes/Découvertes/Inconnues, cadre de Discovery et retour à Pause. Saisie et
défilement conservés au redimensionnement ; trois boutons Aventure centrés
avec ou sans Atlas. [Guide](docs/story/README.md) et
[modèle Markdown](docs/story/TEMPLATE.md) pour les futurs récits personnels.
Textes et déblocages restent à intégrer. `check build assemblePack`, quatre
GameTests requis et parcours Vulkan FR/EN GUI 2/3/4 réussis.
[MRpack local beta.234](build/Sanctuary-beta.234.mrpack) vérifié ; aucune
publication ni mise à jour d'installation personnelle.
## beta.233 — Navigation centrée et rédaction simplifiée
[Ajustements du menu et des formulaires](docs/pause-community-beta233.md) : même
ordre avec et sans carte, Communauté (Habitant/Faction/Battle) en haut,
Aventure (Discovery/Progression) au centre et Partie (options/sortie) en bas,
sans barre sous Communauté. Notice Board sans choix de catégorie, notices
uniformes, demandes et récompenses sous le texte, puis Publish. Photo et
Choose a screenshot de la Gazette avant le titre. Brouillons et anciennes
catégories conservés ; parcours de publication et essais Vulkan FR/EN réussis.
`check build assemblePack` avec la suite menus : quatre GameTests requis et
contrôles communautaires réussis. [MRpack local beta.233](build/Sanctuary-beta.233.mrpack)
vérifié ; aucune installation personnelle ni mise à jour du canal.
## beta.232 — Session solo et progression
[Réglages et correctifs](docs/solo-gameplay-beta232.md) : fuseau persistant via
`/sanctuary time timezone Europe/Paris` ou `auto`, disponible au propriétaire
solo sans commandes opérateur. Portée de Construction de 3 à 7 blocs, contour
et pose cohérents ; vein mining maintenu enchaînant sur le bloc regardé.
Aucun diamant généré par les coffres de l'île initiale ; inventaires déjà
remplis et terrain conservés. Avant Atlas : Gazette à gauche, huit boutons
centrés, Notice Board à droite, aucune World Map. Build, 26 GameTests requis
et essais client Vulkan FR/EN réussis.
[MRpack local beta.232](build/Sanctuary-beta.232.mrpack) vérifié ; aucune
installation personnelle ni mise à jour du canal.
## beta.231 — Sous-bois pâles et archipel complet
[Correctif et visite](docs/diagonal-understory-beta231.md) : herbes, fleurs,
+21
View File
@@ -1,5 +1,26 @@
# Backlog Sanctuary
## PAUSE-235 — groupes côte à côte avant Atlas — essai livré localement
Branche `codex/pause-columns-beta235`, base beta.234. Avant Atlas : Aventure à
gauche, Communauté à droite et Game juste dessous, bloc centré. Avec Atlas :
une colonne étroite à gauche dans l'ordre Communauté → Aventure → Game,
carte centrale et fils à droite. [Contrat et vérifications](pause-columns-beta235.md),
parcours Vulkan Pause et Story FR/EN GUI 2/3/4, build, quatre GameTests ciblés
et MRpack local vérifiés ; rendu à apprécier.
Commit de disposition `a7bdfe6` séparé des métadonnées pour permettre son retour.
## STORY-234 — recueil personnel avec recherche — livré localement
Branche `codex/story-beta234`, base beta.233. Story sous Progression dans
Aventure ; 100 histoires inconnues, barre de recherche et filtre de découverte.
Cadre commun avec Discovery, état de recherche et défilement conservés au
redimensionnement, accès avant et après Atlas. [Résultat et vérifications](story-beta234.md) :
build, quatre GameTests requis et parcours Vulkan FR/EN GUI 2/3/4 réussis.
[Modèle Markdown](story/TEMPLATE.md) pour les textes de l'auteur.
Les récits et leurs conditions de découverte restent des tickets suivants ;
aucun moteur de quête, format persistant ou déploiement ajouté.
## WG-RESTORE-191 — annuler les plateaux et leurs affleurements
La visite beta.190 ne valide pas le relief corrigé. [Retour exact à la base
+97
View File
@@ -0,0 +1,97 @@
# PAUSE-235 — Aventure et Communauté côte à côte
Branche d'essai `codex/pause-columns-beta235`, base beta.234, commit `f9c2ce8`,
Minecraft 26.3. Le rendu reste à apprécier par le créateur.
## Disposition et réversibilité
Avant Atlas, Aventure et Communauté occupent deux colonnes au même niveau :
Discovery, Progression et Story à gauche ; Habitant, Faction et Battle à droite.
Partie / Game suit juste dessous avec Options, World Options et la sortie
native. L'ensemble est centré verticalement dans le corps de Pause, avec de
l'espace sous Game ; Reprendre la partie reste dans le pied de page.
Sans Atlas, ce bloc est centré entre Gazette à gauche et Notice Board à droite.
Avec Atlas, une seule colonne étroite est serrée à gauche : Communauté, puis
Aventure, puis Partie / Game, selon l'ordre confirmé par le créateur. La carte
garde la région centrale et les deux fils communautaires restent à droite.
Le bloc complet reste centré verticalement. La largeur minimale des boutons
de navigation tient compte des libellés localisés et de leur marge native.
Aux petites hauteurs, boutons et espacements se compactent. Tab suit l'ordre
visuel : par rangée de gauche à droite avant Atlas, puis de haut en bas avec
Atlas. Les libellés FR/EN existants sont réutilisés.
Le changement de disposition et les assertions correspondantes sont isolés
dans le commit `a7bdfe6`, séparé de la livraison et de ses métadonnées. La branche
`codex/story-beta234` conserve la disposition précédente. Un retour s'effectue
avec `git revert a7bdfe6` ; une éventuelle nouvelle
livraison doit prendre le numéro beta suivant, sans diminuer le compteur.
L'application inverse du patch a été vérifiée avec `git apply --reverse --check`,
sans modifier les fichiers de travail.
Ce ticket ne change ni génération, sauvegarde, données communautaires, protocole,
conditions de progression ou contenus Story. Aucune installation personnelle
ou publication n'est demandée.
## Vérifications
```sh
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true -PsanctuaryPause233ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true -PsanctuaryStory234ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
./gradlew check build assemblePack -PsanctuaryFocusedTests=menus -PsanctuaryAtlasOnly=true
```
Le parcours Pause final réussit en 1 min 22 s (`PAUSE233_PASS`), dans un monde
plat neuf de développement, Survie sans commandes. FR/EN, GUI 2/3/4, fenêtre
1280 × 960, avant et après Atlas : six actions Sanctuary, alignement et absence
de chevauchement, largeur des libellés avec la marge native, ordre de Tab et
Game sous les groupes, avec de l'espace avant le pied. Le test attend désormais
un créneau de requête communautaire pour son amorçage et envoie le code de
raccourci natif de Tab, requis par Minecraft 26.3.
Le parcours communautaire existant passe également : publication et photo,
retours des sélecteurs, brouillons, édition d'une ancienne catégorie, inventaire
réel conservé et aucun changement du fichier communautaire pendant les lectures.
La configuration temporaire est restaurée à la fin du banc.
Log : `build/pause235-client-vulkan-final.log`. 38 captures finales copiées dans
`build/pause235-screenshots/`. Captures inspectées : sans Atlas FR/4, avec Atlas
FR/2 et EN/4. Vulkan confirmé : MoltenVK 1.4.2 sur Apple M1.
Le parcours Story final réussit en 29 s (`STORY234_PASS`), avec les mêmes
langues, GUI et états Atlas. Accès depuis les deux dispositions de Pause,
100 entrées désactivées, recherche/filtres, saisie continue et focus, molette,
redimensionnement à 1344 × 1008 et retours Échap / Terminé vérifiés. Le record
de progression serveur ne change pas. Log :
`build/pause235-story-client-vulkan-final.log` ; 21 captures copiées dans
`build/pause235-story-screenshots/`.
Aux GUI étroits, les longs libellés natifs des options/sortie et les titres des
fils gardent leur défilement ou découpage existant ; les boutons de navigation
Sanctuary montrent leur libellé entier. Les infobulles des actions Game sont
conservées. Aucun essai OpenGL, Windows ou monde personnel n'est revendiqué.
Le contrôle `check build assemblePack` réussit en 3 min 1 s : quatre GameTests
requis et contrôles purs du dépôt, dont les quatre contrôles communautaires
fichier/photos/demandes/recherche. Log : `build/pause235-check-build-pack.log`.
Le banc menus conserve sa sélection Atlas existante ; le conflit de générateur
sur le chemin natif par défaut, documenté en beta.232, reste hors de ce ticket.
## Livraison locale
`mod_version`, `pack_version` et `packwiz/pack.toml` sont alignés sur beta.235.
Le commit de livraison conserve ces métadonnées et cette documentation séparées
du commit de disposition.
Export [Sanctuary-beta.235.mrpack](../build/Sanctuary-beta.235.mrpack),
12 776 222 octets : intégrité ZIP, Minecraft 26.3 et Fabric Loader 0.19.5,
un seul JAR Sanctuary embarqué, identique au JAR construit, sans monde ni module
de test. Les 3 303 fichiers d'assets, données et resourcepacks sont identiques
à beta.234, traductions incluses. Story reste dans le JAR de production et les
classes des bancs client en sont absentes. Reçu :
`build/pause235-mrpack-receipt.json`.
- JAR : `aad94732c749161d775ee634bdd85de70de511ef875c6c00fa167d230d0d600b`.
- MRpack : `5f1bd18aa8a4145b7e708da0abfff22068365847dddbc95ae2b490b88bbb305e`.
Aucune publication distante, modification du canal packwiz, synchronisation
Prism ou ouverture d'un monde personnel effectuée.
+86
View File
@@ -0,0 +1,86 @@
# PAUSE-233 — navigation centrée et rédaction communautaire
Branche `codex/pause-community-beta233`, base beta.232, Minecraft 26.3.
## Résultat et compatibilité
Avec ou sans Atlas, les boutons gardent les mêmes groupes et le même ordre :
- Communauté : Habitant, Faction et Battle en haut de la colonne.
- Aventure : Discovery et Progression centrés verticalement dans l'écran.
- Partie : Options, World Options et Save and Quit en bas.
Sans carte, cette colonne est centrée horizontalement entre Gazette à gauche
et Notice Board à droite. Avec carte, elle reste à gauche, carte au centre et
les deux fils communautaires à droite. L'ordre clavier suit l'ordre visuel.
Les boutons deviennent plus compacts sur les petites hauteurs GUI pour conserver
les titres et l'espacement entre groupes. La petite barre sous les boutons
Communauté est supprimée dans les deux dispositions. En-tête et reprise en pied
conservés.
Le formulaire Notice Board ne propose plus Information/Project/Request/Event.
Toutes les notices ont la même présentation, y compris les anciennes catégories.
Les demandes et récompenses restent facultatives et descriptives : après le
texte viennent Request and rewards, son résumé, puis Publish. Le formulaire
Gazette place la photo et Choose a screenshot en premier, avant le titre,
puis le texte et Publish.
Photo obligatoire, brouillons, édition, retours des sélecteurs, suivis et
validations serveur restent actifs.
Le contrat fichier/MariaDB et le protocole ne changent pas. Les nouvelles notices
gardent la valeur compatible `info` ; éditer une ancienne annonce conserve sa
catégorie enregistrée sans l'afficher. La lecture n'entraîne aucune réécriture
des posts. Aucune migration, génération ou opération d'inventaire n'est ajoutée.
Les anciennes traductions de catégories restent disponibles pour les données
historiques. Titres ajoutés en français/anglais : Communauté/Community,
Aventure/Adventure et Partie/Game ; les autres libellés existent déjà.
## Vérifications et livraison
`./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuaryPause233ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan`
Réussite en 47 s (`PAUSE233_PASS`), après suppression de la barre sous Communauté.
Monde plat de développement, Survie sans Allow Commands, stockage fichier isolé.
- FR/EN, GUI 2/3/4, fenêtre 1280×960, avant et après Atlas : même ordre des
actions et des groupes, titres sans chevauchement, paire Discovery/Progression
centrée verticalement, navigation et options séparées.
- Ordre titre → texte → demande et résumé → publication, ou photo et sélecteur
→ titre → texte → publication ; aucune catégorie visible. Défilement du formulaire sur GUI
étroit vérifié. 38 captures dans `build/pause233-screenshots/`.
- Retours réels des sélecteurs : texte conservé, 128 bûches demandées et
récompense personnalisée publiées ; les 17 bûches et 3 diamants d'inventaire
réel restent présents. Photo toujours obligatoire, article et photo relus
depuis un nouveau lecteur du stockage fichier après publication.
- Ancienne catégorie `event` conservée lors de l'édition. Lecture des posts,
redimensionnements et changements de langue n'altèrent aucun octet du fichier
communautaire. Configuration du banc restaurée à la fin.
- Captures inspectées : pause verrouillée FR/2 et EN/4, pause déverrouillée FR/2,
notice configurée, Gazette avec photo assignée et bas du formulaire Notice Board
EN/4. Notices anciennes et nouvelles ont le même cadre et ne montrent que leur état.
Log client final : `build/pause233-client-vulkan-titled.log`. Aucun essai OpenGL ou Windows
et aucune migration MariaDB ne sont revendiqués ; le code serveur est conservé.
`./gradlew check build assemblePack -PsanctuaryFocusedTests=menus
-PsanctuaryAtlasOnly=true`
Réussite en 2 min 39 s : quatre GameTests requis, contrôles purs communautaires
(`COMMUNITY154_FILE_PASS`, `COMMUNITY157_PHOTOS_PASS`, `COMMUNITY158_TASKS_PASS`,
`COMMUNITY159_SEARCH_PASS`), build et assemblage du pack.
Log : `build/pause233-check-build-pack-final.log`.
Ce contrôle utilise le mode existant du banc pour les menus. Le chemin natif par
défaut présente toujours le conflit de générateur documenté en beta.232 ; cette
livraison ne revendique pas une nouvelle suite complète de génération.
Export local `build/Sanctuary-beta.233.mrpack` vérifié : intégrité ZIP, versions
Minecraft 26.3/Fabric Loader 0.19.5, un seul JAR Sanctuary embarqué, exactement
identique au JAR construit, sans module de test ni monde. Les 3 146 fichiers
de données et assets hors traductions sont identiques à beta.232 ; seules les
trois traductions de titres de groupes sont ajoutées en FR/EN, sans changer les
libellés existants. Reçu : `build/pause233-mrpack-receipt.json`.
- JAR : `23a148c197126ede552865f9847ce381700884d5ff087469a13e36540e8f23f9`.
- MRpack : `d95aeeca5f255db4444a22879f465477c0c6a2a111dd4e1c777b20944a283e2b`.
Livraison locale beta.233 ; aucune installation personnelle, publication
distante, ouverture d'un monde personnel ou mise à jour du canal packwiz.
+114
View File
@@ -0,0 +1,114 @@
# SOLO-232 — réglages et progression en solo
Branche `codex/solo-time-building-vein`, base beta.231, Minecraft 26.3.
## Comportement et contrat de sauvegarde
`/sanctuary time timezone <fuseau IANA>` choisit le fuseau et sa ville solaire
de référence pour ce monde. `timezone auto` reprend le réglage automatique
solo ou la configuration dédiée. Le propriétaire solo peut faire ce choix
sans Allow Commands ; les autres joueurs et le serveur dédié exigent les
droits opérateur. Le statut affiche le fuseau et la commande propose les
identifiants disponibles sans accès réseau.
Le choix persiste dans le champ opaque `reference` du contrat solaire existant,
avec le préfixe `timezone:`. Le schéma 1 et les autres champs restent inchangés.
Les anciennes références restent lisibles. Le changement réancre le calendrier
à son cycle courant, écrit atomiquement avant l'application solaire et ne
change jamais `gameTime`. Le mode Vanilla reste Vanilla. `auto` retire le
préfixe ; une ancienne version reprendrait sa référence configurée au prochain
rechargement. La configuration globale et les autres mondes ne sont pas modifiés.
Seule la commande explicitement exécutée par le joueur change son contrat.
Construction utilise les portées 3 / 3,5 / 4 / 4,5 / 5 / 5,5 / 6 / 7 blocs.
Avec un bloc en main principale ou secondaire, la visée suit cette portée :
un contour hors portée native reste un support de pose. Minage, contenants,
combat, protections, collisions et stock conservent leurs validations propres.
Une pose visible peut être refusée pour ces raisons ordinaires, mais pas par
une portée de construction différente de celle de la visée.
R + clic gauche maintenus terminent le groupe verrouillé, puis sélectionnent
le nouveau bloc regardé et démarrent un nouveau groupe. Chaque groupe conserve
sa durée native, son coût et la validation serveur. Une confirmation serveur
de réussite permet la reprise ; refus, outil cassé ou remplacé, interruption,
menu ou relâchement arrêtent le geste. Un maintien ne rejoue jamais le même
groupe terminé. Aucun groupe n'est lancé sans cible valide.
## Menu pause et butin de l'île
Avant l'achat Atlas, aucun widget World Map ni requête
de carte n'est créé dans le menu pause. Discovery, Progression, Inhabitant,
Faction, Battle, Options, World Options et Save and Quit sont centrés.
Gazette est à gauche des boutons et Notice Board à droite, chacune sur toute
la hauteur du corps du menu. Calendrier et bulletin conservent leurs emplacements.
Après déblocage, la disposition avec carte
et les deux fils à droite revient à la prochaine ouverture du menu.
Les diamants sont retirés lors du premier remplissage
des coffres et wagonnets à coffre situés sur l'île initiale du générateur
Sanctuary, quelle que soit la table de butin (mines natives comprises).
Les expansions, autres dimensions et mondes ordinaires conservent leurs tables.
Les coffres déjà ouverts et les objets déposés manuellement sont conservés.
Les caches non ouvertes de mondes existants suivent cette nouvelle règle à leur
ouverture ; aucune visite ou réécriture de chunks n'a lieu. Aucun minerai ni
profil de génération ne change. La graine n'affecte pas l'exclusion des diamants.
La règle s'applique au générateur courant NoiseBased et à ses trois tailles,
ainsi qu'aux anciens profils Sanctuary. Le filtre intercepte uniquement
`LootTable.fill`, sans changer les tables elles-mêmes ni leurs autres usages.
Les 794 ressources de génération et de butin du JAR beta.231 restent identiques.
La génération demeure 231 ; graines de butin vérifiées : 0, 42, 232, puis
256 tirages de la table native abandoned_mineshaft.
## Livraison et vérifications
Version mod et pack : `beta.232`, Minecraft 26.3, Fabric Loader 0.19.5, Java 25.
Le nouveau message serveur `sanctuary:mining_result` autorise la reprise d'un
groupe terminé ; client et serveur doivent utiliser cette livraison ensemble.
Le client attend aussi la mise à jour du bloc initial avant de recalculer sa
visée, car la confirmation peut arriver avant les blocs détruits.
`./gradlew check build assemblePack -PsanctuaryFocusedTests=realtime,mining,building
-PsanctuaryAtlasOnly=true` réussit en 2 min 36 s : 24 GameTests requis et les
contrôles purs du dépôt passent. Fuseau persistant/idempotent, fuseau invalide
et contrat modifié extérieurement refusés, mode Vanilla et horloge native
préservés ; validations serveur du minage et de la construction conservées.
Log : `build/solo232-check-build-pack.log`.
`./gradlew :sanctuary:runGameTest -PsanctuaryFocusedTests=solo232
-PsanctuaryTestGeneration=13` réussit en 37 s : 2 GameTests requis, remplissage
réel avec le mixin, conservation des diamants déposés et du fer, butin distant
et contexte absent préservés, identification des trois profils courants.
Log : `build/solo232-loot-native.log`.
Les contrôles utilisent les modes existants du banc d'essai. Son chemin par
défaut tente encore de convertir le preset courant NoiseBased en ancien
SanctuaryChunkGenerator et échoue avant les tests. Ce ticket ne corrige pas
ce banc historique et ne revendique pas une suite complète de génération.
`./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuarySolo232ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan`
réussit en 1 min 3 s, Vulkan confirmé avec MoltenVK 1.4.2. Un propriétaire
Survie sans Allow Commands utilise réellement la commande de fuseau. Deux
groupes successifs sont minés avec R + clic gauche maintenus, huit destructions
et huit usages natifs d'outil ; un outil cassé interrompt le geste après une
destruction sans redémarrer. L'essai Balance080 couvre aussi pose native aux
deux mains, portée initiale, contour et pose à 6,75 blocs, refus d'un paquet
forgé à 7,25 blocs, indépendance Minage/Construction, contenants, nénuphars,
construction groupée et bonus externes.
Le menu pause est contrôlé avant/après achat Atlas, en FR/EN, aux échelles
GUI 2/3/4 dans une fenêtre 1280×960 : huit actions présentes et dans le corps,
colonnes sans chevauchement, carte uniquement après achat. Douze captures
sont conservées dans `build/solo232-screenshots/`, avec inspection visuelle
du mode verrouillé FR/2 et EN/4, et du mode débloqué FR/2. Les libellés longs
gardent les infobulles natives aux petites largeurs.
Log : `build/solo232-client-vulkan-final.log`, marqueurs `SOLO232_PASS` et
`BALANCE080_PASS`. Aucun essai OpenGL ou Windows n'est revendiqué.
MRpack local : `build/Sanctuary-beta.232.mrpack`, intégrité ZIP, versions,
dépendances exactes, JAR construit identique, absence de monde et d'entrée
Sanctuary Test vérifiées. Reçu : `build/solo232-mrpack-receipt.json`.
SHA-256 pack : `03e588c3866ad513d057162e9fea4ba9ad07e33a522841e2e7bbd66356b153f8`.
SHA-256 JAR : `a208ccce6318ceccf04d35d983e4a9ea1d46e872fda202d47a37000faa740d9f`.
Aucun monde personnel, installation de jeu ou canal packwiz n'est modifié.
+80
View File
@@ -0,0 +1,80 @@
# STORY-234 — recherche et filtres
Branche `codex/story-beta234`, base beta.233, commit `9c096b2`, Minecraft 26.3.
## Contrat
Story apparaît sous Progression dans Aventure, avant et après Atlas. Il reprend
le cadre de Discovery : titre, séparateurs et Terminé / Done. La recherche et
le filtre Toutes / Découvertes / Inconnues remplacent les boutons de catégories.
La liste réserve 100 histoires numérotées et inconnues, toutes désactivées.
Toutes et Inconnues montrent les entrées correspondant à la recherche, dans
l'ordre numérique ; Découvertes indique qu'aucune histoire n'est découverte.
Une recherche sans résultat dispose d'un autre message. La recherche porte sur
le numéro et le libellé visible, ignore la casse et les espaces extérieurs.
Recherche et filtre se combinent et ramènent le défilement en haut. La saisie
garde son focus et son curseur ; redimensionner garde recherche, filtre et
défilement. Terminé et Échap reviennent directement au menu Pause d'origine.
Aventure comprend maintenant Discovery, Progression et Story. Les trois groupes
de Pause conservent leur ordre avec ou sans carte. Aux petites hauteurs, les
boutons et espaces se compactent pour éviter les chevauchements.
[Guide de rédaction](story/README.md) et [modèle Markdown](story/TEMPLATE.md) :
un fichier `001.md` à `100.md`, avec numéro, titre, texte et idée de découverte
facultative. Aucun récit n'est inventé, chargé ou révélé dans ce lot. Leur
intégration et leurs déblocages seront des tickets suivants. Pas de nouveau
paquet réseau, dépendance, sauvegarde ou changement de génération.
## Vérifications
```sh
./gradlew check build assemblePack -PsanctuaryFocusedTests=menus -PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true -PsanctuaryStory234ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
```
Build final réussi en 2 min 34 s : quatre GameTests requis et contrôles purs du
dépôt, dont les contrôles communautaires fichier, photos, demandes et recherche.
Log : `build/story234-check-build-pack-final.log`. Le banc menus utilise la
sélection Atlas existante ; le conflit du générateur sur le chemin par défaut,
documenté en beta.232, n'est pas modifié par ce lot.
Parcours client final réussi en 30 s (`STORY234_PASS`), sur un monde plat neuf
de développement en Survie sans commandes. Vulkan confirmé par le journal :
MoltenVK 1.4.2 sur macOS, Apple M1. Log : `build/story234-client-vulkan-final.log`.
- FR/EN, GUI 2/3/4, fenêtre 1280 × 960, avec et sans Atlas : six actions
Sanctuary dans l'ordre visuel et clavier, trois groupes sans chevauchement,
groupe Discovery/Progression/Story centré verticalement.
- 100 numéros consécutifs sans doublon, entrées désactivées, recherche du numéro
100, espaces extérieurs et recherche en majuscules dans le libellé localisé.
Recherche et filtre combinés, deux états vides distincts.
- Saisie par événements natifs, focus et curseur conservés, molette et accès à
la dernière entrée ; défilement remis en haut après recherche ou filtre.
Redimensionnement réel à 1344 × 1008 : recherche, filtre et défilement conservés.
- Terminé et Échap reviennent à la même Pause. La page reste une pause solo ;
le record de progression serveur est identique avant et après les consultations.
21 captures dans `build/story234-screenshots/`. Captures inspectées : Story FR/2
et EN/4, Pause sans Atlas FR/4 et avec Atlas EN/3, états Découvertes et recherche
vides, recherche de l'histoire 100. L'indication de recherche reste visible
lorsque le champ vide a le focus. Aucun essai OpenGL ou Windows n'est revendiqué.
## Livraison locale
`mod_version`, `pack_version` et `packwiz/pack.toml` sont alignés sur beta.234.
Export [Sanctuary-beta.234.mrpack](../build/Sanctuary-beta.234.mrpack),
12 775 502 octets : intégrité ZIP, Minecraft 26.3, Fabric Loader 0.19.5,
un seul JAR Sanctuary embarqué, identique au JAR construit, sans monde ni
module de test. Le JAR de production inclut Story et aucune classe de son banc
client. Assets et données identiques à beta.233, hors les neuf traductions
ajoutées dans chacune des langues FR/EN ; les libellés existants sont conservés.
Reçu : `build/story234-mrpack-receipt.json`.
- JAR : `25dc161468679379e642f9746761905045d1135b6a300e3bc3f58f37a4c8f9a8`.
- MRpack : `0b16514e4dec16d14061fd25c7cb42d53fdb28c73f06a1c6361d16d059977c69`.
Les trois commits couvrent l'interface, les tests/documentation et la livraison.
Aucune publication distante, modification de canal packwiz, synchronisation
Prism ou ouverture de monde personnel effectuée.
+19
View File
@@ -0,0 +1,19 @@
# Écrire les histoires de Sanctuary
Story accueille des textes personnels sur Sanctuary, son développement, Steve,
Minecraft et ce que signifie habiter un jeu. Les numéros de 1 à 100 sont des
repères durables ; ils n'imposent ni un ordre de lecture ni une chronologie.
Copier [le modèle](TEMPLATE.md) dans un fichier nommé `001.md` à `100.md`, puis
remplacer le titre et écrire librement dans la section Texte. Les paragraphes,
les hésitations et la voix de l'auteur peuvent être conservés tels quels.
La section Idée de découverte est facultative et peut rester vide.
Transmettre les fichiers au fil des séances pour préparer les prochains tickets.
Ne pas renuméroter une histoire déjà intégrée. La traduction éventuelle des récits
se décide avec l'auteur ; les interfaces du jeu disposent de leurs libellés FR/EN.
En beta.234, les 100 histoires sont encore inconnues et désactivées. Ces fichiers
sont des sources de rédaction : le mod ne les charge pas automatiquement.
L'intégration des premiers textes et leurs conditions de découverte restent à
implémenter, avec autorité serveur si elles dépendent de la partie.
+12
View File
@@ -0,0 +1,12 @@
# Titre de l'histoire
Numéro : 001
## Texte
Écrire ici le texte, avec autant de paragraphes que nécessaire.
## Idée de découverte (facultative)
Décrire éventuellement un geste, une rencontre, un lieu ou une autre idée qui
pourrait révéler cette histoire. Cette note est une intention de conception.
+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.231
pack_version=beta.231
mod_version=beta.235
pack_version=beta.235
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+10 -1
View File
@@ -228,7 +228,7 @@ tasks.named('processGametestResources') {
def villageOnly = providers.gradleProperty('sanctuaryVillageOnly').map { it.toBoolean() }.getOrElse(false)
def focused = providers.gradleProperty('sanctuaryFocusedTests').getOrElse('').split(',').findAll { !it.isEmpty() }
def focusClasses = [diagonal231: 'Diagonal231GameTests', diagonal230: 'Diagonal230GameTests', diagonal229: 'Diagonal229GameTests', diagonal228: 'Diagonal228GameTests', diagonal227: 'Diagonal227GameTests', diagonal226: 'Diagonal226GameTests', diagonal225: 'Diagonal225GameTests', east224: 'East224GameTests', east223: 'East223GameTests', rock222: 'YellowRock222GameTests', west221: 'West221GameTests', west220: 'West220GameTests', west219: 'West219GameTests', west218: 'West218GameTests', west217: 'West217GameTests', south216: 'South216GameTests', relays215: 'Relays215GameTests', south214: 'South214GameTests', south213: 'South213GameTests', south212: 'South212GameTests', south211: 'South211GameTests', south210: 'South210GameTests', south209: 'South209GameTests', north208: 'North208GameTests', north207: 'North207GameTests', north206: 'North206GameTests', north205: 'North205GameTests', base204: 'Base204GameTests', palace173: 'Palace173GameTests', horde172: 'Horde172GameTests', horde171: 'Horde171GameTests', horde170: 'Horde170GameTests', horde169: 'Horde169GameTests', horde168: 'Horde168GameTests', horde167: 'Horde167GameTests', plans072: 'Plans072GameTests', starter052: 'Starter052GameTests', dispenser051: 'Dispenser051GameTests', head050: 'Head050GameTests', boatshead: 'BoatsHead049GameTests', poultry: 'Poultry048GameTests', starinspect: 'StarInspection047GameTests', skyflight: 'SkyFlight046GameTests', eggaudit: 'Companion044GameTests', headblocks: 'HeadBlocks043GameTests', operator: 'OperatorStars040GameTests', starter: 'Starter039GameTests', gravelayout: 'GraveInventory039GameTests', foodknowledge: 'FoodKnowledge039GameTests', graves: 'GravesFood038GameTests', realtime: 'Realtime037GameTests', corrections: 'Corrections036GameTests', collections: 'Collections035GameTests', notifications: 'Notifications034GameTests', menus: 'Menus033GameTests', aerial: 'Aerial24WorldGameTests', transit: 'Transit29WorldGameTests', village: 'Village306GameTests', drained: 'Drained307GameTests', expeditions: 'AncientExpeditions001GameTests', progression: 'Progression003GameTests', map: 'Atlas005GameTests', demeure: 'Demeure006GameTests', mining: 'Mining008GameTests', building: 'Building009GameTests', inventory: 'Inventory010GameTests', cycle: 'Cycle011GameTests', inventoryflow: 'Inventory012GameTests', sorting: 'Inventory014GameTests', tutorial: 'Tutorial016GameTests', movement: 'Movement017GameTests', accessories: 'Accessory018GameTests', companions: 'Companion019GameTests', familiar: 'Familiar020GameTests', carry: 'Carry027GameTests', familiarhit: 'Familiar029GameTests', refonte: 'Companion032GameTests', lights: 'Dynamic030GameTests', cosmetics: 'HeadCosmetic021GameTests', startup: 'StartupPerformanceGameTests', recipes: 'Recipes024GameTests']
def focusClasses = [solo232: 'Solo232GameTests', diagonal231: 'Diagonal231GameTests', diagonal230: 'Diagonal230GameTests', diagonal229: 'Diagonal229GameTests', diagonal228: 'Diagonal228GameTests', diagonal227: 'Diagonal227GameTests', diagonal226: 'Diagonal226GameTests', diagonal225: 'Diagonal225GameTests', east224: 'East224GameTests', east223: 'East223GameTests', rock222: 'YellowRock222GameTests', west221: 'West221GameTests', west220: 'West220GameTests', west219: 'West219GameTests', west218: 'West218GameTests', west217: 'West217GameTests', south216: 'South216GameTests', relays215: 'Relays215GameTests', south214: 'South214GameTests', south213: 'South213GameTests', south212: 'South212GameTests', south211: 'South211GameTests', south210: 'South210GameTests', south209: 'South209GameTests', north208: 'North208GameTests', north207: 'North207GameTests', north206: 'North206GameTests', north205: 'North205GameTests', base204: 'Base204GameTests', palace173: 'Palace173GameTests', horde172: 'Horde172GameTests', horde171: 'Horde171GameTests', horde170: 'Horde170GameTests', horde169: 'Horde169GameTests', horde168: 'Horde168GameTests', horde167: 'Horde167GameTests', plans072: 'Plans072GameTests', starter052: 'Starter052GameTests', dispenser051: 'Dispenser051GameTests', head050: 'Head050GameTests', boatshead: 'BoatsHead049GameTests', poultry: 'Poultry048GameTests', starinspect: 'StarInspection047GameTests', skyflight: 'SkyFlight046GameTests', eggaudit: 'Companion044GameTests', headblocks: 'HeadBlocks043GameTests', operator: 'OperatorStars040GameTests', starter: 'Starter039GameTests', gravelayout: 'GraveInventory039GameTests', foodknowledge: 'FoodKnowledge039GameTests', graves: 'GravesFood038GameTests', realtime: 'Realtime037GameTests', corrections: 'Corrections036GameTests', collections: 'Collections035GameTests', notifications: 'Notifications034GameTests', menus: 'Menus033GameTests', aerial: 'Aerial24WorldGameTests', transit: 'Transit29WorldGameTests', village: 'Village306GameTests', drained: 'Drained307GameTests', expeditions: 'AncientExpeditions001GameTests', progression: 'Progression003GameTests', map: 'Atlas005GameTests', demeure: 'Demeure006GameTests', mining: 'Mining008GameTests', building: 'Building009GameTests', inventory: 'Inventory010GameTests', cycle: 'Cycle011GameTests', inventoryflow: 'Inventory012GameTests', sorting: 'Inventory014GameTests', tutorial: 'Tutorial016GameTests', movement: 'Movement017GameTests', accessories: 'Accessory018GameTests', companions: 'Companion019GameTests', familiar: 'Familiar020GameTests', carry: 'Carry027GameTests', familiarhit: 'Familiar029GameTests', refonte: 'Companion032GameTests', lights: 'Dynamic030GameTests', cosmetics: 'HeadCosmetic021GameTests', startup: 'StartupPerformanceGameTests', recipes: 'Recipes024GameTests']
focused.each { if (!focusClasses.containsKey(it)) throw new GradleException('Unknown focused native test: ' + it) }
inputs.property('focusedNative', focused)
def yellowRock222Client = providers.gradleProperty('sanctuaryYellowRock222ClientTests').map { it.toBoolean() }.getOrElse(false)
@@ -331,6 +331,15 @@ tasks.named('processGametestResources') {
def weather082Client = providers.gradleProperty('sanctuaryWeather082ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryWeather082ClientTests', weather082Client)
if (weather082Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Weather082ClientChecks') }
def solo232Client = providers.gradleProperty('sanctuarySolo232ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySolo232ClientTests', solo232Client)
if (solo232Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Solo232ClientChecks') }
def pause233Client = providers.gradleProperty('sanctuaryPause233ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPause233ClientTests', pause233Client)
if (pause233Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Pause233ClientChecks') }
def story234Client = providers.gradleProperty('sanctuaryStory234ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryStory234ClientTests', story234Client)
if (story234Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Story234ClientChecks') }
def balance080Client = providers.gradleProperty('sanctuaryBalance080ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryBalance080ClientTests', balance080Client)
if (balance080Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Balance080ClientChecks') }
@@ -29,14 +29,15 @@ import net.minecraft.world.phys.*;
/** Actual input, native prediction, play packets and authoritative blocks in a disposable solo world. */
public final class Balance080ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
fr.koka.sanctuary.testing.QuickTestWorld.enable();
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
IntroClient.setQuickTestIntro(true);Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Balance080 "+UUID.randomUUID());s.setSeed("80");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()) {
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Balance080 "+UUID.randomUUID());s.setSeed("80");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("minecraft:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.getInventory().clearContent();p.getAbilities().invulnerable=true;
@@ -45,7 +46,8 @@ public final class Balance080ClientChecks implements FabricClientGameTest {
});
// The base skill places nearby, rejects a normal 4.25-block click, and consumes no stock on rejection.
setup(c,server,0,0,3,Items.COBBLESTONE,false);use(c);server.waitFor(s->player(s).level().getBlockState(pos(3).north()).is(Blocks.COBBLESTONE),100);
setup(c,server,0,0,5,Items.COBBLESTONE,false);use(c);c.waitTicks(12);
setup(c,server,0,0,5,Items.COBBLESTONE,false);
c.runOnClient(m->check(!(m.hitResult instanceof BlockHitResult h&&h.getType()==HitResult.Type.BLOCK&&h.getBlockPos().equals(pos(5))),"Rank-zero construction does not show an unreachable support outline"));use(c);c.waitTicks(12);
server.runOnServer(s->{var p=player(s);check(p.level().getBlockState(pos(5).north()).isAir()&&p.getMainHandItem().getCount()==64,"Rank zero rejects far placement without consuming stock");});
// Construction alone reaches 5.25 blocks; Mining stays at its own rank-zero reach.
setup(c,server,0,7,6,Items.COBBLESTONE,false);
@@ -54,15 +56,15 @@ public final class Balance080ClientChecks implements FabricClientGameTest {
server.runOnServer(s->check(player(s).level().getBlockState(pos(6)).is(Blocks.STONE),"Construction does not extend mining"));
use(c);server.waitFor(s->player(s).level().getBlockState(pos(6).north()).is(Blocks.COBBLESTONE),100);
setup(c,server,0,7,6,Items.COBBLESTONE,true);use(c);server.waitFor(s->player(s).level().getBlockState(pos(6).north()).is(Blocks.COBBLESTONE),100);
// beta.081: the newly earned half-block works through the actual pick and play packet.
setup(c,server,0,7,6,Items.COBBLESTONE,false);server.runOnServer(s->player(s).teleportTo(.5,1,.25));
c.waitFor(m->Math.abs(m.player.getZ()-.25)<.01,100);c.getInput().lookAt(pos(6));c.waitTicks(5);use(c);
server.waitFor(s->player(s).level().getBlockState(pos(6).north()).is(Blocks.COBBLESTONE),100);
// Outside six blocks, a forged hit is rejected even within the native packet tolerance.
setup(c,server,0,7,6,Items.COBBLESTONE,false);server.runOnServer(s->player(s).teleportTo(.5,1,-.25));
// beta.232: the seventh block of reach works through the actual pick and play packet.
setup(c,server,0,7,7,Items.COBBLESTONE,false);server.runOnServer(s->player(s).teleportTo(.5,1,.25));
c.waitFor(m->Math.abs(m.player.getZ()-.25)<.01,100);c.getInput().lookAt(pos(7));c.waitTicks(5);use(c);
server.waitFor(s->player(s).level().getBlockState(pos(7).north()).is(Blocks.COBBLESTONE),100);
// Outside seven blocks, a forged hit is rejected even within the native packet tolerance.
setup(c,server,0,7,7,Items.COBBLESTONE,false);server.runOnServer(s->player(s).teleportTo(.5,1,-.25));
c.waitFor(m->Math.abs(m.player.getZ()+.25)<.01,100);c.getInput().lookAt(pos(6));c.waitTicks(5);
c.runOnClient(m->m.gameMode.useItemOn(m.player,InteractionHand.MAIN_HAND,new BlockHitResult(new Vec3(.5,2.5,6),Direction.NORTH,pos(6),false)));c.waitTicks(15);
server.runOnServer(s->{var p=player(s);check(p.level().getBlockState(pos(6).north()).isAir()&&p.getMainHandItem().getCount()==64,"Six-block maximum is enforced by the server");});
c.runOnClient(m->m.gameMode.useItemOn(m.player,InteractionHand.MAIN_HAND,new BlockHitResult(new Vec3(.5,2.5,7),Direction.NORTH,pos(7),false)));c.waitTicks(15);
server.runOnServer(s->{var p=player(s);check(p.level().getBlockState(pos(7).north()).isAir()&&p.getMainHandItem().getCount()==64,"Seven-block maximum is enforced by the server");});
// A deliberately forged out-of-range native packet cannot place or consume an item.
setup(c,server,7,7,8,Items.COBBLESTONE,false);
c.runOnClient(m->m.gameMode.useItemOn(m.player,InteractionHand.MAIN_HAND,new BlockHitResult(new Vec3(.5,2.5,8),Direction.NORTH,pos(8),false)));c.waitTicks(15);
@@ -105,13 +107,13 @@ public final class Balance080ClientChecks implements FabricClientGameTest {
}
}
private static void curve(ServerPlayer p) {
double[] speed={.75,.85,.95,1,1.075,1.15,1.225,1.30},reach={3,3.5,4,4.5,4.75,5,5.25,5.5},buildReach={3,3.5,4,4.5,4.75,5,5.5,6};
double[] speed={.75,.85,.95,1,1.075,1.15,1.225,1.30},reach={3,3.5,4,4.5,4.75,5,5.25,5.5},buildReach={3,3.5,4,4.5,5,5.5,6,7};
var extra=SanctuaryMod.id("test/balance080");var attribute=p.getAttribute(Attributes.BLOCK_BREAK_SPEED);
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.DIAMOND_PICKAXE));setRanks(p,3,3);
var state=Blocks.STONE.defaultBlockState();float baseline=state.getDestroyProgress(p,p.level(),new BlockPos(0,0,0));
for(int rank=0;rank<=7;rank++) {setRanks(p,rank,rank);near(attribute.getValue(),speed[rank],"Mining speed rank "+rank);near(state.getDestroyProgress(p,p.level(),new BlockPos(0,0,0))/baseline,speed[rank],"Native block breaking progress rank "+rank);near(BuildReach.range(p),buildReach[rank],"Build reach rank "+rank);near(MiningReach.range(p),reach[rank],"Mining reach rank "+rank);}
attribute.addTransientModifier(new AttributeModifier(extra,.5,AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL));setRanks(p,7,7);near(attribute.getValue(),1.95,"Mining composes with external boost");attribute.removeModifier(extra);
var blockRange=p.getAttribute(Attributes.BLOCK_INTERACTION_RANGE);blockRange.addTransientModifier(new AttributeModifier(extra,2,AttributeModifier.Operation.ADD_VALUE));near(BuildReach.range(p),8,"Construction preserves external range bonuses");near(MiningReach.range(p),7.5,"Mining preserves external range bonuses");blockRange.removeModifier(extra);
var blockRange=p.getAttribute(Attributes.BLOCK_INTERACTION_RANGE);blockRange.addTransientModifier(new AttributeModifier(extra,2,AttributeModifier.Operation.ADD_VALUE));near(BuildReach.range(p),9,"Construction preserves external range bonuses");near(MiningReach.range(p),7.5,"Mining preserves external range bonuses");blockRange.removeModifier(extra);
near(p.blockInteractionRange(),4.5,"Base interactions remain native");near(p.entityInteractionRange(),3,"Entity interactions remain native");
}
private static void setup(ClientGameTestContext c,TestServerContext server,int mining,int building,int z,Item item,boolean offhand) {
@@ -208,7 +208,7 @@ public final class Community158ClientChecks implements FabricClientGameTest {
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");
check(inhabitant.getY()<pauseButton(screen,"sanctuary.cycle.faction").getY()&&pauseButton(screen,"sanctuary.arena.title").getBottom()<discovery.getY()&&progression.getY()>discovery.getBottom(),"Community group precedes the separate adventure group");
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");
@@ -0,0 +1,149 @@
package fr.koka.sanctuary.client;
import com.google.gson.Gson;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.community.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.progression.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.*;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Native layout, selector returns and publication packets in an isolated file-backed solo world. */
public final class Pause233ClientChecks implements FabricClientGameTest {
private final Path config=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json");
private final Path game=FabricLoader.getInstance().getGameDir();
private final String serverId=UUID.randomUUID().toString();
@Override public void runTest(ClientGameTestContext c) {
byte[] previous=checked(()->Files.exists(config)?Files.readAllBytes(config):null);
var intro=new AtomicBoolean(true);
Path fixture=game.resolve("screenshots/zz-pause233.png");
checked(()->{
Files.createDirectories(config.getParent());Files.writeString(config,new Gson().toJson(new CommunityConfig("file",serverId,"jdbc:mariadb://localhost/sanctuary","root","SANCTUARY_COMMUNITY_DB_PASSWORD")));
Files.createDirectories(fixture.getParent());var image=new java.awt.image.BufferedImage(320,180,java.awt.image.BufferedImage.TYPE_INT_RGB);
for(int y=0;y<180;y++)for(int x=0;x<320;x++)image.setRGB(x,y,x<160?0x567445:0xe5cf86);
javax.imageio.ImageIO.write(image,"png",fixture.toFile());return null;
});
fr.koka.sanctuary.testing.QuickTestWorld.enable();
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Pause233 "+UUID.randomUUID());s.setSeed("233");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("minecraft:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.getInventory().setItem(6,new ItemStack(Items.OAK_LOG,17));p.getInventory().setItem(7,new ItemStack(Items.DIAMOND,3));});
String legacy=UUID.randomUUID().toString();
// Wait and dispatch together so the periodic following request cannot take the slot between them.
c.waitFor(m->CommunityClient.request(null,"create",legacy,"","notice","event","Ancienne annonce","Texte historique",0,0,r->check(!r.has("error"),"Legacy-compatible notice seeded through the server")),200);
c.waitFor(m->!CommunityClient.pending(),200);c.waitTicks(24);
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.options.guiScale().set(2);m.resizeGui();CommunityClient.open(new PauseScreen(true),"notice","",true);edit(m.gui.screen()).setValue("Du bois pour le pont");body(m.gui.screen()).setValue("Une annonce commune, avec une demande facultative.");composer(m.gui.screen(),"notice");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->{widgets(m.gui.screen()).filter(CommunityMaterialsScreen.Slot.class::isInstance).map(CommunityMaterialsScreen.Slot.class::cast).filter(w->w.getMessage().getString().startsWith("minecraft:oak_log")).findFirst().orElseThrow().onPress(enter());widgets(m.gui.screen()).filter(EditBox.class::isInstance).map(EditBox.class::cast).toList().getLast().setValue("128");press(m.gui.screen(),"sanctuary.community.save");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");check(body(m.gui.screen()).getValue().startsWith("Une annonce commune"),"Text survives task/material selector returns");composer(m.gui.screen(),"notice");});
shot(c,"notice-configured");
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.publish"));
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.follow")&&!CommunityClient.pending(),400);
server.runOnServer(s->checked(()->{var entry=store(s).list("notice","",0).entries().stream().filter(e->e.title().equals("Du bois pour le pont")).findFirst().orElseThrow();check(entry.category().equals("info")&&entry.task().materials().equals(List.of(new CommunityTask.Material("minecraft:oak_log",128)))&&entry.task().customReward().equals("Un repas au village"),"Requests and rewards persist without notice category selection");check(s.getPlayerList().getPlayers().getFirst().getInventory().getItem(6).getCount()==17&&s.getPlayerList().getPlayers().getFirst().getInventory().getItem(7).getCount()==3,"Publication never consumes real inventory");return null;}));
c.waitTicks(24);
c.runOnClient(m->CommunityClient.open(new PauseScreen(true),"notice",legacy,false));
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.actions")&&!CommunityClient.pending(),300);
c.runOnClient(m->{press(m.gui.screen(),"sanctuary.community.actions");press(m.gui.screen(),"sanctuary.community.edit");composer(m.gui.screen(),"notice");body(m.gui.screen()).setValue("Texte historique corrigé");press(m.gui.screen(),"sanctuary.community.save");});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.follow")&&!CommunityClient.pending(),400);
server.runOnServer(s->checked(()->{var entry=store(s).thread(legacy,0).entry();check(entry.category().equals("event")&&entry.body().equals("Texte historique corrigé"),"Editing preserves the hidden historical category");return null;}));
c.waitTicks(24);
c.runOnClient(m->{CommunityClient.open(new PauseScreen(true),"article","",true);edit(m.gui.screen()).setValue("Gazette233");body(m.gui.screen()).setValue("La photo vient avant le titre et le texte.");composer(m.gui.screen(),"article");check(!button(m.gui.screen(),"publish").active,"Screenshot is still mandatory");press(m.gui.screen(),"sanctuary.community.photo_choose");});
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
c.runOnClient(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow().onPress(enter()));
c.runOnClient(m->{press(m.gui.screen(),"sanctuary.community.photo_assign");check(body(m.gui.screen()).getValue().equals("La photo vient avant le titre et le texte."),"Text survives screenshot selector return");composer(m.gui.screen(),"article");check(button(m.gui.screen(),"publish").active,"Assigned screenshot enables publication");});
shot(c,"gazette-photo-assigned");c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.publish"));
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityArticle.class::isInstance)&&!CommunityClient.pending(),500);
server.runOnServer(s->checked(()->{var entry=store(s).list("article","",0).entries().getFirst();check(entry.title().equals("Gazette233")&&entry.body().equals("La photo vient avant le titre et le texte.")&&!entry.photo().isEmpty(),"Article text and assigned photo survive publication");return null;}));
byte[] saved=server.computeOnServer(s->checked(()->Files.readAllBytes(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"))));
for(boolean unlocked:new boolean[]{false,true}) {
server.runOnServer(s->atlas(s.getPlayerList().getPlayers().getFirst(),unlocked));c.waitFor(m->ProgressionClient.progress.atlas()==unlocked,200);
for(String language: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(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new PauseScreen(true));});
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityCard.class::isInstance),300);
c.runOnClient(m->pause(m.gui.screen(),unlocked));shot(c,"pause-"+(unlocked?"unlocked-":"locked-")+language+"-"+scale);
for(String kind:List.of("notice","article")) {
c.runOnClient(m->{CommunityClient.open(new PauseScreen(true),kind,"",true);edit(m.gui.screen()).setValue("Brouillon233");body(m.gui.screen()).setValue("Le brouillon suit les changements de langue et de taille.");composer(m.gui.screen(),kind);});
if(!unlocked){shot(c,"compose-"+kind+"-"+language+"-"+scale);c.runOnClient(m->{widgets(m.gui.screen()).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).filter(a->a.maxScrollAmount()>0).findFirst().ifPresent(a->a.setScrollAmount(a.maxScrollAmount()));});shot(c,"compose-bottom-"+kind+"-"+language+"-"+scale);}
}
}
}
server.runOnServer(s->checked(()->{check(Arrays.equals(saved,Files.readAllBytes(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"))),"Reading/resizing old and new posts never rewrites stored categories");return null;}));
System.out.println("PAUSE233_PASS: FR/EN GUI 2/3/4, parallel Adventure/Community before Atlas, narrow Community/Adventure stack beside Atlas, Game directly below, centered navigation block, visual Tab order, unified notice composer, descriptive task and photo publication, legacy category preservation");
}finally {intro.set(false);checked(()->{if(previous==null)Files.deleteIfExists(config);else Files.write(config,previous);Files.deleteIfExists(fixture);return null;});}
}
private FileCommunityStore store(net.minecraft.server.MinecraftServer s)throws Exception{return new FileCommunityStore(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),serverId);}
private static void atlas(ServerPlayer p,boolean unlocked){var r=ProgressRecord.INITIAL;if(unlocked)r=r.buy("atlas",r.cost("atlas",ProgressionService.session(p.level().getServer()).config));p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");}
private static String key(AbstractWidget w){return w.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
private static AbstractWidget widget(Screen s,String key){return widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->key(w).equals("sanctuary.community."+key)).findFirst().orElseThrow();}
private static Button button(Screen s,String key){return (Button)widget(s,key);}
private static void composer(Screen s,String kind){
check(widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).noneMatch(w->key(w).startsWith("sanctuary.community.category.")),"No notice category control or label");
var body=body(s);var extra=button(s,kind.equals("notice")?"task_configure":has(s,"sanctuary.community.photo_change")?"photo_change":"photo_choose");
var publish=button(s,has(s,"sanctuary.community.save")?"save":"publish");
if(kind.equals("notice")){var summary=widget(s,"task_summary");check(extra.getY()>body.getBottom()&&summary.getY()>extra.getBottom()&&publish.getY()>summary.getBottom(),"Text, request configuration, summary and Publish are in order");}
else {check(extra.getBottom()<edit(s).getY()&&publish.getY()>body.getBottom(),"Screenshot selector is before title, then text and Publish");widgets(s).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).forEach(w->check(w.getBottom()<extra.getY(),"Photo preview is first, before selector and title"));}
}
static void pause(Screen s,boolean unlocked){
check((((PauseAtlasAccess)s).sanctuary$pauseMap()!=null)==unlocked,"Pause map appears only after Atlas purchase");
var keys=Set.of("sanctuary.progression.blocks","sanctuary.progression.progress","sanctuary.story.title","sanctuary.progression.identity","sanctuary.cycle.faction","sanctuary.arena.title");
var nav=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->keys.contains(key(b))).toList();check(nav.size()==6,"Six Sanctuary navigation actions");
var options=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals("menu.options")).findFirst().orElseThrow();
var worldOptions=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals("options.worldOptions.button")).findFirst().orElseThrow();
var quit=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->Set.of("menu.returnToMenu","menu.disconnect").contains(key(b))).findFirst().orElseThrow();
var community=widget(s,"pause_group_community");var adventure=widget(s,"pause_group_adventure");var game=widget(s,"pause_group_game");
check(nav.stream().map(CommunityPausePanels::key).toList().equals(unlocked
?List.of("sanctuary.progression.identity","sanctuary.cycle.faction","sanctuary.arena.title","sanctuary.progression.blocks","sanctuary.progression.progress","sanctuary.story.title")
:List.of("sanctuary.progression.blocks","sanctuary.progression.identity","sanctuary.progression.progress","sanctuary.cycle.faction","sanctuary.story.title","sanctuary.arena.title")),"Navigation follows visual order in both layouts");
for(var button:nav)check(s.getFont().width(button.getMessage())+8<=button.getWidth(),"Navigation labels fit with native button padding");
check(nav.stream().allMatch(b->b.getBottom()<game.getY())&&game.getBottom()<options.getY()&&options.getBottom()<worldOptions.getY()&&worldOptions.getBottom()<quit.getY(),"Game follows below Sanctuary navigation");
check(options.getX()==nav.get(0).getX()&&worldOptions.getX()==options.getX()&&quit.getX()==options.getX()&&worldOptions.getWidth()==options.getWidth()&&quit.getWidth()==options.getWidth(),"Native Game actions share the navigation block width");
int bodyTop=s.height<280?46:50,bodyBottom=s.height-39;
check(Math.abs((Math.min(adventure.getY(),community.getY())+quit.getBottom())/2.-(bodyTop+bodyBottom)/2.)<=.5&&quit.getBottom()+8<=bodyBottom,"Complete block centered with clear space below Game");
// KeyEvent carries the physical key and the SDL shortcut key separately.
s.setFocused(nav.get(0));s.keyPressed(new KeyEvent(InputConstants.KEY_TAB,'\t',0));check(s.getFocused()==nav.get(1),"Tab follows the second visible action");
s.keyPressed(new KeyEvent(InputConstants.KEY_TAB,'\t',0));check(s.getFocused()==nav.get(2),"Tab follows the third visible action");
if(!unlocked){
check(adventure.getY()==community.getY()&&adventure.getX()==nav.get(0).getX()&&community.getX()==nav.get(1).getX()&&adventure.getBottom()<nav.get(0).getY(),"Both headings align above their columns");
for(int row=0;row<3;row++) {
var left=nav.get(2*row);var right=nav.get(2*row+1);
check(left.getY()==right.getY()&&left.getHeight()==right.getHeight()&&left.getRight()<right.getX(),"Two parallel columns without overlap");
if(row>0)check(nav.get(2*row-2).getBottom()<left.getY(),"Rows remain separated");
}
check(options.getRight()==nav.get(1).getRight(),"Game spans both columns before Atlas");
check(Math.abs((nav.get(0).getX()+nav.get(1).getRight())/2.-s.width/2.)<=.5,"Two-column block centered horizontally");
check(widget(s,"article").getX()==8&&widget(s,"article").getRight()<nav.get(0).getX()&&widget(s,"notice").getX()>nav.get(1).getRight(),"Gazette remains left and Notice Board right");
}
else {
check(community.getBottom()<nav.get(0).getY()&&nav.get(2).getBottom()<adventure.getY()&&adventure.getBottom()<nav.get(3).getY(),"Community then Adventure stacked above Game with Atlas");
for(int i=0;i<nav.size();i++) {
var button=nav.get(i);check(button.getX()==8&&button.getWidth()==options.getWidth()&&button.getWidth()<=132,"All actions use one narrow left column");
if(i>0)check(nav.get(i-1).getBottom()<button.getY(),"Stacked buttons remain separated");
}
var map=((PauseAtlasAccess)s).sanctuary$pauseMap();check(nav.get(0).getRight()<map.getX()&&map.getRight()<widget(s,"article").getX()&&widget(s,"article").getX()==widget(s,"notice").getX(),"Narrow navigation left, map center and community feeds right");
}
}
private void shot(ClientGameTestContext c,String name){var path=c.takeScreenshot("pause233-"+name);checked(()->{var dir=game.resolve("../../../../../build/pause233-screenshots").normalize();Files.createDirectories(dir);Files.copy(path,dir.resolve(name+".png"),StandardCopyOption.REPLACE_EXISTING);return null;});}
private interface Checked<T>{T run()throws Exception;}
private static <T>T checked(Checked<T> action){try{return action.run();}catch(Exception e){throw new AssertionError(e);}}
}
@@ -0,0 +1,93 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.mining.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.realtime.RealtimeService;
import java.time.ZoneId;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.PauseScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
/** Held input and real command/result packets, with a non-operator integrated owner. */
public final class Solo232ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
fr.koka.sanctuary.testing.QuickTestWorld.enable();
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Solo232 "+UUID.randomUUID());s.setSeed("232");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("minecraft:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;check(!fr.koka.sanctuary.operator.OperatorService.allowed(p),"Test owner has no operator commands");});
c.runOnClient(m->{var time=m.player.connection.getCommands().getRoot().getChild("sanctuary").getChild("time");check(time.getChild("timezone")!=null&&time.getChild("vanilla")==null,"Solo owner sees timezone but no operator rule controls");m.player.connection.sendCommand("sanctuary time timezone America/New_York");});
server.waitFor(s->RealtimeService.civilTime(s).getZone().equals(ZoneId.of("America/New_York")),200);
server.runOnServer(s->checked(()->{RealtimeService.reload(s);check(RealtimeService.civilTime(s).getZone().equals(ZoneId.of("America/New_York")),"Timezone survives reload without op");}));
c.runOnClient(m->m.player.connection.sendCommand("sanctuary time timezone auto"));
server.waitFor(s->RealtimeService.civilTime(s).getZone().equals(ZoneId.systemDefault()),200);
for(boolean unlocked:new boolean[]{false,true}) {
server.runOnServer(s->ranks(s.getPlayerList().getPlayers().getFirst(),unlocked));
c.waitFor(m->ProgressionClient.progress.atlas()==unlocked,200);
for(String language: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(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new PauseScreen(true));});
c.waitForScreen(PauseScreen.class);c.waitTicks(3);
c.runOnClient(m->{var screen=m.gui.screen();var map=((PauseAtlasAccess)screen).sanctuary$pauseMap();check((map!=null)==unlocked,"Map widget exists only after Atlas purchase");
var buttons=screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->Set.of("sanctuary.progression.blocks","sanctuary.progression.progress","sanctuary.story.title","sanctuary.progression.identity","sanctuary.cycle.faction","sanctuary.arena.title","menu.options","options.worldOptions.button","menu.returnToMenu","menu.disconnect").contains(CommunityPausePanels.key(b))).toList();
check(buttons.size()==9,"All nine pause menu actions remain present");
var article=heading(screen,"sanctuary.community.article");var notice=heading(screen,"sanctuary.community.notice");
if(!unlocked){check(article.getX()==8&&article.getY()==(screen.height<280?46:50)&&notice.getY()==article.getY(),"Locked map uses full-height Gazette on the left and Notice Board on the right");for(var button:buttons)check(article.getRight()<button.getX()&&notice.getX()>button.getRight(),"Community columns do not overlap centered navigation");}
else check(article.getX()==notice.getX()&&article.getY()<notice.getY(),"Unlocked map retains both community feeds stacked on the right");
for(var button:buttons)check(button.getY()>=50&&button.getBottom()<=screen.height-33,"Button stays in pause body");
Pause233ClientChecks.pause(screen,unlocked);
});
String shot="solo232-pause-"+(unlocked?"unlocked-":"locked-")+language+"-"+scale;
var image=c.takeScreenshot(shot);checked(()->{var dir=net.fabricmc.loader.api.FabricLoader.getInstance().getGameDir().resolve("../../../../../build/solo232-screenshots").normalize();java.nio.file.Files.createDirectories(dir);java.nio.file.Files.copy(image,dir.resolve(shot+".png"),java.nio.file.StandardCopyOption.REPLACE_EXISTING);});
c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(2);
}
}
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();p.teleportTo(.5,1,.5);p.setOnGround(true);
for(int x=-3;x<=3;x++)for(int z=-2;z<=8;z++){p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.BEDROCK.defaultBlockState());for(int y=1;y<=5;y++)p.level().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());}
for(int x=0;x<=1;x++)for(int y=2;y<=3;y++){p.level().setBlockAndUpdate(new BlockPos(x,y,3),Blocks.STONE.defaultBlockState());p.level().setBlockAndUpdate(new BlockPos(x,y,5),Blocks.COBBLESTONE.defaultBlockState());}
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.DIAMOND_PICKAXE));p.inventoryMenu.broadcastChanges();
});
c.waitFor(m->m.player.getMainHandItem().is(Items.DIAMOND_PICKAXE)&&m.level.getBlockState(new BlockPos(0,2,3)).is(Blocks.STONE),200);c.waitTicks(6);
c.getInput().lookAt(new BlockPos(0,2,3));c.getInput().holdKey(InputConstants.KEY_R);c.waitFor(m->MiningClient.previewSize()==4,200);
c.getInput().holdKey(o->o.keyAttack);server.waitFor(s->MiningManager.active(s.getPlayerList().getPlayers().getFirst()),200);
c.getInput().lookAt(new BlockPos(0,2,5));
server.waitFor(s->s.overworld().getBlockState(new BlockPos(0,2,5)).isAir(),500);
c.getInput().releaseKey(o->o.keyAttack);c.getInput().releaseKey(InputConstants.KEY_R);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();for(int x=0;x<=1;x++)for(int y=2;y<=3;y++)for(int z:new int[]{3,5})check(p.level().getBlockState(new BlockPos(x,y,z)).isAir(),"Both successive groups are mined while input stays held");check(p.getMainHandItem().getDamageValue()==8,"Eight breaks cost eight native tool uses");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();MiningManager.forget(p);for(int x=0;x<=1;x++)for(int y=2;y<=3;y++){p.level().setBlockAndUpdate(new BlockPos(x,y,3),Blocks.STONE.defaultBlockState());p.level().setBlockAndUpdate(new BlockPos(x,y,5),Blocks.COBBLESTONE.defaultBlockState());}var fragile=new ItemStack(Items.DIAMOND_PICKAXE);fragile.setDamageValue(fragile.getMaxDamage()-1);p.setItemInHand(InteractionHand.MAIN_HAND,fragile);p.inventoryMenu.broadcastChanges();});
c.waitFor(m->m.player.getMainHandItem().is(Items.DIAMOND_PICKAXE)&&m.level.getBlockState(new BlockPos(0,2,3)).is(Blocks.STONE),200);c.waitTicks(4);
c.getInput().lookAt(new BlockPos(0,2,3));c.getInput().holdKey(InputConstants.KEY_R);c.waitFor(m->MiningClient.previewSize()==4,200);c.getInput().holdKey(o->o.keyAttack);
server.waitFor(s->s.getPlayerList().getPlayers().getFirst().getMainHandItem().isEmpty(),300);c.waitTicks(35);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();int remaining=0;for(int x=0;x<=1;x++)for(int y=2;y<=3;y++){if(p.level().getBlockState(new BlockPos(x,y,3)).is(Blocks.STONE))remaining++;check(p.level().getBlockState(new BlockPos(x,y,5)).is(Blocks.COBBLESTONE),"Broken tool cannot restart on another group");}check(remaining==3&&!MiningManager.active(p),"Tool break interrupts held mining after one paid break");});
c.getInput().releaseKey(o->o.keyAttack);c.getInput().releaseKey(InputConstants.KEY_R);
System.out.println("SOLO232_PASS: timezone owner permissions and real commands, persistent override, FR/EN pause map gating and centered buttons, held mining follows next target with native costs");
}finally{intro.set(false);c.getInput().releaseKey(o->o.keyAttack);c.getInput().releaseKey(InputConstants.KEY_R);}
new Balance080ClientChecks().runTest(c);
}
private static void ranks(ServerPlayer p,boolean atlas){var r=ProgressRecord.INITIAL;var config=ProgressionService.session(p.level().getServer()).config;for(int i=0;i<7;i++)r=r.buy("mining",r.cost("mining",config));if(atlas)r=r.buy("atlas",r.cost("atlas",config));p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");}
private static AbstractWidget heading(net.minecraft.client.gui.screens.Screen screen,String key){return screen.children().stream().filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->w.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private interface Checked {void run()throws Exception;}
private static void checked(Checked action){try{action.run();}catch(Exception e){throw new RuntimeException(e);}}
private static void check(boolean ok,String reason){if(!ok)throw new AssertionError(reason);}
}
@@ -0,0 +1,72 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.progression.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.CharacterEvent;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Real native events on Story and Pause, using a fresh non-operator solo world. */
public final class Story234ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
var intro=new AtomicBoolean(true);
fr.koka.sanctuary.testing.QuickTestWorld.enable();
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Story234 "+UUID.randomUUID());s.setSeed("234");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("minecraft:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().getAbilities().invulnerable=true);
for(boolean unlocked:new boolean[]{false,true}) {
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var r=ProgressRecord.INITIAL;if(unlocked)r=r.buy("atlas",r.cost("atlas",ProgressionService.session(s).config));p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");});
c.waitFor(m->ProgressionClient.progress.atlas()==unlocked,200);
String before=server.computeOnServer(s->s.getPlayerList().getPlayers().getFirst().getAttached(ProgressionService.RECORD));
for(String language: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(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new PauseScreen(true));});
c.waitTicks(3);
c.runOnClient(m->Pause233ClientChecks.pause(m.gui.screen(),unlocked));
shot(c,"pause-"+unlocked+"-"+language+"-"+scale);
c.runOnClient(m->{var pause=m.gui.screen();press(pause,"sanctuary.story.title");check(m.gui.screen() instanceof StoryScreen,"Story opens from Pause without Atlas gating");check(m.gui.screen().isPauseScreen(),"Story retains solo pause");check(m.gui.screen().getTitle().getString().equals("Story"),"Story has its own accessible screen title");check(edit(m.gui.screen()).getValue().isEmpty()&&cycle(m.gui.screen()).getValue().toString().equals("ALL"),"Empty search and All on opening");assertEntries(m.gui.screen(),100);layout(m.gui.screen());});
shot(c,"story-"+language+"-"+scale);
c.runOnClient(m->{var s=m.gui.screen();var search=edit(s);s.setFocused(search);for(int codepoint:"100".codePoints().toArray()){check(s.charTyped(new CharacterEvent(codepoint)),"Native typing handled");check(s.getFocused()==search&&search.isFocused(),"List refresh retains search focus");}check(search.getValue().equals("100")&&search.getCursorPosition()==3,"Continuous typing retains cursor");assertEntries(s,1);check(entries(s).getFirst().getMessage().getString().equals(StoryScreen.label("unknown_entry",100).getString()),"Search selects story 100");search.setValue(" 100 ");assertEntries(s,1);cycle(s).onPress(enter());check(has(s,"sanctuary.story.no_discoveries")&&!has(s,"sanctuary.story.no_results")&&entries(s).isEmpty(),"Discovered has a distinct empty state");cycle(s).onPress(enter());check(cycle(s).getValue().toString().equals("UNKNOWN"),"Third filter is Unknown");assertEntries(s,1);search.setValue("absent-story");check(has(s,"sanctuary.story.no_results")&&!has(s,"sanctuary.story.no_discoveries"),"Search empty state is distinct");search.setValue(language.equals("fr_fr")?"INCONNUE":"UNKNOWN");assertEntries(s,100);var area=area(s);check(s.mouseScrolled(area.getX()+5,area.getY()+5,0,-5)&&area.scrollAmount()>0,"Native wheel scrolls Story");area.setScrollAmount(area.maxScrollAmount());check(entries(s).getLast().getBottom()<=area.getBottom(),"Story 100 can be reached at the end");area.setScrollAmount(100);});
c.runOnClient(m->{m.getWindow().setWindowed(1344,1008);m.resizeGui();});c.waitTicks(3);
c.runOnClient(m->{var s=m.gui.screen();check(edit(s).getValue().equals(language.equals("fr_fr")?"INCONNUE":"UNKNOWN")&&cycle(s).getValue().toString().equals("UNKNOWN"),"Resize retains query and filter");check(area(s).scrollAmount()==100,"Resize retains scroll position");layout(s);edit(s).setValue("100");check(area(s).scrollAmount()==0,"Search resets scroll");edit(s).setValue("");area(s).setScrollAmount(100);cycle(s).onPress(enter());check(cycle(s).getValue().toString().equals("ALL")&&area(s).scrollAmount()==0,"Filter resets scroll and cycles back to All");assertEntries(s,100);});
if(!unlocked&&language.equals("fr_fr")&&scale==2) {
c.runOnClient(m->cycle(m.gui.screen()).onPress(enter()));shot(c,"empty-discovered");
c.runOnClient(m->{cycle(m.gui.screen()).onPress(enter());edit(m.gui.screen()).setValue("absent-story");});shot(c,"empty-search");
c.runOnClient(m->edit(m.gui.screen()).setValue("100"));shot(c,"search-100");
}
c.runOnClient(m->{var story=(StoryScreen)m.gui.screen();var pause=story.parent;story.keyPressed(new KeyEvent(InputConstants.KEY_ESCAPE,0,0));check(m.gui.screen()==pause,"Escape returns directly to original Pause");press(pause,"sanctuary.story.title");press(m.gui.screen(),"gui.done");check(m.gui.screen()==pause,"Done returns directly to original Pause");m.gui.setScreen(null);});
c.waitTicks(2);
}
server.runOnServer(s->check(before.equals(s.getPlayerList().getPlayers().getFirst().getAttached(ProgressionService.RECORD)),"Reading and searching Story leave authoritative progression unchanged"));
}
System.out.println("STORY234_PASS: Vulkan FR/EN GUI 2/3/4, Atlas gating, 100 disabled entries, live search, three filters, distinct empty states, typing focus, wheel, resize, Escape/Done, unchanged progression");
c.runOnClient(m->m.gui.setScreen(null));
}finally{intro.set(false);}
}
private static List<Button> entries(Screen s){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals("sanctuary.story.unknown_entry")).toList();}
private static String key(AbstractWidget w){return w.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
private static CycleButton<?> cycle(Screen s){return widgets(s).filter(CycleButton.class::isInstance).map(CycleButton.class::cast).findFirst().orElseThrow();}
private static AbstractScrollArea area(Screen s){return widgets(s).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow();}
private static void assertEntries(Screen s,int expected){var rows=entries(s);check(rows.size()==expected,"Expected "+expected+" entries, got "+rows.size());check(rows.stream().allMatch(b->!b.active),"Unknown entries stay disabled");if(expected==100)for(int i=0;i<100;i++)check(rows.get(i).getMessage().getString().equals(StoryScreen.label("unknown_entry",i+1).getString()),"Numbers stay ordered without gaps or duplicates");}
private static void layout(Screen s){var search=edit(s);var filter=cycle(s);var list=area(s);check(search.getBottom()<filter.getY()&&filter.getBottom()<list.getY(),"Search, filter and list do not overlap");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,"Top-level widget stays inside the screen");var done=widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals("gui.done")).findFirst().orElseThrow();check(list.getBottom()<done.getY(),"List leaves room for native footer");}
private static void shot(ClientGameTestContext c,String name){var source=c.takeScreenshot("story234-"+name);try{var dir=FabricLoader.getInstance().getGameDir().resolve("../../../../../build/story234-screenshots").normalize();Files.createDirectories(dir);Files.copy(source,dir.resolve(name+".png"),StandardCopyOption.REPLACE_EXISTING);}catch(Exception e){throw new AssertionError(e);}}
}
@@ -46,6 +46,20 @@ public final class Realtime037GameTests {
h.assertTrue(RealtimeService.setMode(s,false),"Return to native clock");var nativeState=clocks.getInstance(over).packState();h.assertTrue(nativeState.rate()==2&&nativeState.paused()&&nativeState.totalTicks()==first.totalTicks(),"Native parameters restored at current time");
clocks.getInstance(over).loadFrom(new ClockState(nativeState.totalTicks(),0,3,false));RealtimeService.setMode(s,false);RealtimeService.reload(s);
h.assertTrue(clocks.getInstance(over).rate()==3&&!clocks.getInstance(over).isPaused(),"Repeated vanilla command/reload preserves subsequent operator edits");
var contract=s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-realtime.json");
var vanillaBefore=clocks.getInstance(over).packState();
h.assertTrue(!node.getChild("timezone").canUse(p.createCommandSourceStack()),"Dedicated non-operator cannot change the world's timezone");
h.assertTrue(commands.execute("sanctuary time timezone Asia/Tokyo",source)==1,"Timezone command accepts IANA names including slash");
h.assertTrue(RealtimeService.civilTime(s).getZone().equals(ZoneId.of("Asia/Tokyo"))&&clocks.getInstance(over).packState().equals(vanillaBefore),"Timezone choice preserves Vanilla mode and live clock");
byte[] chosen=Files.readAllBytes(contract);
h.assertTrue(commands.execute("sanctuary time timezone Asia/Tokyo",source)==1&&java.util.Arrays.equals(chosen,Files.readAllBytes(contract)),"Repeated timezone choice is idempotent");
h.assertTrue(commands.execute("sanctuary time timezone Unknown/Nowhere",source)==0&&java.util.Arrays.equals(chosen,Files.readAllBytes(contract)),"Unknown timezone preserves saved contract");
RealtimeService.reload(s);
h.assertTrue(RealtimeService.civilTime(s).getZone().equals(ZoneId.of("Asia/Tokyo")),"World override survives reload");
Files.writeString(contract,"{external-conflict");
h.assertTrue(commands.execute("sanctuary time timezone Europe/London",source)==0&&RealtimeService.civilTime(s).getZone().equals(ZoneId.of("Asia/Tokyo"))&&Files.readString(contract).equals("{external-conflict"),"Atomic conflict keeps current timezone and external data");
Files.write(contract,chosen);
h.assertTrue(commands.execute("sanctuary time timezone auto",source)==1&&RealtimeService.civilTime(s).getZone().equals(new RealtimeConfig(true,"Europe/Paris","auto").zone(s.isSingleplayer())),"Auto restores configured timezone");
commands.execute("time set 7000",source);h.assertTrue(clocks.getInstance(over).totalTicks()==7000,"Native command works again after opt-out");
RealtimeService.setMode(s,true);Files.writeString(configPath,new RealtimeConfig(false,"Europe/Paris","auto").json());RealtimeService.reload(s);
h.assertTrue(!RealtimeService.active(h.getLevel())&&clocks.getInstance(over).rate()==3,"Global disable restores the last native rate");
@@ -0,0 +1,55 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.worldgen.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.item.*;
import net.minecraft.world.level.storage.loot.*;
import net.minecraft.world.level.storage.loot.entries.LootItem;
import net.minecraft.world.level.storage.loot.parameters.*;
import net.minecraft.world.level.storage.loot.providers.number.ints.ConstantValue;
import net.minecraft.world.phys.Vec3;
public final class Solo232GameTests {
@GameTest public void originContainersExcludeDiamondsAndPreserveOtherLoot(GameTestHelper h) {
var level=h.getLevel();
h.assertTrue(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,"Exercise actual Sanctuary generator");
var generator=(SanctuaryChunkGenerator)level.getChunkSource().getGenerator();
var presets=level.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.WORLD_PRESET);
for(var size:fr.koka.sanctuarytest.SharedIsland196.Size.values()){
var current=presets.getValue(size.id()).createWorldDimensions().overworld();
h.assertTrue(IslandChestLoot.restricted(current,new Vec3(0,180,0)),"Current noise-based profile excludes island diamonds: "+size);
h.assertFalse(IslandChestLoot.restricted(current,new Vec3(size.diameter*3,180,size.diameter*3)),"Current profile preserves distant region loot: "+size);
}
var root=generator.islandAt(0,0);
h.assertTrue(root!=null&&root.origin(),"Initial island is registered");
var table=LootTable.lootTable().setParamSet(LootContextParamSets.CHEST)
.withPool(LootPool.lootPool().setRolls(net.minecraft.core.Holder.direct(new ConstantValue(1))).add(LootItem.lootTableItem(Items.DIAMOND)))
.withPool(LootPool.lootPool().setRolls(net.minecraft.core.Holder.direct(new ConstantValue(1))).add(LootItem.lootTableItem(Items.IRON_INGOT))).build();
for(long seed:new long[]{0,42,232}) {
var origin=new Vec3(0,180,0);
var params=new LootParams.Builder(level).withParameter(LootContextParams.ORIGIN,origin).create(LootContextParamSets.CHEST);
h.assertTrue(table.getRandomItems(params,seed).stream().anyMatch(s->s.is(Items.DIAMOND)),"Raw loot outside container fill remains native");
var chest=new SimpleContainer(27);chest.setItem(0,new ItemStack(Items.DIAMOND,9));
table.fill(chest,params,seed);
h.assertTrue(chest.countItem(Items.DIAMOND)==9&&chest.countItem(Items.IRON_INGOT)==1,"Only generated diamonds are removed; deposited diamonds and iron stay");
var outside=new Vec3(root.centerX()+root.diameter()*3,180,root.centerZ()+root.diameter()*3);
h.assertFalse(IslandChestLoot.restricted(level,outside),"Void or expansion outside origin is unrestricted");
var other=new SimpleContainer(27);table.fill(other,new LootParams.Builder(level).withParameter(LootContextParams.ORIGIN,outside).create(LootContextParamSets.CHEST),seed);
h.assertTrue(other.countItem(Items.DIAMOND)==1&&other.countItem(Items.IRON_INGOT)==1,"Other regions retain original chest loot");
}
h.assertFalse(IslandChestLoot.restricted(level,null),"Missing context never suppresses unrelated loot");
var mine=level.getServer().reloadableRegistries().getLootTable(BuiltInLootTables.ABANDONED_MINESHAFT);
var params=new LootParams.Builder(level).withParameter(LootContextParams.ORIGIN,new Vec3(0,180,0)).create(LootContextParamSets.CHEST);
boolean nativeDiamond=false;
for(int seed=1;seed<=256;seed++) {
nativeDiamond|=mine.getRandomItems(params,seed).stream().anyMatch(s->s.is(Items.DIAMOND));
var container=new SimpleContainer(27);mine.fill(container,params,seed);
h.assertTrue(container.countItem(Items.DIAMOND)==0,"Native mineshaft chest seed "+seed+" contains no diamonds");
}
h.assertTrue(nativeDiamond,"Native mineshaft table would actually have yielded diamonds");
h.succeed();
}
}
@@ -21,9 +21,8 @@ public final class BuildReach {
return new AABB(support).distanceToSqr(player.getEyePosition())<range*range;
}
public static double pickRange(Player player) {
double base=Math.max(player.blockInteractionRange(),fr.koka.sanctuary.mining.MiningReach.range(player));
return player.getMainHandItem().getItem() instanceof BlockItem || player.getOffhandItem().getItem() instanceof BlockItem
? Math.max(base,range(player)) : base;
? range(player) : Math.max(player.blockInteractionRange(),fr.koka.sanctuary.mining.MiningReach.range(player));
}
public static boolean grouped(Player player) {
return player instanceof ServerPlayer server && BuildingManager.placing(server);
@@ -3,7 +3,7 @@ package fr.koka.sanctuary.building;
/** Surface size and placement reach progress independently; entity reach stays native. */
public final class BuildingLimits {
public static final int MAX_RANK=7;
private static final double[] REACH={3,3.5,4,4.5,4.75,5,5.5,6};
private static final double[] REACH={3,3.5,4,4.5,5,5.5,6,7};
private BuildingLimits() { }
public static double reach(int rank) {return REACH[Math.clamp(rank,0,MAX_RANK)];}
public static int diameter(int rank) {return 1+2*Math.clamp(rank,0,MAX_RANK);}
@@ -17,7 +17,11 @@ abstract class BlocodexFrame extends Screen {
private HeaderAndFooterLayout frame;
protected BlocodexFrame(Screen parent) {
super(ProgressionService.label("title"));
this(parent,ProgressionService.label("title"));
}
protected BlocodexFrame(Screen parent,net.minecraft.network.chat.Component pageTitle) {
super(pageTitle);
this.parent=parent;
}
@@ -11,15 +11,14 @@ 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. */
/** A single accessible card, with a face/title row and a shared notice style. */
final class CommunityCard extends AbstractButton implements AutoCloseable {
static final int NOTICE_COLOR=0xffead47e,NOTICE_BACKGROUND=0xff34301f;
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"))));
@@ -35,8 +34,8 @@ final class CommunityCard extends AbstractButton implements AutoCloseable {
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")));
int x=getX(),y=getY(),accent=article?0xffaebfae:NOTICE_COLOR;var font=Minecraft.getInstance().font;
g.fill(x,y,getRight(),getBottom(),article?0xff202720:NOTICE_BACKGROUND);
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;
@@ -48,11 +47,11 @@ final class CommunityCard extends AbstractButton implements AutoCloseable {
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);
if(compact)g.text(font,font.plainSubstrByWidth(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);
if(!article)g.text(font,font.plainSubstrByWidth(text(str(entry,"status")).getString(),width-14),x+7,top+59,accent,false);
}
}
@Override protected void updateWidgetNarration(NarrationElementOutput output){defaultButtonNarrationText(output);}
@@ -23,7 +23,7 @@ final class CommunityMapMarkers {
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"))));
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.NOTICE_COLOR));
}
markers=List.copyOf(next);
}
@@ -27,7 +27,7 @@ final class CommunityNoticeScene extends AbstractWidget {
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);
heading=font.split(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);
@@ -44,10 +44,10 @@ final class CommunityNoticeScene extends AbstractWidget {
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"));
int x=getX(),y=getY(),bx=x+bubbleX,accent=CommunityCard.NOTICE_COLOR;
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.fill(bx,y,getRight(),getBottom(),CommunityCard.NOTICE_BACKGROUND);
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;
@@ -23,39 +23,63 @@ public final class CommunityPausePanels implements AutoCloseable {
private boolean closed;
public CommunityPausePanels(Screen screen,Consumer<AbstractWidget> add,List<Button> nativeButtons) {
this.screen=screen;
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;
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));
boolean compact=screen.height<280;
boolean unlocked=ProgressionClient.progress.atlas();
int margin=8,gap=10,top=compact?46:50,bottom=screen.height-39;
String[] sections={"blocks","identity","progress","faction","story","battle"};
int columnGap=compact?4:8;
int minimumWidth=Arrays.stream(sections).map(CommunityPausePanels::sectionLabel).mapToInt(Minecraft.getInstance().font::width).max().orElse(0)+8;
int columnWidth=Math.max(minimumWidth,(Math.min(264,screen.width/3)-columnGap)/2);
int menuWidth=unlocked?Math.max(minimumWidth,Math.min(132,Math.max(76,screen.width/5))):2*columnWidth+columnGap;
int right=unlocked?Math.min(244,Math.max(104,screen.width*31/100))
:Math.min(244,(screen.width-2*margin-menuWidth-2*gap)/2);
int centerX=margin+menuWidth+gap,rightX=screen.width-margin-right,centerWidth=rightX-gap-centerX;
int menuX=unlocked?margin:(screen.width-menuWidth)/2;
int communityX=unlocked?menuX:menuX+columnWidth+columnGap;
int navGap=compact?(unlocked?1:2):4,groupGap=compact?(unlocked?2:8):12,titleHeight=compact&&unlocked?10:12;
int groupCount=unlocked?3:2;
int navHeight=Math.min(compact?14:20,Math.max(11,(bottom-top-16-groupCount*titleHeight-(groupCount-1)*groupGap-2*groupCount*navGap)/(3*groupCount)));
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);
int sectionHeight=titleHeight+3*navHeight+2*navGap;
int menuHeight=groupCount*sectionHeight+(groupCount-1)*groupGap;
// Use a narrow stack beside the map, or parallel groups before Atlas.
int menuTop=top+Math.max(0,(bottom-top-menuHeight)/2);
int adventureTop=unlocked?menuTop+sectionHeight+groupGap:menuTop;
int communityTop=menuTop;
int gameTop=menuTop+(groupCount-1)*(sectionHeight+groupGap);
int optionsTop=gameTop+titleHeight;
add.accept(new Frame(screen.width,screen.height));
date=new Calendar(margin,3,Math.min(132,Math.max(76,screen.width/5)));
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","progress","identity"};
for(int i=0;i<sections.length;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,y,left,navHeight).build());
add.accept(new StringWidget(menuX,adventureTop,unlocked?menuWidth:columnWidth,9,text("pause_group_adventure").withStyle(net.minecraft.ChatFormatting.GRAY),Minecraft.getInstance().font));
add.accept(new StringWidget(communityX,communityTop,unlocked?menuWidth:columnWidth,9,text("pause_group_community").withStyle(net.minecraft.ChatFormatting.GRAY),Minecraft.getInstance().font));
add.accept(new StringWidget(menuX,gameTop,menuWidth,9,text("pause_group_game").withStyle(net.minecraft.ChatFormatting.GRAY),Minecraft.getInstance().font));
if(unlocked)sections=new String[]{"identity","faction","battle","blocks","progress","story"};
for(String target:sections) {
boolean adventure=target.equals("blocks")||target.equals("progress")||target.equals("story");
int y=(adventure?adventureTop:communityTop)+titleHeight+step*switch(target){case "blocks","identity"->0;case "progress","faction"->1;default->2;};
add.accept(Button.builder(sectionLabel(target),b->{switch(target){case "faction"->CycleClient.open(screen,"faction");case "battle"->ArenaClient.open(screen);default->ProgressionClient.open(screen,target);}})
.bounds(adventure?menuX:communityX,y,unlocked?menuWidth:columnWidth,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);
map=unlocked?new PauseAtlasWidget(screen,centerX,top,centerWidth,Math.max(30,bottom-top)):null;
if(map!=null)add.accept(map);
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,optionHeight);
place(nativeButtons,add,"options.worldOptions.button",margin,optionsTop+optionHeight+4,left,optionHeight);
articles=new PauseCommunityFeed(screen,add,unlocked?rightX:margin,top,right,unlocked?feedHeight:bottom-top,"article");
notices=new PauseCommunityFeed(screen,add,rightX,unlocked?top+feedHeight+gap:top,right,unlocked?bottom-top-feedHeight-gap:bottom-top,"notice");
place(nativeButtons,add,"menu.options",menuX,optionsTop,menuWidth,navHeight);
place(nativeButtons,add,"options.worldOptions.button",menuX,optionsTop+step,menuWidth,navHeight);
for(var button:nativeButtons) if(key(button).equals("menu.returnToMenu") || key(button).equals("menu.disconnect")) {
button.setRectangle(left,optionHeight,margin,optionsTop+2*(optionHeight+4));button.setTooltip(Tooltip.create(button.getMessage()));add.accept(button);
button.setRectangle(menuWidth,navHeight,menuX,optionsTop+2*step);button.setTooltip(Tooltip.create(button.getMessage()));add.accept(button);
}
updateHeader();
}
private static Component sectionLabel(String target) {
return switch(target){case "story"->StoryScreen.label("title");case "faction"->fr.koka.sanctuary.cycle.CycleService.label("faction");case "battle"->fr.koka.sanctuary.arena.Arenas.text("title");default->fr.koka.sanctuary.progression.ProgressionService.label(target);};
}
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) {
place(buttons,add,key,x,y,width,20);
@@ -79,14 +103,12 @@ public final class CommunityPausePanels implements AutoCloseable {
}
/** 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;}
Frame(int width,int height){super(0,0,width,height,Component.empty());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) { }
}
@@ -113,11 +135,11 @@ public final class CommunityPausePanels implements AutoCloseable {
}
public void tick() {
if(closed)return;
map.tick(); updateHeader();
if(map!=null)map.tick(); updateHeader();
if(++ticks%6!=0 || pending() || !available())return;
if(ticks==6 || ticks%240==0){overview(screen);if(ticks>6){articles.refresh();notices.refresh();}}
else if(!articles.load())notices.load();
}
public PauseAtlasWidget map() { return map; }
@Override public void close() {closed=true;articles.close();notices.close();map.close();}
@Override public void close() {closed=true;articles.close();notices.close();if(map!=null)map.close();}
}
@@ -100,7 +100,7 @@ public final class CommunityScreen extends BlocodexFrame {
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("notice")) line(layout,w,text(str(entry,"status")));
if (!kind.equals("bulletin")) { replies(layout,w,thread);replyComposer(layout,w,entry); }
}
private void replies(LinearLayout layout,int w,JsonObject thread) {
@@ -199,26 +199,22 @@ public final class CommunityScreen extends BlocodexFrame {
}
private void compose(LinearLayout layout,int w) {
boolean reply = !id.isEmpty() && (editingId.isEmpty() || !editingId.equals(id));
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")) 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())));
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);
}
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);
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())));
}
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";
@@ -15,6 +15,7 @@ import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.Item;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.HitResult;
@@ -25,12 +26,23 @@ public final class MiningClient {
private static BlockPos active;
private static Item tool;
private static int slot;
private static boolean engaged;
private static boolean engaged,completed;
private static BlockState anchorState;
private static List<BlockPos> locked=List.of();
private static List<BlockPos> preview=List.of();
private MiningClient() { }
public static void register(KeyMapping.Category category) {
key=KeyMappingHelper.registerKeyMapping(new KeyMapping("sanctuary.mining.key",InputConstants.Type.KEYBOARD,InputConstants.KEY_R,category));
ClientPlayNetworking.registerGlobalReceiver(MiningResultPayload.TYPE,(payload,context)->context.client().execute(()->{
var c=context.client();
if(active==null||payload.gesture()!=gesture)return;
boolean resume=payload.completed()&&enabled(c)&&c.options.keyAttack.isDown()
&&c.player.getMainHandItem().getItem()==tool&&c.player.getInventory().getSelectedSlot()==slot;
// The result can precede the tick's queued block updates. Keep the old anchor until
// its removal arrives, otherwise a stale raycast would restart a mined block.
if(resume)completed=true;
else {stop();preview=List.of();}
}));
ClientPreAttackCallback.EVENT.register((client,player,clicks) -> {
if (!enabled(client)) return false;
// Even a rejected/empty selection consumes the modified gesture: it must not mine individually.
@@ -64,6 +76,7 @@ public final class MiningClient {
if (active!=null && (c.player.getMainHandItem().getItem()!=tool || c.player.getInventory().getSelectedSlot()!=slot)) {
stop(); // Keep the gesture latched until release, even after an interruption.
}
if(completed&&active!=null&&c.level.getBlockState(active)!=anchorState){stop();engaged=false;}
if (BuildingClient.previewMode(c)) {stop();preview=List.of();return;}
BlockPos target=c.hitResult instanceof BlockHitResult hit && hit.getType()!=HitResult.Type.MISS && fr.koka.sanctuary.mining.MiningReach.within(c.player,hit.getBlockPos(),0) ? hit.getBlockPos() : null;
preview=engaged ? locked.stream().filter(p -> !c.level.getBlockState(p).isAir()).toList()
@@ -71,9 +84,10 @@ public final class MiningClient {
MiningPreviewRenderer.collect(c,preview,0xFFE4B85F);
if (!c.options.keyAttack.isDown()) return;
if (!engaged) {
engaged=true;
if (target==null || preview.isEmpty()) return;
engaged=true;
active=target.immutable();locked=List.copyOf(preview);tool=c.player.getMainHandItem().getItem();slot=c.player.getInventory().getSelectedSlot();
anchorState=c.level.getBlockState(active);
gesture=gesture==Integer.MAX_VALUE ? 1 : gesture+1;heartbeat=0;animation=0;
c.gameMode.stopDestroyBlock();send(true);swing(c);
} else if (active!=null && ++heartbeat>=5) {heartbeat=0;send(true);}
@@ -98,8 +112,8 @@ public final class MiningClient {
private static void send(boolean down) {
if (active!=null && ClientPlayNetworking.canSend(MiningPayload.TYPE)) ClientPlayNetworking.send(new MiningPayload(gesture,down,active,selected));
}
private static void stop() {if (active!=null) send(false);active=null;tool=null;locked=List.of();heartbeat=0;animation=0;}
private static void reset() {active=null;tool=null;preview=List.of();locked=List.of();selected=1;earnedBefore=0;heartbeat=0;animation=0;engaged=false;}
private static void stop() {if (active!=null) send(false);active=null;tool=null;anchorState=null;completed=false;locked=List.of();heartbeat=0;animation=0;}
private static void reset() {active=null;tool=null;anchorState=null;completed=false;preview=List.of();locked=List.of();selected=1;earnedBefore=0;heartbeat=0;animation=0;engaged=false;}
public static Component label(String name,Object... args) {return Component.translatable("sanctuary.mining."+name,args);}
public static boolean modifierDown() {return key!=null && key.isDown();}
public static Component modifierLabel() {return key==null ? Component.literal("R") : key.getTranslatedKeyMessage();}
@@ -54,7 +54,7 @@ public final class NotificationClient {
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);
var track=new Track(Component.literal(CommunityClient.str(e,"title")),detail,closed?1:0,CommunityCard.NOTICE_COLOR&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);
@@ -30,6 +30,7 @@ public final class ProgressionClient {
public static void open(Screen parent,String section) {
var gui=Minecraft.getInstance().gui;
if (section.equals("blocks")) gui.setScreen(new SanctuaryBlocodexScreen(parent));
else if (section.equals("story") && active()) gui.setScreen(new StoryScreen(parent));
else if (active()) gui.setScreen(section.equals("history") ? new SanctuaryAtlasScreen(parent) : new ProgressionScreen(parent,section));
}
public static void register() {
@@ -0,0 +1,64 @@
package fr.koka.sanctuary.client;
import java.util.Locale;
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;
/** The first Story catalogue reserves 100 unknown stories without inventing discoveries. */
public final class StoryScreen extends BlocodexFrame {
private enum Filter {
ALL, DISCOVERED, UNKNOWN;
Component label() { return StoryScreen.label("filter_"+name().toLowerCase(Locale.ROOT)); }
}
private String query="";
private Filter filter=Filter.ALL;
private ScrollableLayout scroll;
public StoryScreen(Screen parent) { super(parent,label("title")); }
public static Component label(String key,Object... args) {
return Component.translatable("sanctuary.story."+key,args);
}
@Override protected void init() {
initFrame("story",label("title"));
var search=addRenderableWidget(new EditBox(font,contentX,contentTop,contentWidth,20,label("search")));
search.setMaxLength(128);
search.setValue(query);
// Native hints disappear on focus; a suggestion keeps the empty search identifiable.
search.setSuggestion(query.isEmpty()?label("search").getString():null);
search.setResponder(value->{query=value;search.setSuggestion(value.isEmpty()?label("search").getString():null);refreshList(0);});
addRenderableWidget(CycleButton.builder(Filter::label,filter).withValues(Filter.values())
.create(contentX,contentTop+24,contentWidth,20,label("filter"),(button,value)->{
filter=value;refreshList(0);
}));
contentTop+=50;
refreshList(scroll==null?0:scroll.getScrollAmount());
setInitialFocus(search);
}
/** Replace only list widgets: search/cursor and keyboard focus remain untouched. */
private void refreshList(double offset) {
if(scroll!=null)scroll.visitWidgets(this::removeWidget);
var contents=LinearLayout.vertical().spacing(4);
String needle=query.strip().toLowerCase(Locale.ROOT);
int count=0;
if(filter!=Filter.DISCOVERED)for(int number=1;number<=100;number++) {
Component name=label("unknown_entry",number);
if(!name.getString().toLowerCase(Locale.ROOT).contains(needle))continue;
var entry=contents.addChild(Button.builder(name,button->{}).width(contentWidth-18).build());
entry.active=false;
count++;
}
if(count==0)contents.addChild(new MultiLineTextWidget(label(filter==Filter.DISCOVERED?"no_discoveries":"no_results"),font)
.setMaxWidth(contentWidth-18));
int height=Math.max(20,contentBottom-contentTop);
scroll=new ScrollableLayout(minecraft,contents,height,ScrollableLayout.ReserveStrategy.RIGHT);
scroll.setMinHeight(height);
scroll.arrangeElements();scroll.setX(contentX);scroll.setY(contentTop);
scroll.visitWidgets(widget->{addRenderableWidget(widget);if(widget instanceof AbstractScrollArea area)area.setScrollAmount(offset);});
}
}
@@ -29,6 +29,7 @@ public final class MiningManager {
private MiningManager() { }
public static void register() {
PayloadTypeRegistry.serverboundPlay().register(MiningPayload.TYPE,MiningPayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(MiningResultPayload.TYPE,MiningResultPayload.CODEC);
ServerPlayNetworking.registerGlobalReceiver(MiningPayload.TYPE,(p,c) -> c.server().execute(() -> handle(c.player(),p)));
ServerTickEvents.END_SERVER_TICK.register(MiningManager::tick);
ServerPlayConnectionEvents.DISCONNECT.register((handler,server) -> forget(handler.player));
@@ -64,6 +65,7 @@ public final class MiningManager {
((MiningVanillaAccess)player.gameMode).sanctuary$cancelVanillaMining();
}
ATTEMPTS.put(player,new Attempt(payload,rank,next));
if(next==null)result(player,payload.gesture(),false);
}
public static void tick(MinecraftServer server) {
ATTEMPTS.forEach((player,attempt) -> {
@@ -72,9 +74,14 @@ public final class MiningManager {
}
public static void tick(ServerPlayer player) {
var attempt=ATTEMPTS.get(player);
if (attempt!=null && attempt.session!=null && !attempt.session.tick()) attempt.stop();
if (attempt!=null && attempt.session!=null && !attempt.session.tick()) {
boolean completed=attempt.session.completed();attempt.stop();result(player,attempt.gesture,completed);
}
}
public static void interrupt(ServerPlayer player) {var attempt=ATTEMPTS.get(player);if (attempt!=null&&attempt.session!=null) {attempt.stop();result(player,attempt.gesture,false);}}
private static void result(ServerPlayer player,int gesture,boolean completed) {
if(ServerPlayNetworking.canSend(player,MiningResultPayload.TYPE))ServerPlayNetworking.send(player,new MiningResultPayload(gesture,completed));
}
public static void interrupt(ServerPlayer player) {var attempt=ATTEMPTS.get(player);if (attempt!=null) attempt.stop();}
public static void forget(ServerPlayer player) {interrupt(player);ATTEMPTS.remove(player);LAST_START.remove(player);}
public static boolean active(ServerPlayer player) {var a=ATTEMPTS.get(player);return a!=null && a.session!=null;}
public static int selectedCount(ServerPlayer player) {var a=ATTEMPTS.get(player);return a==null || a.session==null ? 0 : a.session.size();}
@@ -0,0 +1,15 @@
package fr.koka.sanctuary.mining;
import fr.koka.sanctuary.SanctuaryMod;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
/** Only successful, fully paid server work may unlock the next held selection. */
public record MiningResultPayload(int gesture,boolean completed) implements CustomPacketPayload {
public static final Type<MiningResultPayload> TYPE=new Type<>(SanctuaryMod.id("mining_result"));
public static final StreamCodec<RegistryFriendlyByteBuf,MiningResultPayload> CODEC=CustomPacketPayload.codec(
(p,b)->{b.writeVarInt(p.gesture);b.writeBoolean(p.completed);},
b->new MiningResultPayload(b.readVarInt(),b.readBoolean()));
public Type<MiningResultPayload> type(){return TYPE;}
}
@@ -29,7 +29,7 @@ final class MiningSession {
private ItemStack expected;
private int lastHeartbeat,chargeIndex,nextBreak,crack=-2;
private double progress;
private boolean breaking,sounded;
private boolean breaking,sounded,completed;
private MiningSession(ServerPlayer player,BlockPos origin,int rank,List<BlockPos> blocks) {
this.player=player;level=player.level();this.origin=origin.immutable();this.rank=rank;this.blocks=blocks;
states=blocks.stream().map(level::getBlockState).toList();
@@ -42,6 +42,7 @@ final class MiningSession {
return blocks.isEmpty() ? null : new MiningSession(player,origin,rank,blocks);
}
int size() {return blocks.size();}
boolean completed() {return completed;}
void refresh() {lastHeartbeat=level.getServer().getTickCount();}
boolean tick() {
var earned=ProgressEffects.get(player);
@@ -92,7 +93,8 @@ final class MiningSession {
if (!expected.isEmpty() && tool.isEmpty()) return false; // Tool broke: never continue with a bare hand.
expected=tool.copy();
}
return nextBreak<blocks.size();
completed=nextBreak==blocks.size();
return !completed;
}
void stop() {updateCracks(-1);}
private void updateCracks(int stage) {
@@ -0,0 +1,21 @@
package fr.koka.sanctuary.mixin;
import com.llamalad7.mixinextras.injector.wrapoperation.*;
import fr.koka.sanctuary.worldgen.IslandChestLoot;
import it.unimi.dsi.fastutil.objects.ObjectArrayList;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.storage.loot.LootContext;
import net.minecraft.world.level.storage.loot.LootTable;
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
@Mixin(LootTable.class)
public abstract class IslandChestLootMixin {
@WrapOperation(method="fill",at=@At(value="INVOKE",target="Lnet/minecraft/world/level/storage/loot/LootTable;getRandomItems(Lnet/minecraft/world/level/storage/loot/LootContext;)Lit/unimi/dsi/fastutil/objects/ObjectArrayList;"))
private ObjectArrayList<ItemStack> sanctuary$islandLoot(LootTable table,LootContext context,Operation<ObjectArrayList<ItemStack>> original) {
var loot=original.call(table,context);
IslandChestLoot.filter(context.getLevel(),context.getOptional(LootContextParams.ORIGIN),loot);
return loot;
}
}
@@ -1,6 +1,7 @@
package fr.koka.sanctuary.realtime;
import fr.koka.sanctuary.SanctuaryMod;
import com.mojang.brigadier.arguments.StringArgumentType;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
@@ -30,16 +31,27 @@ public final class RealtimeService {
final ZoneId zone;
final SolarReference reference;
final SolarTime sun;
final boolean customTimezone;
final RealtimeStore store;
// Clock injection keeps deterministic time tests outside the game's command surface.
Clock clock;
boolean healthy=true;
Session(MinecraftServer server,RealtimeConfig config,Clock clock)throws Exception{
this.server=server;this.config=config;this.clock=clock;zone=config.zone(server.isSingleplayer());reference=config.reference(server.isSingleplayer(),clock.instant());sun=new SolarTime(zone,reference);
definition=server.registryAccess().lookupOrThrow(Registries.WORLD_CLOCK).getOrThrow(WorldClocks.OVERWORLD);
store=new RealtimeStore(server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-realtime.json"),instance().packState(),sun.sample(clock.instant()),key(),config.enabled());
this(server,config,clock,null,null);
}
String key(){return zone.getId()+"|"+reference.name()+"|"+reference.latitude()+"|"+reference.longitude();}
Session(MinecraftServer server,RealtimeConfig config,Clock clock,RealtimeStore existing,String choice)throws Exception{
this.server=server;this.config=config;this.clock=clock;
definition=server.registryAccess().lookupOrThrow(Registries.WORLD_CLOCK).getOrThrow(WorldClocks.OVERWORLD);
var defaultZone=config.zone(server.isSingleplayer());var defaultReference=config.reference(server.isSingleplayer(),clock.instant());
var defaultSun=new SolarTime(defaultZone,defaultReference);
store=existing!=null?existing:new RealtimeStore(server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-realtime.json"),instance().packState(),defaultSun.sample(clock.instant()),referenceKey(defaultZone,defaultReference),config.enabled());
if(choice==null&&store.state().reference().startsWith("timezone:"))choice=store.state().reference().substring(9).split("\\|",-1)[0];
customTimezone=choice!=null&&!choice.equals("auto");
zone=customTimezone?ZoneId.of(choice):defaultZone;
reference=customTimezone?SolarReference.resolve(zone,"auto",clock.instant()):defaultReference;
sun=new SolarTime(zone,reference);
}
String key(){return (customTimezone?"timezone:":"")+referenceKey(zone,reference);}
public boolean active(){return healthy&&config.enabled()&&store.state().realtime();}
ServerClockManager.ServerClockInstance instance(){return server.clockManager().getInstance(definition);}
ClockState savedNative(ClockState current){return new ClockState(current.totalTicks(),current.partialTick(),store.state().nativeRate(),store.state().nativePaused());}
@@ -56,6 +68,7 @@ public final class RealtimeService {
}catch(Exception error){healthy=false;restoreRate();SanctuaryMod.LOGGER.error("Realtime clock stopped; contract retained and native speed restored",error);}
}
}
private static String referenceKey(ZoneId zone,SolarReference reference){return zone.getId()+"|"+reference.name()+"|"+reference.latitude()+"|"+reference.longitude();}
public static boolean southernHemisphere(MinecraftServer server){var s=SESSIONS.get(server);return s!=null&&s.reference.latitude()<0;}
public static Session session(MinecraftServer server){return SESSIONS.get(server);}
/** Civil timestamps share the configured sun timezone without changing the game clock. */
@@ -68,10 +81,35 @@ public final class RealtimeService {
ServerLifecycleEvents.SERVER_STOPPED.register(SESSIONS::remove);
CommandRegistrationCallback.EVENT.register((dispatcher,context,environment)->dispatcher.register(Commands.literal("sanctuary").then(Commands.literal("time")
.executes(c->status(c.getSource()))
.then(Commands.literal("timezone").requires(RealtimeService::canChangeTimezone).executes(c->status(c.getSource()))
.then(Commands.argument("zone",StringArgumentType.greedyString())
.suggests((c,b)->SharedSuggestionProvider.suggest(java.util.stream.Stream.concat(java.util.stream.Stream.of("auto"),ZoneId.getAvailableZoneIds().stream().sorted()),b))
.executes(c->timezone(c.getSource(),StringArgumentType.getString(c,"zone")))))
.then(Commands.literal("realtime").requires(fr.koka.sanctuary.operator.OperatorService::canCommand).executes(c->change(c.getSource(),true)))
.then(Commands.literal("vanilla").requires(fr.koka.sanctuary.operator.OperatorService::canCommand).executes(c->change(c.getSource(),false)))
.then(Commands.literal("reload").requires(fr.koka.sanctuary.operator.OperatorService::canCommand).executes(c->{try{reload(c.getSource().getServer());return status(c.getSource());}catch(Exception e){c.getSource().sendFailure(label("invalid"));SanctuaryMod.LOGGER.error("Realtime reload refused",e);return 0;}})))));
}
public static boolean canChangeTimezone(CommandSourceStack source){
return source.getEntity() instanceof net.minecraft.server.level.ServerPlayer player
&& source.getServer().isSingleplayer()&&source.getServer().isSingleplayerOwner(player.nameAndId())
|| fr.koka.sanctuary.operator.OperatorService.canCommand(source);
}
public static boolean setTimezone(MinecraftServer server,String choice)throws Exception{
if(!choice.equals("auto"))ZoneId.of(choice);
var previous=session(server);if(previous==null||!previous.healthy)return false;
var next=new Session(server,previous.config,previous.clock,previous.store,choice);
if(previous.key().equals(next.key()))return true;
var nativeClock=previous.active()?previous.savedNative(previous.instance().packState()):previous.instance().packState();
next.store.reanchor(next.store.state().realtime(),nativeClock,next.sample(),next.key());
SESSIONS.put(server,next);next.tick();sync(server);return true;
}
private static int timezone(CommandSourceStack source,String choice){
try{
if(!setTimezone(source.getServer(),choice)){source.sendFailure(label("inactive"));return 0;}
return status(source);
}catch(DateTimeException e){source.sendFailure(label("timezone_invalid",choice));return 0;}
catch(Exception e){source.sendFailure(label("invalid"));SanctuaryMod.LOGGER.error("Realtime timezone change refused",e);return 0;}
}
public static void reload(MinecraftServer server)throws Exception{
if(!supported(server))return;
var previous=session(server);var config=RealtimeConfig.load(CONFIG);
@@ -98,6 +136,7 @@ public final class RealtimeService {
if(!s.healthy){source.sendFailure(label("invalid"));return 0;}
var sample=s.sample();String rise=sample.day().sunrise()==null?"—":HOUR.format(sample.day().sunrise().atZone(s.zone));String set=sample.day().sunset()==null?"—":HOUR.format(sample.day().sunset().atZone(s.zone));
source.sendSuccess(()->label(s.active()?"status":"vanilla",CIVIL.format(s.clock.instant().atZone(s.zone)),rise,set),false);
source.sendSuccess(()->label("timezone",s.zone.getId()),false);
if(s.reference.approximate())source.sendSuccess(()->label("approximate"),false);return 1;
}
public static Component label(String key,Object... args){return Component.translatable("sanctuary.realtime."+key,args);}
@@ -0,0 +1,31 @@
package fr.koka.sanctuary.worldgen;
import java.util.List;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.level.chunk.ChunkGenerator;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import fr.koka.sanctuarytest.LabExpansions199;
import fr.koka.sanctuarytest.SharedIsland196;
/** A loot rule at first container fill; existing inventories and terrain are never rewritten. */
public final class IslandChestLoot {
private IslandChestLoot() { }
public static boolean restricted(ServerLevel level,Vec3 origin) {
return restricted(level.getChunkSource().getGenerator(),origin);
}
public static boolean restricted(ChunkGenerator generator,Vec3 origin) {
if(origin==null)return false;
var pos=BlockPos.containing(origin);var adapter=LabExpansions199.get(generator);
if(adapter!=null){var island=adapter.islandAt(pos.getX(),pos.getZ());return island!=null&&island.origin();}
if(generator instanceof SanctuaryChunkGenerator historical){var island=historical.islandAt(pos.getX(),pos.getZ());return island!=null&&island.origin();}
var size=SharedIsland196.size(generator);
return size!=null&&ExpansionIsland.origin(size.diameter,0,24).contains(pos.getX(),pos.getZ());
}
public static void filter(ServerLevel level,Vec3 origin,List<ItemStack> loot) {
if(restricted(level,origin))loot.removeIf(stack->stack.is(Items.DIAMOND));
}
}
@@ -1,4 +1,13 @@
{
"sanctuary.story.title": "Story",
"sanctuary.story.search": "Search stories…",
"sanctuary.story.filter": "Discovery",
"sanctuary.story.filter_all": "All",
"sanctuary.story.filter_discovered": "Discovered",
"sanctuary.story.filter_unknown": "Unknown",
"sanctuary.story.unknown_entry": "Story %s — Unknown",
"sanctuary.story.no_discoveries": "No stories discovered yet.",
"sanctuary.story.no_results": "No stories match the search.",
"biome.sanctuary.diagonal_lush227": "Mangrove Lush Caves",
"biome.sanctuary.diagonal_dripstone227": "Mangrove Dripstone Caves",
"biome.sanctuary.diagonal_swamp226": "Cool Swamp",
@@ -436,7 +445,7 @@
"sanctuary.atlas.zoom_in": "Zoom in",
"sanctuary.atlas.zoom_out": "Zoom out",
"sanctuary.mining.key": "Group mining and building (hold)",
"sanctuary.mining.controls": "Hold %s to preview the vein. Hold left click to lock and mine, with free look. Release to select another group. Scroll before clicking to choose size.",
"sanctuary.mining.controls": "Hold %s to preview the vein. Hold left click to mine the group, then the one you are looking at. Scroll before clicking to choose size. Release to stop.",
"sanctuary.mining.no_target": "No vein selected",
"sanctuary.mining.selection": "%s / %s blocks",
"sanctuary.mining.size": "Mining · rank %s · up to %s blocks",
@@ -1226,6 +1235,8 @@
"sanctuary.realtime.invalid": "The real-time configuration or world contract is invalid. See the server log.",
"sanctuary.realtime.approximate": "No reference city for this time zone: using an approximate equatorial sun.",
"sanctuary.realtime.managed": "Real time controls this clock. Use /sanctuary time vanilla before changing it.",
"sanctuary.realtime.timezone": "Time zone: %s · /sanctuary time timezone <zone> · auto: default setting",
"sanctuary.realtime.timezone_invalid": "Unknown time zone: %s. Choose a suggested identifier, such as Europe/Paris, or auto.",
"sanctuary.notifications.objective_done": "Objective completed: %s",
"sanctuary.notifications.objective_progress": "Objective progress: %s",
"sanctuary.food_details.title": "Food information",
@@ -2380,6 +2391,9 @@
"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.pause_group_community": "Community",
"sanctuary.community.pause_group_adventure": "Adventure",
"sanctuary.community.pause_group_game": "Game",
"sanctuary.community.notice_tab": "Notice board",
"sanctuary.community.map_followed": "Followed request · click for discussion",
"sanctuary.community.map_more_materials": "+ %s more materials",
@@ -1,4 +1,13 @@
{
"sanctuary.story.title": "Story",
"sanctuary.story.search": "Rechercher une histoire…",
"sanctuary.story.filter": "Découverte",
"sanctuary.story.filter_all": "Toutes",
"sanctuary.story.filter_discovered": "Découvertes",
"sanctuary.story.filter_unknown": "Inconnues",
"sanctuary.story.unknown_entry": "Histoire %s — Inconnue",
"sanctuary.story.no_discoveries": "Aucune histoire découverte pour le moment.",
"sanctuary.story.no_results": "Aucune histoire ne correspond à la recherche.",
"biome.sanctuary.diagonal_lush227": "Grottes luxuriantes des mangroves",
"biome.sanctuary.diagonal_dripstone227": "Grottes à concrétions des mangroves",
"biome.sanctuary.diagonal_swamp226": "Marais frais",
@@ -436,7 +445,7 @@
"sanctuary.atlas.zoom_in": "Zoom avant",
"sanctuary.atlas.zoom_out": "Zoom arrière",
"sanctuary.mining.key": "Minage et construction groupés (maintenir)",
"sanctuary.mining.controls": "Maintiens %s pour voir la veine. Clic gauche maintenu : verrouiller et miner, regard libre. Relâche pour choisir un autre groupe. Molette avant le clic : taille.",
"sanctuary.mining.controls": "Maintiens %s pour voir la veine. Clic gauche maintenu : miner le groupe, puis celui que tu regardes. Molette avant le clic : taille. Relâche pour arrêter.",
"sanctuary.mining.no_target": "Aucune veine sélectionnée",
"sanctuary.mining.selection": "%s / %s blocs",
"sanctuary.mining.size": "Minage · rang %s · %s blocs maximum",
@@ -1226,6 +1235,8 @@
"sanctuary.realtime.invalid": "La configuration du temps réel ou le contrat du monde est invalide. Voir le journal serveur.",
"sanctuary.realtime.approximate": "Aucune ville de référence pour ce fuseau : soleil équatorial approximatif.",
"sanctuary.realtime.managed": "Le temps réel contrôle cette horloge. Utiliser /sanctuary time vanilla avant de la modifier.",
"sanctuary.realtime.timezone": "Fuseau : %s · /sanctuary time timezone <fuseau> · auto : réglage par défaut",
"sanctuary.realtime.timezone_invalid": "Fuseau inconnu : %s. Choisis un identifiant proposé, par exemple Europe/Paris, ou auto.",
"sanctuary.notifications.objective_done": "Objectif accompli : %s",
"sanctuary.notifications.objective_progress": "Objectif en progrès : %s",
"sanctuary.food_details.title": "Informations alimentaires",
@@ -2380,6 +2391,9 @@
"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.pause_group_community": "Communauté",
"sanctuary.community.pause_group_adventure": "Aventure",
"sanctuary.community.pause_group_game": "Partie",
"sanctuary.community.notice_tab": "Tableau",
"sanctuary.community.map_followed": "Demande suivie · cliquer pour la discussion",
"sanctuary.community.map_more_materials": "+ %s autres matériaux",
@@ -3,6 +3,7 @@
"package": "fr.koka.sanctuary.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"IslandChestLootMixin",
"BadlandsBands211Accessor",
"WestMonument217Accessor",
"WrenchTextureEditMixin",
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.231"
version = "beta.235"
pack-format = "packwiz:1.1.0"
[index]