Compare commits

..
Author SHA1 Message Date
ChrisM-PekandCursor 8de569bbf1 Add access-code gate to Hello World creation (beta.144).
Build Sanctuary / build (pull_request) Waiting to run
Build Sanctuary / build (push) Waiting to run
Require a site-issued SANC code when the server has app URL and API token configured, with replay-safe ledger binding and FR/EN UI including galactic code display.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 03:03:48 +02:00
37 changed files with 632 additions and 893 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## beta.144 — Code daccès à la création
- Hello World demande le code `SANC-XXXX-XXXX` quand le serveur a lURL et le jeton du site.
- Le client ne contacte pas le site. Le serveur vérifie, puis consomme le code au moment de créer lhabitant.
- Le lien Discord est enregistré à part. Une création interrompue après `redeemed` peut se terminer sans nouveau code si le compte est encore connu.
- [Contrat](docs/access-code-beta144.md).
## beta.110 — Atelier dargile inclus
- Une seule livraison réunit latelier dargile, les statuaires et limport GLB de beta.106 avec les œufs et la neige saisonnière de beta.107 à beta.109.
+2 -18
View File
@@ -20,20 +20,14 @@ La [vision complète](docs/vision.md) conserve les intentions ; le
L’économie, les claims et les dimensions décrits dans la vision restent
à implémenter.
La version **beta.144** ajoute une option d**eau naturelle pixélisée** :
couleur du biome, trois profils selon le fond, vert olive d’étang et visibilité
adaptée en immersion.
Les océans gardent leur couleur native et le profil clair.
[Contrat et vérifications](docs/natural-water-beta144.md).
La beta.144 est publiée et synchronisée dans **Sanctuary Beta** ;
les 923 fichiers personnels suivis sont conservés.
La version **beta.143** garde le sampler natif de précipitation pour la neige
saisonnière, mais ralentit son rythme : l'accumulation est espacée par un
facteur 12 et la fonte par le facteur temps réel 72. Les protections existantes
restent inchangées : neige construite, biomes froids, hiver, profils neigeux
et météo Vanilla. Le [ticket](docs/realtime-snow-melt-beta143.md) documente le
contrat et les vérifications.
La beta.143 est publiée et synchronisée dans linstance Sanctuary Beta ;
les 923 fichiers personnels suivis sont conservés.
La [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà
latelier, les œufs et la neige saisonnière dans linstance **Sanctuary Beta**.
Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées.
@@ -44,16 +38,6 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
l’étude de Xaero et des alternatives libres.
## beta.144 — Eau naturelle pixélisée
Eau claire, légèrement teintée ou vert olive d’étang selon le fond, avec
textures et animation natives. Océans clairs, visibilité sous-marine adaptée,
interrupteur et intensité dans les options Shaders.
[Contrat et mesures](docs/natural-water-beta144.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-Test-beta.144.mrpack) ·
[Tous les livrables](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.144).
## beta.143 — Neige saisonnière en temps réel
Accumulation ralentie par un facteur 12 et fonte saisonnière ralentie par le
+57
View File
@@ -0,0 +1,57 @@
# beta.144 — Code daccès à la création
Le site accepte la candidature, whitelist le pseudo et envoie un code `SANC-XXXX-XXXX`.
Hello World demande ce code avant de créer le personnage. Le client ne parle pas
au site. Seul le serveur appelle lAPI, avec le jeton `SANCTUARY_API_TOKEN`.
Sans ces réglages, Hello World reste celui de beta.143 : biographie, couleur et
familier, sans code. Dès que lURL et le jeton sont présents, un nouveau
personnage exige un code reconnu. Un habitant déjà enregistré entre sans écran.
## Réglage serveur
Variables denvironnement, ou fichier `config/sanctuary/access.json` si une
variable manque :
```json
{"appUrl":"https://exemple.sanctuary","token":"..."}
```
`appUrl` est lorigine du site, sans barre finale. Le serveur appelle
`POST {appUrl}/api/v1/access-codes/verify` puis `redeem`. Le jeton nest pas
écrit dans les logs, le client ou le pack. Une URL ou un jeton illisible ferme
la création : aucun personnage nest inventé hors ligne.
## Parcours
1. La whitelist laisse passer le pseudo. L’écran souvre tant que lUUID na pas
dhabitant.
2. Le joueur saisit le code. Laffichage du champ et de lexemple utilise
lalphabet galactique standard (`minecraft:alt`, la police de la table
denchantement). La valeur envoyée reste le texte tapé. Un format complet
déclenche `verify`, pas chaque frappe. « Code reconnu » n’écrit rien.
3. Confirmer envoie le code de la session, la biographie, la couleur et le
familier. Le pseudo envoyé au site est celui de la session.
4. `redeem` répond `redeemed` avec `discord_id` : le lien est enregistré, puis
lhabitant est créé. Les connexions suivantes sautent l’écran.
5. Erreur, site injoignable ou jeton refusé : le joueur reste sur Hello World.
Une déconnexion avant `redeemed` ne consomme pas le code. Si le site a répondu
`redeemed` et que l’écriture de lhabitant est interrompue, le lien Discord
reste dans `data/sanctuary-access.json` (`pending`, schéma 1, graine du monde).
La confirmation suivante termine le personnage sans rappeler `redeem`.
Si le code est déjà consommé et que le site ne renvoie plus `discord_id`, le
joueur reste bloqué avec un message de reprise. Le site doit alors renvoyer
`minecraft_username` et `discord_id` pour le même pseudo. Le mod accepte cette
réponse, que `valid` soit vrai ou faux, et finit la création.
Le registre des habitants ne change pas. Le Discord est un fichier à part.
Un fichier illisible est conservé et refuse laccueil.
## Limites
Le gel dans le monde nest pas ajouté : Hello World reste avant lentrée, comme
aujourdhui. Inventaire, commandes et dimensions ne sont pas accessibles tant
que lhabitant nexiste pas. La whitelist RCON reste celle du site. Un pseudo
ajouté à la main, sans code, voit l’écran sans pouvoir le valider.
-209
View File
@@ -1,209 +0,0 @@
# WATER-144 — Eau naturelle pixélisée
Branche `codex/water-beta144`, socle beta.143 publiée (`97ff2b5`) et suivi
`580b241`. Minecraft 26.3, Java 25, dépendances conservées.
## Contrat
**Options → Shaders → Eau naturelle** est activée par défaut avec Sanctuary.
Lintensité (0100 %, défaut 100 %) agit sur la surface et limmersion.
Linterrupteur ou 0 % retrouve le rendu précédent. Les préférences existantes
restent conservées ; un moteur externe suspend leffet comme les autres effets
natifs Sanctuary.
La couleur native du biome reste la base, y compris les variantes docéans.
Les océans (`minecraft:is_ocean`) imposent le profil clair.
Hors océan, le premier support situé au maximum huit blocs sous la cellule
observée choisit le profil :
| Profil | Fond | Mélange olive #788448 | Alpha natif | Distance de brume native |
| --- | --- | ---: | ---: | ---: |
| Claire | Pierre, matériaux construits, inconnus | 0 % | ×0,55 | ×1 |
| Légèrement teintée | Sable, sable rouge, gravier, argile | 10 % | ×0,80 | ×0,70 |
| Étang | Terre, terre stérile/enracinée, herbe, podzol, mycélium, boue, racines de palétuvier boueuses | 85 % | ×1 | ×0,40 |
Leau et les plantes aquatiques sans collision sont traversées. Un support
immergé reste un fond ; absence de support, air ou données manquantes donnent
une eau claire. La référence visuelle fournie pendant limplémentation demande un véritable
vert olive légèrement jaunâtre : le profil étang passe donc de 45 % à 85 %
de mélange, tout en laissant les eaux claires et océaniques natives.
Les surfaces profondes sans fond dans la limite restent donc
claires. Les profils se mélangent aux sommets sur un bloc, sans filtrer les
textures. Aux bords océaniques, les sommets partagés restent clairs et la
transition occupe le bloc voisin hors océan. Leau sous un toit suit son fond,
pas l’éclairage du bâtiment.
La ressource client `assets/sanctuary/water_materials.json` accepte deux listes
didentifiants de blocs, `light` et `pond`, remplaçables par un resource pack.
Les blocs absents des listes sont clairs. Une ressource invalide est signalée
et reprend les familles embarquées. Le template personnel inclut cette ressource.
## Rendu et coût
Modification des seules couleurs/alpha des sommets de `FluidRenderer`, sans
passe écran ni duplication de maillage. Textures, UV, animation, éclairage
et tri des transparences restent natifs. Les calculs de profils sont différés
jusqu’à l’émission dun sommet visible, puis mutualisés dans la région compilée.
Un test de palette élimine les sections sans eau avant toute copie de biomes.
Les états de blocs viennent des copies natives ; les appartenances océaniques
sont capturées avant lenvoi au worker. La présence du terrain est vérifiée dans le cache client avec chargement
désactivé, sans utiliser `ClientLevel.hasChunk` (toujours vrai). Aucun
chargement de chunk nest demandé par le classificateur.
Un changement de bloc invalide la zone dépendante, huit blocs au-dessus et
un bloc horizontalement. Les reconstructions natives recopient leurs données ;
leffet ne déclenche aucune reconstruction pour une lumière mobile.
Un changement doption reconstruit seulement les sections chargées contenant de
leau ; les caches des autres effets restent disponibles. Le cache temporaire
disparaît avec sa compilation, sans historique de monde.
Sous leau, le profil est échantillonné au maximum une fois par tick, avec
transition de 0,5 seconde. La couleur et les distances environnementales sont
adaptées dans le rendu natif du brouillard. La teinte finale est appliquée après
la normalisation de la vision aquatique, pour éviter de transformer lolive en
jaune fluorescent ; la luminosité issue des modificateurs natifs de vision
et le bonus du compagnon calmar sont conservés. Lave et brume atmosphérique ne changent
pas. Il ny a pas de simulation dabsorption volumétrique ni de reflet ajouté.
## Validation native
Les essais utilisent un monde plat jetable, graine 122, avec bassins sur quartz,
sable et boue, support du sable, plantes aquatiques, toit, frontière de sections
et changement de biome océan. Une copie personnelle de ressource remplace
réellement les familles de matériaux puis est désactivée.
Les suites graphiques finales OpenGL et Vulkan sont réussies le 18 septembre
2026 (`WATER144_PASS` et `WATER144_EXTRAS_PASS`). Elles couvrent surface,
immersion, fond modifié, priorité océan, toit, plantes, cascades, lave inchangée,
blocs gorgés deau, préférences FR/EN et rechargement des ressources/chunks.
Les sommets partagés aux limites océaniques et le repli clair lorsque le cache
client ne contient pas le terrain font lobjet dassertions supplémentaires.
Aucun autre client de test concurrent na été détecté pendant ces séries.
Deux passages ciblés supplémentaires (`WATER144_TEXTURE_PASS`), Vulkan en
1 min 18 s et OpenGL en 1 min 4 s, chargent de vraies textures personnelles
32×32 à deux images animées pour leau calme et courante. Grille, animation
visible et retour aux textures natives sont vérifiés après désactivation.
Les fixtures, leurs alpha et les captures restent dans les preuves locales.
### Protocole de performance
MacBookPro17,1, Apple M1, 8 Gio, macOS 26.5.1 (25F80), Java 25,
fenêtre logique 854×480, cible Retina **1708×960 pixels**, distance de rendu 6 chunks,
ombres 32 blocs / 16 pixels, PBR 50 %, autres effets activés.
VSync désactivée, limite 260 (illimitée), limitation AFK désactivée.
Le mod de test vérifie le limiteur effectif et journalise le mode de présentation.
Pendant les échantillons uniquement, il omet lattente artificielle de
`FramerateLimiter.limitDisplayFPS`, afin de mesurer sans temporisation
applicative. Cette sonde reste absente du JAR de production. Les temps couvrent
la frame native, présentation comprise, et ne sont pas des temps GPU isolés.
Pour chaque scène : OFF/ON/ON/OFF/ON/OFF/OFF/ON, dix secondes par passage,
avec stabilisation des sections puis quatre secondes de chauffe entre passages.
Le déplacement avance de 120 blocs et laisse les sections se compiler
naturellement, sans vidange artificielle des caches des autres effets.
La scène de déplacement prolonge le bassin hors de la vue initiale : son terrain
est préparé côté serveur de test, puis découvert normalement par le client.
Le vérificateur exige des compilations réelles dans chaque passage en mouvement.
Les moyennes des quatre médianes et P95 par état sont comparées avec les seuils
+5 % et +2 ms. Les temps de compilation de sections sont consignés également.
Les essais courts initiaux, les exécutions concurrentes et les vérifications
incomplètes du limiteur restent conservés comme preuves de développement ;
ils ne fondent pas ces résultats. Les séries finales ont été exécutées après
la fin du chantier SSR. Les variations négatives sont du bruit de mesure,
pas une promesse daccélération. Vulkan reste proche de 120 images/s dans
cette chaîne de présentation, malgré le mode `IMMEDIATE` et labsence de
limiteur applicatif : les conclusions sont limitées à la machine et aux
conditions ci-dessus.
| Moteur / scène | Médiane OFF → ON (ms) | Variation | P95 OFF → ON (ms) | Δ P95 (ms) |
| --- | --- | ---: | --- | ---: |
| vulkan / mixed | 8.069 → 8.092 | +0.29 % | 9.091 → 9.101 | +0.010 |
| vulkan / large | 8.078 → 8.062 | -0.20 % | 9.126 → 9.114 | -0.012 |
| vulkan / moving | 8.123 → 8.103 | -0.24 % | 9.103 → 9.080 | -0.023 |
| opengl / mixed | 4.545 → 4.407 | -3.02 % | 7.080 → 6.733 | -0.347 |
| opengl / large | 4.879 → 5.110 | +4.73 % | 7.519 → 7.767 | +0.248 |
| opengl / moving | 6.817 → 6.338 | -7.02 % | 10.618 → 9.759 | -0.859 |
Les six comparaisons respectent les seuils : médiane ≤ +5 %, P95 ≤ +2 ms.
Le cas limitant est la grande surface OpenGL (+4,73 %, +0,248 ms).
Les compilations de sections sont mesurées sur le parcours en mouvement :
| Moteur | Sections OFF / ON | Médiane OFF → ON (ms) | P95 OFF → ON (ms) |
| --- | ---: | --- | --- |
| vulkan | 222 / 220 | 1.889 → 2.186 | 4.307 → 4.898 |
| opengl | 222 / 220 | 1.528 → 1.540 | 5.492 → 5.227 |
Pour reproduire, avec Java 25 et un environnement graphique disponible :
```sh
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryPixelShadows122ClientTests=true -PsanctuaryWater144ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true \
-PsanctuaryClientGraphicsBackend=vulkan > build/water144-vulkan.log 2>&1
python3 scripts/check_water_performance.py build/water144-vulkan.log
```
Répéter avec `opengl`, sans autre client graphique simultané. Le vérificateur
refuse les séries incomplètes et les dépassements ; il écrit le reçu détaillé
près du journal. Le marqueur `WATER144_PASS` atteste les contrôles fonctionnels.
### Assemblage et publication
Le GameTest serveur dédié reste exclu conformément au refus antérieur de son
EULA. La commande de livraison est `./gradlew check build assemblePack
assembleTestPack -x :sanctuary:runGameTest` ; la suite complète non exclue
nest pas déclarée réussie. Les contrôles natifs de leau utilisent le serveur
intégré dun monde jetable. Aucun essai Windows nest revendiqué.
Lassemblage avec lexclusion ci-dessus a réussi en 2 min 33 s (126 tâches).
`python3 scripts/pack.py check` réussit. Les cinq archives passent le contrôle
ZIP ; les deux MRpack embarquent exactement le même JAR Sanctuary et déclarent
Fabric API 0.160.5+26.3 avec lempreinte du manifeste source. Lexport MRpack est
exécuté depuis chaque dossier de staging packwiz. Les dépendances imbriquées
Demeure et JEI sont vérifiées par lassemblage natif du dépôt.
[Release beta.144](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.144) publiée depuis
le commit source `83c377acc1b5a4d4137d7a93c397531f77915303` ; tag exact `beta.144`.
Canal packwiz stable : `c8e28d878a55703bdc28fc046242b42b2fcfa9f6`.
Deux synchronisations isolées puis deux synchronisations de linstance
**Sanctuary Beta** ont réussi, jeu et lanceur fermés. Les secondes passes sont
idempotentes. Un seul JAR Sanctuary est actif, et son SHA-256 correspond
exactement à celui de la release et du manifeste stable.
Les **923 fichiers personnels** suivis (sauvegardes, configurations,
packs, captures et mods non gérés) ont conservé leurs empreintes. Aucun monde
personnel ouvert. Sauvegarde des fichiers remplacés :
`sanctuary-backups/before-beta.144/`.
Les neuf liens publics (cinq livrables, trois captures, manifeste SHA-256)
ont été retéléchargés anonymement et leurs empreintes comparées. Les reçus de
publication, de téléchargement et des quatre synchronisations restent dans
`build/water144-publish.log`, `build/water144-public-downloads.json`,
`build/water144-isolated.json` et `build/water144-prism.json`.
Le checkout initial beta.122 et ses modifications restent préservés.
Les sources sont sur `codex/water-beta144`, dans le worktree isolé.
Les archives vérifiées sont aussi copiées dans `sanctuary-beta/build/`.
Les preuves détaillées restent dans les dossiers ignorés :
`build/water144-*.log`, `build/water144-*-performance.json`,
`build/water144-*-isolation.json` et `build/water144-evidence/`.
## Artefacts vérifiés
| Artefact | Octets | SHA-256 |
| --- | ---: | --- |
| [sanctuary-beta.144.jar](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/sanctuary-beta.144.jar) | 11343257 | `1ddbe941f3262da8a1497ea5df4a992e260b80ae95b341623dd68f8f9729ae88` |
| [Sanctuary-beta.144.mrpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144.mrpack) | 10483196 | `d5ef5a781f5ee796117210d1f6afc96172461315faee34097323beff541ce6f0` |
| [Sanctuary-Test-beta.144.mrpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-Test-beta.144.mrpack) | 10502119 | `e7cc32ac19ae98ebbe7a9aac3dc08decdf09396c8175b597f87051399ff3812e` |
| [Sanctuary-Template-beta.144.zip](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-Template-beta.144.zip) | 4354678 | `e510c0040dc9053492715ac91381b7dbc5a6f7c7defbf81e7502d8e18dbb0ee6` |
| [Sanctuary-Prism-auto-update.zip](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-Prism-auto-update.zip) | 92423 | `c0920823f40d709c14e074d8d949f2cc3681c24140bf86f21c3be345cfc3e1f6` |
## Captures comparatives
Pièces jointes de la release, issues du client Vulkan de validation :
[OFF](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144-water-off.png),
[ON](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144-water-on.png),
[profil étang en proximité](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144-water-pond.png).
Les captures sont exportées à 854×480 ; la cible de présentation mesurée est
1708×960. Le [manifeste SHA-256](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144-SHA256SUMS.txt) inclut les cinq
livrables et ces trois captures. Les journaux et échantillons détaillés restent
locaux, dans `build/water144-evidence/` et les reçus `build/water144-*.json`.
-14
View File
@@ -14,20 +14,6 @@ JAR Sanctuary sont des pièces jointes de releases Gitea. Aucun JAR de mod ou
dépendance téléchargée n'entre dans l'historique Git. Le canal Beta est distinct
de l'ancien pack 26.2.
## beta.144 — Eau naturelle pixélisée
Trois profils selon le fond, teinte olive d’étang, océans natifs clairs et
brume aquatique adaptée. [Contrat, captures et mesures](natural-water-beta144.md).
[Release beta.144](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.144)
publiée depuis `83c377acc1b5a4d4137d7a93c397531f77915303` ; canal
`c8e28d878a55703bdc28fc046242b42b2fcfa9f6`. Deux synchronisations isolées puis deux
dans linstance Sanctuary Beta réussies et idempotentes. Un seul JAR Sanctuary
actif, identique à lartefact public. Les 923 fichiers personnels suivis gardent
leurs empreintes ; aucun monde personnel ouvert. Sauvegarde préalable :
`sanctuary-backups/before-beta.144/`. Les cinq livrables et leurs SHA-256 sont
vérifiés dans `sanctuary-beta/build/`. Textures beta.121, Minecraft 26.3 et
dépendances exactes conservés.
## beta.143 — Neige saisonnière en temps réel
Accumulation espacée par un facteur 12 et fonte par le facteur temps réel 72,
-6
View File
@@ -107,9 +107,3 @@ Les contrôles ne revendiquent pas un essai avec Iris ou un autre moteur externe
Les ombres portées pixélisées sont livrées dans [beta.127](shader-beta127.md),
intégrées sur la beta.126. Leur préparation locale portait un numéro beta.122
non publié ; la release beta.122 existante n'est pas remplacée.
## beta.144 — Eau naturelle
Le [ticket WATER-144](natural-water-beta144.md) ajoute les profils clair,
légèrement teinté et étang au rendu natif des fluides et à leur brouillard.
La couleur du biome et les textures pixélisées restent la base.
+8 -3
View File
@@ -34,9 +34,6 @@ if (clientRenderTests) {
if (improvedTransparency.isPresent()) {
systemProperty('sanctuary.test.improvedTransparency', improvedTransparency.get())
}
systemProperty('sanctuary.test.water144', providers.gradleProperty('sanctuaryWater144ClientTests').getOrElse('false'))
systemProperty('sanctuary.test.water144ExtrasOnly', providers.gradleProperty('sanctuaryWater144ExtrasOnly').getOrElse('false'))
systemProperty('sanctuary.test.water144PerfOnly', providers.gradleProperty('sanctuaryWater144PerfOnly').getOrElse('false'))
systemProperty('sanctuary.test.pbr142', providers.gradleProperty('sanctuaryPbr142ClientTests').getOrElse('false'))
systemProperty('sanctuary.test.directional141Only', providers.gradleProperty('sanctuaryDirectional141Only').getOrElse('false'))
systemProperty('sanctuary.test.directional141', providers.gradleProperty('sanctuaryDirectional141ClientTests').getOrElse('false'))
@@ -1140,6 +1137,14 @@ tasks.register('intro031Smoke', JavaExec) {
}
tasks.named('check') { dependsOn('intro031Smoke') }
tasks.register('access144Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.progression.AccessCodeSmoke'
}
tasks.named('check') { dependsOn('access144Smoke') }
if (providers.gradleProperty('sanctuaryQuickTests').map { it.toBoolean() }.getOrElse(false)) {
dependencies { gametestRuntimeOnly project(':sanctuary-test') }
}
@@ -151,7 +151,6 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
c.runOnClient(m -> check(VanillaLight.active(), "Native Sanctuary shader is active without an external engine"));
float morning = c.computeOnClient(m -> m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);
if(Boolean.getBoolean("sanctuary.test.water144")){Water144ClientChecks.run(c,server);return;}
if(coloredOnly){if(Boolean.getBoolean("sanctuary.test.directional141Only")){DirectionalLights141ClientChecks.run(c,server);return;}ColoredLights139ClientChecks.run(c,server);if(Boolean.getBoolean("sanctuary.test.colored140"))ColoredLights140ClientChecks.run(c,server);if(Boolean.getBoolean("sanctuary.test.directional141"))DirectionalLights141ClientChecks.run(c,server);return;}
if(sunshaftOnly){Sunshafts137ClientChecks.run(c,server);return;}
if(closeupOnly){SsgiCloseup135ClientChecks.run(c,server);Pbr134ClientChecks.run(c,server);return;}
@@ -569,7 +568,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
}
static Frame capture(ClientGameTestContext c, String name) {
if (name != null) c.takeScreenshot((Boolean.getBoolean("sanctuary.test.water144")?"sanctuary-beta144-water-":"sanctuary-beta142-shader-") + name);
if (name != null) c.takeScreenshot("sanctuary-beta142-shader-" + name);
var result = new CompletableFuture<Frame>();
c.runOnClient(m -> Screenshot.takeScreenshot(m.gameRenderer.mainRenderTarget(), image -> {
try (image) {
@@ -1,215 +0,0 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import fr.koka.sanctuary.gametest.Water144Metrics;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.world.level.block.Blocks;
import java.nio.file.*;
import java.util.*;
final class Water144ClientChecks {
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{
VanillaLight.setEnabled(true);VanillaLight.setNaturalWater(true);VanillaLight.setWaterIntensity(100);
VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);
VanillaLight.setPixelShadows(false);VanillaLight.setColoredLights(false);VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);VanillaLight.setEdgeHighlights(false);
});
server.runOnServer(s->{
command(s,"fill 47 94 -1 96 95 33 quartz_block");
command(s,"fill 47 96 -1 47 100 33 glass");command(s,"fill 96 96 -1 96 100 33 glass");
command(s,"fill 47 96 -1 96 100 -1 glass");command(s,"fill 47 96 33 96 100 33 glass");
command(s,"fill 64 95 0 79 95 32 sand");command(s,"fill 80 95 0 95 95 32 mud");
command(s,"fill 48 96 0 95 99 32 water");
command(s,"setblock 84 96 8 seagrass");
for(int x=50;x<95;x+=4)command(s,"setblock "+x+" 96 20 gold_block");
});
camera(c,server,72,125,16,70);c.waitTicks(50);settle(c);
if(Boolean.getBoolean("sanctuary.test.water144ExtrasOnly")){
resourceOverride(c);
camera(c,server,88.5,96.2,8.5,70);look(c,server,0,0);c.waitTicks(20);capture(c,"pond-final-fog");
var green=c.computeOnClient(m->new org.joml.Vector4f(m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.color));
enable(c,false);var blue=c.computeOnClient(m->new org.joml.Vector4f(m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.color));capture(c,"native-final-fog");
check(green.y>green.z&&green.y<Math.max(blue.x,Math.max(blue.y,blue.z))*.8,"Olive fog stays muted after native vision adaptation");
enable(c,true);extras(c,server);return;
}
if(!Boolean.getBoolean("sanctuary.test.water144PerfOnly")){
c.runOnClient(m->{
check(NaturalWater.sample(m.level,56.5,99.1,8.5).equals(NaturalWater.CLEAR),"Constructed bed clear");
check(NaturalWater.sample(m.level,72.5,99.1,8.5).equals(NaturalWater.LIGHT),"Sand lightly tinted");
check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Mud pond");
check(NaturalWater.sample(m.level,84.5,99.1,8.5).equals(NaturalWater.POND),"Seagrass is not a floor");
check(NaturalWater.sample(m.level,88.5,110.1,8.5).equals(NaturalWater.CLEAR),"No bed in bounded search uses clear");
var unloaded=new BlockPos(1_000_016,96,1_000_016);
check(!NaturalWater.loaded(m.level,unloaded.getX()>>4,unloaded.getZ()>>4),"Fixture region is unloaded");
var missing=new NaturalWater.Region(m.level,62500,5,62500);
check(missing.corner(null,unloaded,0,0).equals(NaturalWater.CLEAR),"Unavailable biome snapshot forces clear without reading missing terrain");
check(!NaturalWater.loaded(m.level,unloaded.getX()>>4,unloaded.getZ()>>4),"Classification does not load chunks");
var boundary=NaturalWater.sample(m.level,80,99.1,8.5);check(boundary.tint()>.1&&boundary.tint()<.85,"Section boundary mixes profiles");
});
enable(c,false);var off=capture(c,"surface-off");enable(c,true);var on=capture(c,"surface-on");
check(difference(off,on)>100000,"Visible surface colour and opacity change");
c.runOnClient(m->VanillaLight.setWaterIntensity(0));settle(c);var zero=capture(c,"surface-zero");
check(difference(off,zero)<difference(off,on)/4,"Zero restores native water (animated texture tolerance)");
c.runOnClient(m->VanillaLight.setWaterIntensity(100));settle(c);
camera(c,server,88.5,103,4.5,70);look(c,server,0,28);capture(c,"pond-closeup");
camera(c,server,72,125,16,70);
server.runOnServer(s->command(s,"fill 80 95 0 95 95 32 quartz_block"));c.waitTicks(30);settle(c);var changed=capture(c,"bed-changed");
check(difference(changed,on)>100000,"Changing bed four blocks below surface refreshes water");
server.runOnServer(s->command(s,"fill 80 95 0 95 95 32 mud"));c.waitTicks(25);settle(c);
server.runOnServer(s->command(s,"fill 80 103 0 95 103 12 stone"));c.waitTicks(25);settle(c);
c.runOnClient(m->check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Roof does not replace floor rule"));capture(c,"covered-pond");
server.runOnServer(s->command(s,"fill 80 103 0 95 103 12 air"));c.waitTicks(20);
for(int x:new int[]{56,72,88}){
camera(c,server,x+.5,96.2,8.5,70);look(c,server,0,0);c.waitTicks(20);capture(c,"immersed-"+x);
c.runOnClient(m->{check(m.gameRenderer.mainCamera().getFluidInCamera()==net.minecraft.world.level.material.FogType.WATER,"Camera immersed");
float expected=x==56?1:x==72?.7f:.4f;check(Math.abs(NaturalWater.fogProfile().fog()-expected)<.001,"Immersion uses corresponding profile");});
}
var pondColor=c.computeOnClient(m->new org.joml.Vector4f(m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.color));
check(pondColor.y>pondColor.z&&pondColor.y<.8f,"Olive fog avoids native neon normalization");
float pondFog=c.computeOnClient(m->m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.environmentalEnd);
enable(c,false);float nativeFog=c.computeOnClient(m->m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.environmentalEnd);
check(Math.abs(pondFog/nativeFog-.4)<.01,"Rendered underwater fog distance matches pond profile");capture(c,"immersed-native");enable(c,true);
camera(c,server,72,125,16,70);
server.runOnServer(s->command(s,"fillbiome 80 88 0 95 104 32 minecraft:ocean"));c.waitTicks(35);settle(c);
c.runOnClient(m->{
check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.CLEAR),"Ocean overrides muddy bed");
boolean found=false;
for(int x=72;x<89;x++){
var p=new BlockPos(x,99,8);
if(!NaturalWater.ocean(m.level,p)&&NaturalWater.ocean(m.level,p.east())){
var left=new NaturalWater.Region(m.level,(x>>4)-1,5,-1);
var right=new NaturalWater.Region(m.level,((x+1)>>4)-1,5,-1);
check(left.corner(null,p,1,0).equals(right.corner(null,p.east(),0,0)),"Ocean edge emits identical shared vertices");
found=true;break;
}
}
check(found,"Native ocean boundary exercised");
});capture(c,"ocean-over-mud");
server.runOnServer(s->command(s,"fillbiome 80 88 0 95 104 32 minecraft:plains"));c.waitTicks(30);
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();settle(c);capture(c,"resources-reloaded");
resourceOverride(c);
camera(c,server,400,120,16,70);c.waitTicks(30);camera(c,server,72,125,16,70);settle(c);
c.runOnClient(m->{check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Reloaded chunks keep correct profile");
VanillaLight.setNaturalWater(false);VanillaLight.setWaterIntensity(37);forgetPreferences();check(!VanillaLight.naturalWater()&&VanillaLight.waterIntensity()==37,"Water preferences persist");
VanillaLight.setNaturalWater(true);VanillaLight.setWaterIntensity(100);
for(String locale:new String[]{"fr_fr","en_us"}){m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
for(String key:new String[]{"natural_water","natural_water_help","water_intensity","water_intensity_help","water_intensity_value"}){String full="sanctuary.shaders."+key;check(!Component.translatable(full,100).getString().equals(full),"FR/EN "+full);}}
});settle(c);
}
// All effects enabled; fixed inherited test quality (shadows 32 blocks, PBR 50%) for A/B comparison.
c.runOnClient(m->{VanillaLight.setPbr(true);VanillaLight.setSsgi(true);VanillaLight.setBloom(true);VanillaLight.setOreEmission(true);
VanillaLight.setPixelShadows(true);VanillaLight.setColoredLights(true);VanillaLight.setSunshafts(true);VanillaLight.setLensFlare(true);VanillaLight.setEdgeHighlights(true);});
c.waitTicks(80);capture(c,"combined");
benchmark(c,server);
extras(c,server);
System.out.println("WATER144_PASS surface, immersion, beds, ocean, section seam, roof, plants, invalidation, reload, preferences, FR/EN and combined rendering");
}
private static void enable(ClientGameTestContext c,boolean value){c.runOnClient(m->VanillaLight.setNaturalWater(value));settle(c);}
private static void settle(ClientGameTestContext c){c.waitTicks(8);c.waitFor(m->m.levelRenderer.hasRenderedAllSections(),1200);c.waitTicks(20);}
private static long difference(Frame a,Frame b){long value=0;for(int i=0;i<a.pixels().length;i++)value+=channelDifference(a.pixels()[i],b.pixels()[i]);return value;}
private static void extras(ClientGameTestContext c,TestServerContext server){
server.runOnServer(s->{
for(int x:new int[]{100,108}){
command(s,"fill "+x+" 95 0 "+(x+4)+" 104 16 glass");
command(s,"fill "+(x+1)+" 96 1 "+(x+3)+" 103 15 air");
command(s,"fill "+(x+1)+" 95 1 "+(x+3)+" 95 15 mud");
}
command(s,"setblock 102 102 2 water");command(s,"setblock 110 102 2 lava");
command(s,"setblock 56 99 8 oak_slab[waterlogged=true]");
});
c.waitTicks(80);camera(c,server,106,103,25,70);look(c,server,179,20);settle(c);
c.runOnClient(m->{
var flow=m.level.getFluidState(new BlockPos(102,101,2));check(flow.is(net.minecraft.tags.FluidTags.WATER)&&!flow.isSource(),"Real falling water is rendered");
check(m.level.getFluidState(new BlockPos(110,101,2)).is(net.minecraft.tags.FluidTags.LAVA),"Lava cascade remains lava");
check(m.level.getBlockState(new BlockPos(56,99,8)).getValue(net.minecraft.world.level.block.state.properties.BlockStateProperties.WATERLOGGED),"Waterlogged slab preserved");
});
enable(c,false);var off=capture(c,"cascades-off");enable(c,true);var on=capture(c,"cascades-on");
check(difference(off,on)>1000,"Waterfall responds to water option");
int lava=0,stable=0;for(int i=0;i<off.pixels().length;i++){int a=off.pixels()[i];int r=a>>16&255,g=a>>8&255,b=a&255;
if(r>160&&r>g*1.3&&b<90){lava++;if(channelDifference(a,on.pixels()[i])<70)stable++;}}
check(lava>100&&stable>lava*.8,"Lava colours remain native (animation tolerance)");
camera(c,server,56.5,102,8.5,70);capture(c,"waterlogged-slab");
System.out.println("WATER144_EXTRAS_PASS falling water, unchanged lava and waterlogged slab");
}
private static net.minecraft.client.renderer.texture.SpriteContents waterSprite(net.minecraft.client.Minecraft m,String name){
return m.getAtlasManager().getAtlasOrThrow(net.minecraft.data.AtlasIds.BLOCKS)
.getSprite(net.minecraft.resources.Identifier.withDefaultNamespace("block/"+name)).contents();
}
private static void resourceOverride(ClientGameTestContext c){
int originalWidth=c.computeOnClient(m->waterSprite(m,"water_still").width());
int originalFrames=c.computeOnClient(m->waterSprite(m,"water_still").getUniqueFrames().size());
var directory=c.computeOnClient(m->m.getResourcePackDirectory().resolve("water144-fixture"));
try{
Files.createDirectories(directory.resolve("assets/sanctuary"));
var mod=net.fabricmc.loader.api.FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow();
Files.copy(mod.findPath("resourcepacks/template/pack.mcmeta").orElseThrow(),directory.resolve("pack.mcmeta"),StandardCopyOption.REPLACE_EXISTING);
var textures=directory.resolve("assets/minecraft/textures/block");Files.createDirectories(textures);
try(var pixels=new com.mojang.blaze3d.platform.NativeImage(32,64,false)){
for(int y=0;y<64;y++)for(int x=0;x<32;x++)pixels.setPixel(x,y,((x/4+y/4+y/32)&1)==0?0xd0ffffff:0x80404040);
for(String name:new String[]{"water_still","water_flow"}){
pixels.writeToFile(textures.resolve(name+".png"));
Files.writeString(textures.resolve(name+".png.mcmeta"),"{\"animation\":{\"frametime\":4,\"frames\":[0,1]}}");
}
}
Files.writeString(directory.resolve("assets/sanctuary/water_materials.json"),"{\"light\":[],\"pond\":[\"minecraft:quartz_block\"]}");
}catch(Exception e){throw new AssertionError(e);}
c.runOnClient(m->{var repo=m.getResourcePackRepository();repo.reload();check(repo.addPack("file/water144-fixture"),"Custom materials pack discovered");});
var future=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->future.isDone()&&m.gui.overlay()==null,1200);future.join();settle(c);
c.runOnClient(m->{check(NaturalWater.sample(m.level,56.5,99.1,8.5).equals(NaturalWater.POND),"Resource pack replaces material family");
check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.CLEAR),"Replacement removes bundled assignment");});
c.runOnClient(m->{for(String name:new String[]{"water_still","water_flow"}){
var sprite=waterSprite(m,name);check(sprite.width()==32&&sprite.height()==32&&sprite.getUniqueFrames().size()==2,"Custom pixel grid and animation loaded: "+name);
}});
var first=capture(c,"custom-materials");long animated=0;
for(int i=0;i<8;i++){c.waitTicks(1);animated=Math.max(animated,difference(first,capture(c,"custom-animation-"+i)));}
check(animated>100000,"Custom water animation remains visible");
c.runOnClient(m->m.getResourcePackRepository().removePack("file/water144-fixture"));
var restore=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->restore.isDone()&&m.gui.overlay()==null,1200);restore.join();settle(c);
c.runOnClient(m->{
check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Disabling custom pack restores defaults");
check(waterSprite(m,"water_still").width()==originalWidth&&waterSprite(m,"water_still").getUniqueFrames().size()==originalFrames,"Native water texture restored");
});
System.out.println("WATER144_TEXTURE_PASS custom 32px still/flow textures, alpha, two animation frames and restoration");
}
private static void benchmark(ClientGameTestContext c,TestServerContext s){
int oldLimit=c.computeOnClient(m->m.options.framerateLimit().get());
var oldInactivity=c.computeOnClient(m->m.options.inactivityFpsLimit().get());
c.runOnClient(m->{m.options.framerateLimit().set(net.minecraft.client.Options.UNLIMITED_FRAMERATE_CUTOFF);m.options.inactivityFpsLimit().set(net.minecraft.client.InactivityFpsLimit.MINIMIZED);m.getFramerateLimitTracker().setFramerateLimit(net.minecraft.client.Options.UNLIMITED_FRAMERATE_CUTOFF);});
var results=new LinkedHashMap<String,Object>();
try {
for(String scene:new String[]{"mixed","large","moving"}){
if(scene.equals("large")){s.runOnServer(server->{command(server,"fill 31 94 -17 112 100 -17 glass");command(server,"fill 31 94 48 112 100 48 glass");
command(server,"fill 31 94 -17 31 100 48 glass");command(server,"fill 112 94 -17 112 100 48 glass");
command(server,"fill 32 93 -16 111 93 47 quartz_block");command(server,"fill 32 94 -16 111 94 47 sand");command(server,"fill 32 95 -16 111 99 47 water");});c.waitTicks(50);}
if(scene.equals("moving")){
// Test fixture only: prepare water beyond the initial client view so movement really compiles terrain.
s.runOnServer(server->{
command(server,"forceload add 112 -17 288 48");
for(int x=7;x<=18;x++)for(int z=-2;z<=3;z++)server.overworld().getChunk(x,z);
command(server,"fill 112 94 -17 288 100 -17 glass");command(server,"fill 112 94 48 288 100 48 glass");
command(server,"fill 288 94 -17 288 100 48 glass");
for(int x=112;x<288;x+=16){
command(server,"fill "+x+" 93 -16 "+(x+15)+" 93 47 quartz_block");
command(server,"fill "+x+" 94 -16 "+(x+15)+" 94 47 sand");
command(server,"fill "+x+" 95 -16 "+(x+15)+" 99 47 water");
}
});c.waitTicks(80);
}
camera(c,s,72,125,16,70);settle(c);
for(boolean enabled:new boolean[]{false,true,true,false,true,false,false,true}){
camera(c,s,72,125,16,70);enable(c,enabled);c.waitTicks(80);c.runOnClient(m->{
check(m.getFramerateLimitTracker().getFramerateLimit()==260&&!m.options.enableVsync().get(),"Uncapped native limiter and VSync disabled");
System.out.println("WATER144_CONDITIONS limit="+m.getFramerateLimitTracker().getFramerateLimit()+" throttle="+m.getFramerateLimitTracker().getThrottleReason()+" renderLimit="+m.gameRenderer.gameRenderState().framerateLimit+" surface="+m.windowSurface().currentConfiguration()+" limiterBypass=true");
Water144Metrics.start();});
if(scene.equals("moving")){
for(int i=0;i<200;i++){final int step=i;c.runOnClient(m->{m.player.setPos(48+step*.6,125,16);m.player.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO);});c.waitTicks(1);}
}else c.waitTicks(200);
var values=c.computeOnClient(m->Water144Metrics.stop());String key=scene+"-"+enabled+"-"+results.size();results.put(key,values);
System.out.println("WATER144_PERF "+key+" "+Arrays.toString(values));
}
}
try{Files.writeString(Path.of("water144-performance.json"),new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(results));}catch(Exception e){throw new AssertionError(e);}
}finally{c.runOnClient(m->{m.options.framerateLimit().set(oldLimit);m.getFramerateLimitTracker().setFramerateLimit(oldLimit);m.options.inactivityFpsLimit().set(oldInactivity);});}
}
}
@@ -1,11 +0,0 @@
package fr.koka.sanctuary.gametest;
import java.util.*;
public final class Water144Metrics {
public static volatile boolean recording;
private static final List<Long> frames=new ArrayList<>(),sections=new ArrayList<>();
public static synchronized void frame(long ns){if(recording)frames.add(ns);}
public static synchronized void section(long ns){if(recording)sections.add(ns);}
public static synchronized void start(){frames.clear();sections.clear();recording=true;}
public static synchronized double[] stop(){recording=false;return new double[]{frames.size(),quantile(frames,.5),quantile(frames,.95),sections.size(),quantile(sections,.5),quantile(sections,.95)};}
private static double quantile(List<Long> values,double q){if(values.isEmpty())return 0;var sorted=values.stream().mapToLong(Long::longValue).sorted().toArray();return sorted[Math.min(sorted.length-1,(int)(sorted.length*q))]/1e6;}
}
@@ -1,17 +0,0 @@
package fr.koka.sanctuary.gametest.mixin;
import fr.koka.sanctuary.gametest.Water144Metrics;
import com.llamalad7.mixinextras.injector.wrapoperation.*;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(Minecraft.class)
abstract class Water144FrameProbeMixin {
@Unique private long sanctuary$start;
// Benchmark only: the extracted frame state can retain a 120 FPS limit despite the option/tracker.
@WrapOperation(method="renderFrame",at=@At(value="INVOKE",target="Lnet/minecraft/client/FramerateLimiter;limitDisplayFPS(I)V"))
private void sanctuary$uncapped(int fps,Operation<Void> original){if(!Water144Metrics.recording)original.call(fps);}
@Inject(method="renderFrame",at=@At("HEAD")) private void start(boolean tick,CallbackInfo ci){if(Water144Metrics.recording)sanctuary$start=System.nanoTime();}
@Inject(method="renderFrame",at=@At("RETURN")) private void end(boolean tick,CallbackInfo ci){if(Water144Metrics.recording)Water144Metrics.frame(System.nanoTime()-sanctuary$start);}
}
@@ -1,17 +0,0 @@
package fr.koka.sanctuary.gametest.mixin;
import fr.koka.sanctuary.gametest.Water144Metrics;
import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import net.minecraft.client.renderer.chunk.*;
import net.minecraft.client.renderer.SectionBufferBuilderPack;
import net.minecraft.core.SectionPos;
import com.mojang.blaze3d.vertex.VertexSorting;
import org.spongepowered.asm.mixin.Mixin;
@Mixin(SectionCompiler.class)
abstract class Water144SectionProbeMixin {
@WrapMethod(method="compile")
private SectionCompiler.Results compile(SectionPos pos,RenderSectionRegion region,VertexSorting sorting,SectionBufferBuilderPack buffers,Operation<SectionCompiler.Results> original){
boolean record=Water144Metrics.recording;long start=record?System.nanoTime():0;
try{return original.call(pos,region,sorting,buffers);}finally{if(record)Water144Metrics.section(System.nanoTime()-start);}
}
}
@@ -19,8 +19,6 @@
"Atlas026TextureProbeMixin",
"Inventory013MerchantAccess",
"Dynamic030TerrainProbeMixin",
"Star040RenderProbeMixin",
"Water144FrameProbeMixin",
"Water144SectionProbeMixin"
"Star040RenderProbeMixin"
]
}
@@ -12,13 +12,20 @@ import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.resources.Identifier;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.FontDescription;
import net.minecraft.network.chat.Style;
import net.minecraft.util.FormattedCharSequence;
import net.minecraft.ChatFormatting;
/** Configuration-stage identity and starter slots. Closing cancels connection, never the saved offer. */
public final class HelloWorldScreen extends Screen {
private static final FontDescription GALACTIC = new FontDescription.Resource(Identifier.withDefaultNamespace("alt"));
private static final Style GALACTIC_STYLE = Style.EMPTY.withFont(GALACTIC);
private ProgressionPayloads.Hello offer;private final Runnable disconnect;
private EditBox bio,familiarName;private int color=-1;private Button submit;private boolean waiting,identityLocked;
private String draft="",starter="",nameDraft="";private List<StarterCompanions.Choice> starterChoices=List.of();
private String draft="",starter="",nameDraft="",codeDraft="",codeStatus="",lastChecked="";
private boolean accessRequired,accessPending,admitting;private EditBox codeBox;
private List<StarterCompanions.Choice> starterChoices=List.of();
private final List<Button> colors=new ArrayList<>(),starters=new ArrayList<>();
private int panelX,panelY,panelWidth,bioLabelY,colorLabelY,starterLabelY,nameLabelY;private boolean compact;
public HelloWorldScreen(ProgressionPayloads.Hello offer,Runnable disconnect){super(ProgressionService.label("hello_title"));this.offer=offer;this.disconnect=disconnect;}
@@ -29,18 +36,29 @@ public final class HelloWorldScreen extends Screen {
if(identityLocked){draft=welcome.bio();color=offer.colors().getFirst();}
if(starterChoices.stream().noneMatch(c->c.mob().equals(starter)))starter="";
}
public void accept(StarterPayloads.Welcome welcome){if(bio!=null)draft=bio.getValue();load(welcome);waiting=false;if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
public void accept(ProgressionPayloads.Hello offer){this.offer=offer;waiting=false;if(bio!=null)draft=bio.getValue();if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
public void prepareAccess(boolean pending){accessRequired=true;accessPending=pending;if(pending)codeStatus="code_recognized";}
public void requireAccess(boolean pending){boolean changed=!accessRequired||accessPending!=pending;prepareAccess(pending);if(changed&&minecraft!=null&&minecraft.gui.screen()==this){keepDrafts();rebuildWidgets();}}
public void acceptCode(String code,String reason){if(admitting)return;if(!AccessCodes.normalize(code).equals(AccessCodes.normalize(codeDraft))){if(AccessCodes.validFormat(AccessCodes.normalize(codeDraft)))sendCheck(AccessCodes.normalize(codeDraft));return;}codeStatus=reason;waiting=false;updateSelection();}
public void accept(StarterPayloads.Welcome welcome){keepDrafts();load(welcome);waiting=false;admitting=false;noteCodeError();if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
public void accept(ProgressionPayloads.Hello offer){keepDrafts();this.offer=offer;waiting=false;admitting=false;noteCodeError();if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
private void keepDrafts(){if(bio!=null)draft=bio.getValue();if(codeBox!=null)codeDraft=codeBox.getValue();if(familiarName!=null)nameDraft=familiarName.getValue();}
private void noteCodeError(){if(offer.error().startsWith("code_"))codeStatus=offer.error();}
@Override protected void init(){
boolean naming=!starterChoices.isEmpty();compact=naming&&height<278;
panelWidth=Math.min(520,width-20);panelX=(width-panelWidth)/2;panelY=Math.max(0,(height-(naming&&!compact?270:238))/2);colors.clear();starters.clear();familiarName=null;
bioLabelY=panelY+(compact?37:44);colorLabelY=panelY+(compact?71:81);starterLabelY=panelY+(compact?145:177);nameLabelY=starterLabelY+36;
boolean naming=!starterChoices.isEmpty();int shift=accessRequired?36:0;compact=naming&&height<278+shift;
panelWidth=Math.min(520,width-20);panelX=(width-panelWidth)/2;panelY=Math.max(0,(height-((naming&&!compact?270:238)+shift))/2);colors.clear();starters.clear();familiarName=null;codeBox=null;
bioLabelY=panelY+(compact?37:44)+shift;colorLabelY=panelY+(compact?71:81)+shift;starterLabelY=panelY+(compact?145:177)+shift;nameLabelY=starterLabelY+36;
var skin=addRenderableWidget(new PlayerSkinWidget(64,145,minecraft.getEntityModels(),minecraft.getSkinManager().createLookup(minecraft.getGameProfile(),false)));skin.setPosition(panelX,panelY+35);
int right=panelX+72,w=panelWidth-72;
bio=addRenderableWidget(new EditBox(font,right,panelY+(compact?48:57),w,18,ProgressionService.label("bio")));bio.setMaxLength(256);bio.setHint(ProgressionService.label("bio_hint"));bio.setValue(draft);bio.setResponder(value->draft=value);
if(accessRequired){
codeBox=addRenderableWidget(new EditBox(font,right,panelY+(compact?48:57),w,18,ProgressionService.label("code")));
codeBox.setMaxLength(32);codeBox.setHint(Component.translatable("sanctuary.progression.code_hint").withStyle(style -> style.withFont(GALACTIC)));codeBox.setValue(codeDraft);
codeBox.addFormatter((text,offset) -> FormattedCharSequence.forward(text, GALACTIC_STYLE));
codeBox.setResponder(value->{codeDraft=value;if(accessPending){updateSelection();return;}String normalized=AccessCodes.normalize(value);if(!AccessCodes.validFormat(normalized)){codeStatus=normalized.isEmpty()?"":"code_malformed";lastChecked="";updateSelection();return;}if(!normalized.equals(lastChecked))sendCheck(normalized);else updateSelection();});
}
bio=addRenderableWidget(new EditBox(font,right,panelY+(compact?48:57)+shift,w,18,ProgressionService.label("bio")));bio.setMaxLength(256);bio.setHint(ProgressionService.label("bio_hint"));bio.setValue(draft);bio.setResponder(value->draft=value);
int cell=w/4;
for(int i=0;i<offer.colors().size();i++){
final int rgb=offer.colors().get(i);int rowHeight=compact?12:16;Button button=addRenderableWidget(new ColorButton(right+(i%4)*cell,panelY+(compact?82:94)+(i/4)*rowHeight,cell-2,rowHeight,rgb,i+1,b->{color=rgb;updateSelection();}));
final int rgb=offer.colors().get(i);int rowHeight=compact?12:16;Button button=addRenderableWidget(new ColorButton(right+(i%4)*cell,panelY+(compact?82:94)+shift+(i/4)*rowHeight,cell-2,rowHeight,rgb,i+1,b->{color=rgb;updateSelection();}));
button.setTooltip(Tooltip.create(InhabitantColorNames.label(rgb)));colors.add(button);
}
for(int i=0;i<starterChoices.size();i++)starters.add(addRenderableWidget(new StarterSlot(right+i*28,starterLabelY+11,starterChoices.get(i))));
@@ -49,10 +67,16 @@ public final class HelloWorldScreen extends Screen {
familiarName.setMaxLength(StarterCompanions.NAME_LIMIT);familiarName.setHint(Component.translatable("sanctuary.starter.name_hint"));familiarName.setValue(nameDraft);
familiarName.setResponder(value->{nameDraft=value;updateSelection();});
}
int actionsY=panelY+(naming&&!compact?248:216);
int actionsY=panelY+(naming&&!compact?248:216)+shift;
addRenderableWidget(Button.builder(ProgressionService.label("cancel"),b->onClose()).bounds(panelX,actionsY,90,20).build());
submit=addRenderableWidget(Button.builder(ProgressionService.label("enter"),b->{
if(waiting||color<0||(!starterChoices.isEmpty()&&(starter.isEmpty()||!StarterCompanions.validName(nameDraft))))return;
if(accessRequired){
if(!(accessPending||"code_recognized".equals(codeStatus)))return;
admitting=true;waiting=true;updateSelection();
ClientConfigurationNetworking.send(new AccessPayloads.Admit(bio.getValue(),color,starterChoices.isEmpty()?"":starter,nameDraft,accessPending||codeBox==null?"":codeBox.getValue()));
return;
}
if(!starterChoices.isEmpty()&&!nameDraft.isBlank()&&!ClientConfigurationNetworking.canSend(StarterPayloads.NamedCreate.TYPE)){
offer=new ProgressionPayloads.Hello(offer.name(),offer.colors(),"starter_name_version_required");return;
}
@@ -62,9 +86,12 @@ public final class HelloWorldScreen extends Screen {
else ClientConfigurationNetworking.send(new StarterPayloads.Create(bio.getValue(),color,starter));
}).bounds(panelX+96,actionsY,panelWidth-96,20).build());updateSelection();
}
private void sendCheck(String normalized){lastChecked=normalized;codeStatus="code_checking";updateSelection();if(ClientConfigurationNetworking.canSend(AccessPayloads.Check.TYPE))ClientConfigurationNetworking.send(new AccessPayloads.Check(normalized));}
private void updateSelection(){
if(submit!=null)submit.active=!waiting&&color>=0&&(starterChoices.isEmpty()||(!starter.isEmpty()&&StarterCompanions.validName(nameDraft)));
boolean codeOk=!accessRequired||accessPending||"code_recognized".equals(codeStatus);
if(submit!=null)submit.active=!waiting&&color>=0&&(starterChoices.isEmpty()||(!starter.isEmpty()&&StarterCompanions.validName(nameDraft)))&&codeOk;
if(familiarName!=null)familiarName.setEditable(!waiting);
if(codeBox!=null)codeBox.setEditable(!waiting&&!accessPending);
if(bio!=null)bio.setEditable(!waiting&&!identityLocked);colors.forEach(b->b.active=!waiting&&!identityLocked);starters.forEach(b->b.active=!waiting);
}
@Override public void extractBackground(GuiGraphicsExtractor g,int mx,int my,float a){
@@ -73,13 +100,19 @@ public final class HelloWorldScreen extends Screen {
@Override public void extractRenderState(GuiGraphicsExtractor g,int mx,int my,float a){
super.extractRenderState(g,mx,my,a);g.centeredText(font,title,width/2,panelY+7,0xFFFFFFFF);
g.text(font,Component.literal(offer.name()).withStyle(s->color<0?s:s.withColor(color)),panelX+72,panelY+(compact?24:29),0xFFFFFFFF);
if(accessRequired){
String codeKey=codeStatus.isEmpty()?"code":codeStatus;
int codeColor="code_recognized".equals(codeStatus)?0xFF88CC88:codeStatus.isEmpty()?0xFFBBBBBB:0xFFFFFFAA;
g.text(font,ProgressionService.label(codeKey),panelX+72,panelY+(compact?37:44),codeColor);
}
g.text(font,ProgressionService.label("bio"),panelX+72,bioLabelY,0xFFBBBBBB);g.text(font,ProgressionService.label("choose_color"),panelX+72,colorLabelY,0xFFBBBBBB);
String status=waiting?"joining":offer.error();
String shown=offer.error().startsWith("code_")?"":offer.error();
String status=waiting?"joining":shown;
if(!starterChoices.isEmpty()){
g.text(font,status.isEmpty()?Component.translatable("sanctuary.starter.slots"):ProgressionService.label(status),panelX+72,starterLabelY,status.isEmpty()?0xFFBBBBBB:0xFFFFFFAA);
g.text(font,Component.translatable("sanctuary.starter.name"),panelX+72,nameLabelY,0xFFBBBBBB);
}
else if(!status.isEmpty())g.centeredText(font,ProgressionService.label(status),width/2,panelY+204,0xFFFFFFAA);
else if(!status.isEmpty())g.centeredText(font,ProgressionService.label(status),width/2,panelY+204+(accessRequired?36:0),0xFFFFFFAA);
}
private final class ColorButton extends Button.Plain {
ColorButton(int x,int y,int w,int h,int rgb,int number,OnPress press){super(x,y,w,h,InhabitantColorNames.label(rgb).copy().withStyle(s->s.withColor(rgb)),press,ignored->ProgressionService.label("color_number",number).copy().append(": ").append(InhabitantColorNames.label(rgb)));setOverrideRenderHighlightedSprite(()->color==rgb||isHoveredOrFocused());}
@@ -11,6 +11,7 @@ import net.minecraft.client.gui.screens.Screen;
public final class ProgressionClient {
public static ProgressRecord progress;
private static boolean accessGate,accessPending;
public static boolean operator;
public static InhabitantStore.Identity identity;
public static ProgressionConfig config=ProgressionConfig.DEFAULT;
@@ -38,14 +39,21 @@ public final class ProgressionClient {
CycleClient.register();
EntityRendererRegistry.register(SanctuarySitting.SEAT,NoopRenderer::new);
ClientConfigurationNetworking.registerGlobalReceiver(ProgressionPayloads.InventoryLayout.TYPE,(payload,context) -> {});
ClientConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Gate.TYPE,(payload,context)->context.client().execute(()->{
accessGate=payload.required();accessPending=payload.pending();
if(payload.required()&&context.client().gui.screen() instanceof HelloWorldScreen screen)screen.requireAccess(payload.pending());
}));
ClientConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Status.TYPE,(payload,context)->context.client().execute(()->{
if(context.client().gui.screen() instanceof HelloWorldScreen screen)screen.acceptCode(payload.code(),payload.reason());
}));
ClientConfigurationNetworking.registerGlobalReceiver(StarterPayloads.Welcome.TYPE,(payload,context)->context.client().execute(()->{
if(context.client().gui.screen() instanceof HelloWorldScreen screen)screen.accept(payload);
else context.client().gui.setScreen(new HelloWorldScreen(payload,()->context.responseSender().disconnect(ProgressionService.label("cancelled"))));
else showHello(context.client(),new HelloWorldScreen(payload,()->context.responseSender().disconnect(ProgressionService.label("cancelled"))));
}));
ClientConfigurationNetworking.registerGlobalReceiver(ProgressionPayloads.Hello.TYPE,(payload,context) ->
context.client().execute(() -> {
if (context.client().gui.screen() instanceof HelloWorldScreen screen) screen.accept(payload);
else context.client().gui.setScreen(new HelloWorldScreen(payload,
else showHello(context.client(),new HelloWorldScreen(payload,
() -> context.responseSender().disconnect(ProgressionService.label("cancelled"))));
}));
ClientPlayNetworking.registerGlobalReceiver(ProgressionPayloads.Snapshot.TYPE,(payload,context) ->
@@ -91,9 +99,11 @@ public final class ProgressionClient {
if (active() && ClientPlayNetworking.canSend(ProgressionPayloads.Action.TYPE))
ClientPlayNetworking.send(new ProgressionPayloads.Action(target,progress.revision()));
}
private static void showHello(Minecraft client,HelloWorldScreen screen){if(accessGate)screen.prepareAccess(accessPending);client.gui.setScreen(screen);}
private static void reset(Minecraft client) {
if (client.player != null) ProgressEffects.clear(client.player);
identity=null;progress=null;operator=false;config=ProgressionConfig.DEFAULT;names=MobNamesConfig.DEFAULT;
accessGate=false;accessPending=false;
wasSneaking=false;pendingSit=false;lastSneak=-100;
}
}
@@ -18,7 +18,6 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
public static void registerClient() {
SanctuaryResourcePack.register();
fr.koka.sanctuary.client.shader.NaturalWater.register();
VortexCloudRenderer.register();
fr.koka.sanctuary.client.shader.PixelShadows.register();
fr.koka.sanctuary.client.shader.EdgeHighlights.register();
@@ -1,172 +0,0 @@
package fr.koka.sanctuary.client.shader;
import com.google.gson.*;
import fr.koka.sanctuary.SanctuaryMod;
import java.util.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.resource.v1.ResourceLoader;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.block.BlockAndTintGetter;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.PackType;
import net.minecraft.server.packs.resources.ResourceManagerReloadListener;
import net.minecraft.tags.BiomeTags;
import net.minecraft.tags.FluidTags;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
/** Client-only water materials; no world writes, per-frame terrain scan or additional render pass. */
public final class NaturalWater {
public static final int DEPTH=8;
public record Profile(float tint,float alpha,float fog) {
public Profile mix(Profile b,float t){if(this==b||t==0)return this;if(t==1)return b;return new Profile(tint+(b.tint-tint)*t,alpha+(b.alpha-alpha)*t,fog+(b.fog-fog)*t);}
}
public static final Profile CLEAR=new Profile(0,.55f,1),LIGHT=new Profile(.10f,.80f,.70f),POND=new Profile(.85f,1,.40f);
private static final Profile[] PROFILES={CLEAR,LIGHT,POND};
private static volatile Map<Block,Integer> materials=Map.of();
private static volatile float strength;
private static ClientLevel previousLevel;
private static Profile fogFrom=CLEAR,fogTarget=CLEAR;
private static long fogChanged;
private static boolean wasUnderwater;
private NaturalWater(){}
public static void register(){
ResourceLoader.get(PackType.CLIENT_RESOURCES).registerReloadListener(Identifier.parse("sanctuary:water_materials"),
(ResourceManagerReloadListener) manager->{
try(var reader=manager.getResourceOrThrow(Identifier.parse("sanctuary:water_materials.json")).openAsReader()){
var root=JsonParser.parseReader(reader).getAsJsonObject();var next=new HashMap<Block,Integer>();
for(var entry:Map.of("light",1,"pond",2).entrySet()) for(var id:root.getAsJsonArray(entry.getKey())){
var key=Identifier.parse(id.getAsString());
if(!BuiltInRegistries.BLOCK.containsKey(key))throw new IllegalArgumentException("Unknown water material "+key);
if(next.put(BuiltInRegistries.BLOCK.getValue(key),entry.getValue())!=null)throw new IllegalArgumentException("Duplicate water material "+key);
}
materials=Map.copyOf(next);
}catch(Exception e){SanctuaryMod.LOGGER.warn("Invalid water materials; using bundled defaults",e);materials=defaults();}
});
ClientTickEvents.END_CLIENT_TICK.register(client->{
float next=VanillaLight.active()&&VanillaLight.naturalWater()?VanillaLight.waterIntensity()/100f:0;
if(previousLevel!=client.level||strength!=next){previousLevel=client.level;strength=next;wasUnderwater=false;
if(client.level!=null)invalidateWaterSections(client);}
if(client.level==null||next==0){wasUnderwater=false;return;}
var camera=client.gameRenderer.mainCamera();
if(camera.getFluidInCamera()!=net.minecraft.world.level.material.FogType.WATER){wasUnderwater=false;return;}
var pos=camera.position();var target=sample(client.level,pos.x,pos.y,pos.z);
if(!wasUnderwater){fogFrom=fogTarget=target;fogChanged=System.nanoTime();wasUnderwater=true;}
else if(!target.equals(fogTarget)){fogFrom=fogProfile();fogTarget=target;fogChanged=System.nanoTime();}
});
}
/** Preference changes only rebuild loaded water meshes, retaining unrelated shader caches. */
private static void invalidateWaterSections(Minecraft client){
var level=client.level;
var center=BlockPos.containing(client.gameRenderer.mainCamera().position());
int radius=client.options.getEffectiveRenderDistance()+1;
for(int z=(center.getZ()>>4)-radius;z<=(center.getZ()>>4)+radius;z++)
for(int x=(center.getX()>>4)-radius;x<=(center.getX()>>4)+radius;x++){
var chunk=level.getChunkSource().getChunk(x,z,net.minecraft.world.level.chunk.status.ChunkStatus.FULL,false);
if(chunk==null)continue;
var sections=chunk.getSections();
for(int i=0;i<sections.length;i++)if(!sections[i].hasOnlyAir()
&&sections[i].getStates().maybeHas(state->state.getFluidState().is(FluidTags.WATER)))
client.levelExtractor.setSectionDirty(x,level.getSectionYFromSectionIndex(i),z);
}
}
private static Map<Block,Integer> defaults(){
var result=new HashMap<Block,Integer>();
for(String name:new String[]{"sand","red_sand","gravel","clay"})result.put(BuiltInRegistries.BLOCK.getValue(Identifier.withDefaultNamespace(name)),1);
for(String name:new String[]{"dirt","coarse_dirt","rooted_dirt","grass_block","podzol","mycelium","mud","muddy_mangrove_roots"})result.put(BuiltInRegistries.BLOCK.getValue(Identifier.withDefaultNamespace(name)),2);
return Map.copyOf(result);
}
public static float strength(){return strength;}
public static Profile fogProfile(){return fogFrom.mix(fogTarget,Math.clamp((System.nanoTime()-fogChanged)/500_000_000f,0,1));}
// ClientLevel.hasChunk always returns true; inspect the cache without requesting a chunk.
public static boolean loaded(ClientLevel level,int x,int z){
return level.getChunkSource().getChunk(x,z,net.minecraft.world.level.chunk.status.ChunkStatus.FULL,false)!=null;
}
public static boolean ocean(ClientLevel level,BlockPos pos){return loaded(level,pos.getX()>>4,pos.getZ()>>4)&&level.getBiome(pos).is(BiomeTags.IS_OCEAN);}
private static int classify(java.util.function.Function<BlockPos,BlockState> read,BlockPos pos,Map<Block,Integer> table){
var cursor=pos.mutable();
for(int i=1;i<=DEPTH;i++){
cursor.set(pos.getX(),pos.getY()-i,pos.getZ());var state=read.apply(cursor);
if(state==null||state.isAir())return 0;
if(state.isSolid()||!state.getCollisionShape(net.minecraft.world.level.EmptyBlockGetter.INSTANCE,cursor).isEmpty()){
// Plants have no collision; a waterlogged solid remains a basin floor.
return table.getOrDefault(state.getBlock(),0);
}
if(!state.getFluidState().is(FluidTags.WATER))return 0;
}
return 0;
}
public static Profile sample(ClientLevel level,double x,double y,double z){
var pos=BlockPos.containing(x,y,z);if(ocean(level,pos))return CLEAR;
int bx=(int)Math.floor(x-.5),bz=(int)Math.floor(z-.5),by=pos.getY();
float dx=(float)(x-.5-bx),dz=(float)(z-.5-bz);
return live(level,bx,by,bz).mix(live(level,bx+1,by,bz),dx).mix(live(level,bx,by,bz+1).mix(live(level,bx+1,by,bz+1),dx),dz);
}
private static Profile live(ClientLevel l,int x,int y,int z){
var p=new BlockPos(x,y,z);if(!loaded(l,x>>4,z>>4)||ocean(l,p))return CLEAR;
return PROFILES[classify(l::getBlockState,p,materials)];
}
/** Owned by a single native section compilation, with ocean data captured before worker submission. */
public static final class Region {
private final int ox,oy,oz;
private final net.minecraft.world.level.biome.BiomeManager biomes;
private byte[] oceanKinds;
private byte[] profiles;
private final Map<Block,Integer> table=materials;
public final float intensity=strength;
public Region(ClientLevel level,int minX,int minY,int minZ){
ox=(minX+1)*16;oy=(minY+1)*16;oz=(minZ+1)*16;
// Copy quart holders, then keep the native seeded biome zoom on the immutable copy.
// Sampling only quart centres would misclassify the jagged edges of ocean biomes.
var copied=new java.util.ArrayList<net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome>>(216);
int qx=(ox>>2)-1,qy=(oy>>2)-1,qz=(oz>>2)-1;
for(int y=0;y<6;y++)for(int z=0;z<6;z++)for(int x=0;x<6;x++)
copied.add(loaded(level,(qx+x)>>2,(qz+z)>>2)?level.getBiomeManager().getNoiseBiomeAtQuart(qx+x,qy+y,qz+z):null);
biomes=level.getBiomeManager().withDifferentSource((x,y,z)->copied.get(((y-qy)*6+z-qz)*6+x-qx));
}
// A missing biome snapshot also forces clear water at unloaded region edges.
public boolean ocean(int x,int y,int z){
if(oceanKinds==null)oceanKinds=new byte[18*16*18];
int key=((y-oy)*18+z-oz+1)*18+x-ox+1;
int code=oceanKinds[key];if(code==0){var biome=biomes.getBiome(x,y,z);code=biome==null||biome.is(BiomeTags.IS_OCEAN)?2:1;oceanKinds[key]=(byte)code;}
return code==2;
}
private Profile profile(BlockAndTintGetter region,int x,int y,int z){
if(x<ox-1||x>ox+16||y<oy||y>oy+15||z<oz-1||z>oz+16)return CLEAR;
if(ocean(x,y,z))return CLEAR;
int key=((y-oy)*18+z-oz+1)*18+x-ox+1;
if(profiles==null)profiles=new byte[18*16*18];
int code=profiles[key];if(code==0){code=1+classify(region::getBlockState,new BlockPos(x,y,z),table);profiles[key]=(byte)code;}
return PROFILES[code-1];
}
public Profile corner(BlockAndTintGetter region,BlockPos p,int dx,int dz){
if(ocean(p.getX(),p.getY(),p.getZ()))return CLEAR;
int x=p.getX()+dx-1,y=p.getY(),z=p.getZ()+dz-1;
// Both cells sharing an ocean edge must emit the same clear vertex.
// The one-block transition stays on the non-ocean side, preserving ocean priority.
if(ocean(x,y,z)||ocean(x+1,y,z)||ocean(x,y,z+1)||ocean(x+1,y,z+1))return CLEAR;
return profile(region,x,y,z).mix(profile(region,x+1,y,z),.5f).mix(profile(region,x,y,z+1).mix(profile(region,x+1,y,z+1),.5f),.5f);
}
}
public interface RegionAccess { Region sanctuary$waterRegion(); }
public static int color(int color,int base,Profile profile,float intensity){
float tint=profile.tint*intensity;
int r=color>>16&255,g=color>>8&255,b=color&255;
// Restore directional face shading after tinting, including custom biome/resource-pack colours.
float shade=(r+g+b)/(float)Math.max(1,(base>>16&255)+(base>>8&255)+(base&255));
int rr=Math.clamp(Math.round(r*(1-tint)+120*shade*tint),0,255);
int gg=Math.clamp(Math.round(g*(1-tint)+132*shade*tint),0,255);
int bb=Math.clamp(Math.round(b*(1-tint)+72*shade*tint),0,255);
int a=Math.round((color>>>24)*(1+(profile.alpha-1)*intensity));
return a<<24|rr<<16|gg<<8|bb;
}
public static void dirty(BlockPos pos){
if(strength==0)return;
var renderer=Minecraft.getInstance().levelExtractor;
renderer.setSectionRangeDirty((pos.getX()-1)>>4,pos.getY()>>4,(pos.getZ()-1)>>4,
(pos.getX()+1)>>4,(pos.getY()+DEPTH)>>4,(pos.getZ()+1)>>4);
}
}
@@ -14,9 +14,6 @@ public final class ShaderOptionsScreen extends OptionsSubScreen {
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.vanilla_light",OptionInstance.cachedConstantTooltip(
Component.translatable(VanillaLight.externalEngine()?"sanctuary.shaders.external":"sanctuary.shaders.help")),
VanillaLight.enabled(),VanillaLight::setEnabled));
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.natural_water",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.natural_water_help")),VanillaLight.naturalWater(),VanillaLight::setNaturalWater));
slider("water_intensity",VanillaLight.waterIntensity(),VanillaLight::setWaterIntensity);
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.saturation",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.saturation_help")),(caption,value)->Component.translatable("sanctuary.shaders.saturation_value",value),
new OptionInstance.IntRange(0,200),VanillaLight.saturation(),VanillaLight::setSaturation));
@@ -16,8 +16,6 @@ public final class VanillaLight {
private VanillaLight() { }
private static final class Preferences {
boolean enabled=true;
boolean naturalWater=true;
int waterIntensity=100;
int saturation=142;
boolean pixelShadows=true;
boolean bloom=true;
@@ -60,7 +58,6 @@ public final class VanillaLight {
preferences.oreIntensity=Math.clamp(preferences.oreIntensity,0,100);
preferences.edgeIntensity=Math.clamp(preferences.edgeIntensity,0,100);
preferences.edgeDistance=normalizeShadowDistance(preferences.edgeDistance);
preferences.waterIntensity=Math.clamp(preferences.waterIntensity,0,100);
preferences.saturation=Math.clamp(preferences.saturation,0,200);
preferences.shadowPixels=normalizePrecision(preferences.shadowPixels);
preferences.shadowDistance=normalizeShadowDistance(preferences.shadowDistance);
@@ -69,10 +66,6 @@ public final class VanillaLight {
}
public static boolean externalEngine(){return EXTERNAL_ENGINE;}
public static boolean active(){return ProgressionClient.active()&&!EXTERNAL_ENGINE&&enabled();}
public static boolean naturalWater(){enabled();return preferences.naturalWater;}
public static int waterIntensity(){enabled();return preferences.waterIntensity;}
public static void setNaturalWater(boolean value){enabled();preferences.naturalWater=value;save();}
public static void setWaterIntensity(int value){enabled();preferences.waterIntensity=Math.clamp(value,0,100);save();}
public static int saturation(){enabled();return preferences.saturation;}
public static boolean edgeHighlights(){enabled();return preferences.edgeHighlights;}
public static int edgeDistance(){enabled();return preferences.edgeDistance;}
@@ -1,48 +0,0 @@
package fr.koka.sanctuary.mixin.client;
import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod;
import com.llamalad7.mixinextras.injector.wrapoperation.*;
import com.mojang.blaze3d.vertex.VertexConsumer;
import fr.koka.sanctuary.client.shader.NaturalWater;
import net.minecraft.client.color.block.BlockTintSource;
import net.minecraft.client.renderer.block.*;
import net.minecraft.core.BlockPos;
import net.minecraft.tags.FluidTags;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.FluidState;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.At;
/** Native renderer instances belong to one section compiler. Context is restored even on failure. */
@Mixin(FluidRenderer.class)
abstract class NaturalWaterFluidMixin {
@Unique private NaturalWater.Profile[] sanctuary$corners;
@Unique private NaturalWater.Region sanctuary$snapshot;
@Unique private BlockAndTintGetter sanctuary$region;
@Unique private BlockPos sanctuary$pos;
@Unique private float sanctuary$strength;
@Unique private int sanctuary$x,sanctuary$z,sanctuary$base;
@WrapMethod(method="tesselate")
private void sanctuary$water(BlockAndTintGetter region,BlockPos pos,FluidRenderer.Output output,BlockState state,FluidState fluid,Operation<Void> original){
var previous=sanctuary$snapshot;
try{
sanctuary$corners=null;sanctuary$snapshot=null;sanctuary$base=-1;
if(fluid.is(FluidTags.WATER)&&region instanceof NaturalWater.RegionAccess access){
var snapshot=access.sanctuary$waterRegion();
if(snapshot!=null){
sanctuary$strength=snapshot.intensity;sanctuary$x=pos.getX()&15;sanctuary$z=pos.getZ()&15;
sanctuary$snapshot=snapshot;sanctuary$region=region;sanctuary$pos=pos;
}
}
original.call(region,pos,output,state,fluid);
}finally{sanctuary$snapshot=previous;sanctuary$corners=null;}
}
@WrapOperation(method="tesselate",at=@At(value="INVOKE",target="Lnet/minecraft/client/color/block/BlockTintSource;colorInWorld(Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/client/renderer/block/BlockAndTintGetter;Lnet/minecraft/core/BlockPos;)I"))
private int sanctuary$base(BlockTintSource source,BlockState state,BlockAndTintGetter region,BlockPos pos,Operation<Integer> original){return sanctuary$base=original.call(source,state,region,pos);}
@WrapOperation(method="vertex",at=@At(value="INVOKE",target="Lcom/mojang/blaze3d/vertex/VertexConsumer;addVertex(FFFIFFIIFFF)V"))
private void sanctuary$vertex(VertexConsumer consumer,float x,float y,float z,int color,float u,float v,int overlay,int light,float nx,float ny,float nz,Operation<Void> original){
if(sanctuary$snapshot!=null){
if(sanctuary$corners==null){var s=sanctuary$snapshot;var r=sanctuary$region;var p=sanctuary$pos;
sanctuary$corners=new NaturalWater.Profile[]{s.corner(r,p,0,0),s.corner(r,p,1,0),s.corner(r,p,0,1),s.corner(r,p,1,1)};}
int corner=(x-sanctuary$x>.5f?1:0)+(z-sanctuary$z>.5f?2:0);color=NaturalWater.color(color,sanctuary$base,sanctuary$corners[corner],sanctuary$strength);}
original.call(consumer,x,y,z,color,u,v,overlay,light,nx,ny,nz);
}
}
@@ -1,24 +0,0 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.shader.NaturalWater;
import net.minecraft.client.Camera;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.fog.FogRenderer;
import net.minecraft.world.level.material.FogType;
import org.joml.Vector4f;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(FogRenderer.class)
abstract class NaturalWaterFogColorMixin {
@Inject(method="computeFogColor",at=@At("RETURN"))
private void sanctuary$waterColor(Camera camera,float ticks,ClientLevel level,int distance,float darken,Vector4f color,CallbackInfo ci){
if(NaturalWater.strength()==0||camera.getFluidInCamera()!=FogType.WATER)return;
float amount=NaturalWater.strength()*NaturalWater.fogProfile().tint();if(amount==0)return;
// Apply after native water-vision normalization: normalizing olive to max=1 turns it neon yellow.
// Retain native darkness/vision brightness, including a fully black blinded environment.
float brightness=Math.max(color.x,Math.max(color.y,color.z));
color.x+=(120/255f*brightness-color.x)*amount;
color.y+=(132/255f*brightness-color.y)*amount;
color.z+=(72/255f*brightness-color.z)*amount;
}
}
@@ -1,19 +0,0 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.shader.NaturalWater;
import net.minecraft.client.Camera;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.fog.FogData;
import net.minecraft.client.renderer.fog.environment.WaterFogEnvironment;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.*;
@Mixin(WaterFogEnvironment.class)
abstract class NaturalWaterFogMixin {
@Inject(method="setupFog",at=@At("TAIL"))
private void sanctuary$fog(FogData fog,Camera camera,ClientLevel level,float distance,DeltaTracker delta,CallbackInfo ci){
float amount=NaturalWater.strength();if(amount==0)return;
float factor=1+(NaturalWater.fogProfile().fog()-1)*amount;
fog.environmentalStart*=factor;fog.environmentalEnd*=factor;fog.skyEnd*=factor;fog.cloudEnd*=factor;
}
}
@@ -1,23 +0,0 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.shader.NaturalWater;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.chunk.RenderSectionRegion;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(RenderSectionRegion.class)
abstract class NaturalWaterRegionMixin implements NaturalWater.RegionAccess {
@Shadow @Final private ClientLevel level;
@Shadow @Final private net.minecraft.client.renderer.chunk.SectionCopy[] sections;
@Shadow @Final private int minSectionX,minSectionY,minSectionZ;
@Unique private NaturalWater.Region sanctuary$water;
@Inject(method="<init>",at=@At("RETURN"))
private void sanctuary$snapshot(CallbackInfo ci){
if(NaturalWater.strength()==0)return;
// Native 3×3×3 region, centre index 13. Palette rejection avoids 216 biome reads for dry terrain.
var states=((NaturalWaterSectionCopyAccess)sections[13]).sanctuary$states();
if(states!=null&&states.maybeHas(state->state.getFluidState().is(net.minecraft.tags.FluidTags.WATER)))
sanctuary$water=new NaturalWater.Region(level,minSectionX,minSectionY,minSectionZ);
}
public NaturalWater.Region sanctuary$waterRegion(){return sanctuary$water;}
}
@@ -1,10 +0,0 @@
package fr.koka.sanctuary.mixin.client;
import net.minecraft.client.renderer.chunk.SectionCopy;
import net.minecraft.world.level.chunk.PalettedContainer;
import net.minecraft.world.level.block.state.BlockState;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(SectionCopy.class)
interface NaturalWaterSectionCopyAccess {
@Accessor("section") PalettedContainer<BlockState> sanctuary$states();
}
@@ -17,7 +17,6 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
abstract class PixelShadowDirtyMixin {
@Inject(method="blockChanged", at=@At("HEAD"))
private void sanctuary$dirty(BlockPos pos, int flags, CallbackInfo ci) {
fr.koka.sanctuary.client.shader.NaturalWater.dirty(pos);
PixelShadows.dirtyBlock(pos); ProceduralPbr.dirtyBlock(pos); Bloom.dirtyBlock(pos); ColoredLights.dirtyBlock(pos);
}
@Inject(method="allChanged", at=@At("HEAD"))
@@ -0,0 +1,77 @@
package fr.koka.sanctuary.progression;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.Locale;
import java.util.regex.Pattern;
/** Site access-code contract. The client never calls the site; only these decisions run on the server. */
public final class AccessCodes {
private static final Pattern CODE = Pattern.compile("SANC-[A-Z0-9]{4}-[A-Z0-9]{4}");
private static final Pattern USER = Pattern.compile("[A-Za-z0-9_]{3,16}");
private static final Pattern DISCORD = Pattern.compile("[0-9]{1,20}");
private AccessCodes() { }
public record Result(boolean valid, String reason, String minecraftUsername, String discordId) {
public static Result of(boolean valid, String reason, String username, String discordId) {
return new Result(valid, reason == null ? "" : reason, username == null ? "" : username, discordId == null ? "" : discordId);
}
public static Result unreachable() { return of(false, "unreachable", "", ""); }
public static Result unauthorized() { return of(false, "unauthorized", "", ""); }
public static Result unconfigured() { return of(false, "unconfigured", "", ""); }
}
public record Redeem(boolean accepted, String reason, String minecraftUsername, String discordId) {
public static Redeem accept(String username, String discordId) { return new Redeem(true, "code_recognized", username, discordId); }
public static Redeem reject(String reason) { return new Redeem(false, reason, "", ""); }
}
public static String normalize(String code) { return code == null ? "" : code.trim().toUpperCase(Locale.ROOT); }
public static boolean validFormat(String normalized) { return normalized != null && CODE.matcher(normalized).matches(); }
public static boolean validUsername(String name) { return name != null && USER.matcher(name).matches(); }
public static boolean validDiscord(String id) { return id != null && DISCORD.matcher(id).matches(); }
public static boolean sameUser(String returned, String session) {
return validUsername(session) && returned != null && returned.equalsIgnoreCase(session) && validUsername(returned);
}
public static Result parse(int status, String body) {
if (status == 401) return Result.unauthorized();
if (status == 429 || status < 200 || status >= 500) return Result.unreachable();
try {
var object = JsonParser.parseString(body).getAsJsonObject();
if (!object.has("valid") || !object.has("reason")) return Result.unreachable();
return Result.of(object.get("valid").getAsBoolean(), text(object, "reason"), text(object, "minecraft_username"), text(object, "discord_id"));
} catch (RuntimeException error) { return Result.unreachable(); }
}
public static String verifyKey(Result result, String username) {
if (result == null) return "code_unreachable";
return switch (result.reason()) {
case "unconfigured" -> "code_unconfigured";
case "unauthorized" -> "code_unauthorized";
case "unreachable" -> "code_unreachable";
case "unused" -> result.valid() && sameUser(result.minecraftUsername(), username) && validDiscord(result.discordId()) ? "code_recognized" : "code_invalid";
case "invalid" -> "code_invalid";
case "already_used" -> "code_used";
default -> "code_invalid";
};
}
/** Redeem commits the code. An already used code is accepted only when the site still returns the same account. */
public static Redeem redeem(Result result, String username) {
if (result == null) return Redeem.reject("code_unreachable");
return switch (result.reason()) {
case "unconfigured" -> Redeem.reject("code_unconfigured");
case "unauthorized" -> Redeem.reject("code_unauthorized");
case "unreachable" -> Redeem.reject("code_unreachable");
case "redeemed", "already_used" -> account(result, username);
case "invalid" -> Redeem.reject("code_invalid");
default -> Redeem.reject("code_invalid");
};
}
private static Redeem account(Result result, String username) {
if (!result.minecraftUsername().isEmpty() && !result.minecraftUsername().equalsIgnoreCase(username))
return Redeem.reject("code_mismatch");
if (!sameUser(result.minecraftUsername(), username) || !validDiscord(result.discordId()))
return Redeem.reject("code_replay");
return Redeem.accept(result.minecraftUsername(), result.discordId());
}
private static String text(JsonObject object, String key) {
if (!object.has(key) || object.get(key).isJsonNull() || !object.get(key).isJsonPrimitive()) return "";
return object.get(key).getAsString();
}
}
@@ -0,0 +1,107 @@
package fr.koka.sanctuary.progression;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** Server-only site access. The token stays in the environment or the server config, never in the client. */
public final class AccessConfig {
private static final Logger LOG = LoggerFactory.getLogger("Sanctuary/Access");
private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).followRedirects(HttpClient.Redirect.NEVER).build();
public interface Directory {
AccessCodes.Result verify(String username, String code);
AccessCodes.Result redeem(String username, String code);
}
private static volatile boolean enabled;
private static volatile Directory directory = new Disabled();
private AccessConfig() { }
public static boolean enabled() { return enabled; }
public static Directory directory() { return directory; }
public static void load(Path configDir) {
String url = env("SANCTUARY_APP_URL");
String token = env("SANCTUARY_API_TOKEN");
if (url.isEmpty() || token.isEmpty()) {
var file = readFile(configDir.resolve("access.json"));
if (url.isEmpty()) url = file.url;
if (token.isEmpty()) token = file.token;
}
if (url.isEmpty() && token.isEmpty()) {
enabled = false; directory = new Disabled();
LOG.info("Sanctuary access codes are off. Set SANCTUARY_APP_URL and SANCTUARY_API_TOKEN to require a code.");
return;
}
URI uri;
try { uri = URI.create(url.endsWith("/") ? url.substring(0, url.length() - 1) : url); }
catch (IllegalArgumentException error) { failClosed("Sanctuary access URL is invalid. Character creation stays closed."); return; }
if (uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getHost() == null) {
failClosed("Sanctuary access URL must be a bare https origin. Character creation stays closed."); return;
}
String scheme = uri.getScheme();
if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) {
failClosed("Sanctuary access URL must use https. Character creation stays closed."); return;
}
if (token.isEmpty()) { failClosed("Sanctuary access token is missing. Character creation stays closed."); return; }
if ("http".equalsIgnoreCase(scheme)) LOG.warn("Sanctuary access URL is not HTTPS. Use HTTPS outside a local site.");
enabled = true;
directory = new HttpDirectory(uri, token);
LOG.info("Sanctuary access codes are required before character creation.");
}
private static void failClosed(String message) {
enabled = true; directory = new Disabled(); LOG.error(message);
}
private static String env(String key) {
String value = System.getenv(key);
return value == null ? "" : value.trim();
}
private record FileConfig(String url, String token) { }
private static FileConfig readFile(Path file) {
if (!Files.isRegularFile(file)) return new FileConfig("", "");
try {
var object = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)).getAsJsonObject();
return new FileConfig(text(object, "appUrl"), text(object, "token"));
} catch (Exception error) {
LOG.error("Sanctuary access config cannot be read. Character creation stays closed.");
return new FileConfig("http://invalid", "");
}
}
private static String text(JsonObject object, String key) {
if (!object.has(key) || object.get(key).isJsonNull()) return "";
return object.get(key).getAsString().trim();
}
private static final class Disabled implements Directory {
public AccessCodes.Result verify(String username, String code) { return AccessCodes.Result.unconfigured(); }
public AccessCodes.Result redeem(String username, String code) { return AccessCodes.Result.unconfigured(); }
}
private static final class HttpDirectory implements Directory {
private final URI origin;
private final String token;
HttpDirectory(URI origin, String token) { this.origin = origin; this.token = token; }
public AccessCodes.Result verify(String username, String code) { return post("/api/v1/access-codes/verify", username, code); }
public AccessCodes.Result redeem(String username, String code) { return post("/api/v1/access-codes/redeem", username, code); }
private AccessCodes.Result post(String path, String username, String code) {
var body = new JsonObject();
body.addProperty("code", code);
body.addProperty("minecraft_username", username);
var request = HttpRequest.newBuilder(URI.create(origin.toString() + path)).timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + token).header("Content-Type", "application/json").header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build();
try {
var response = HTTP.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() == 401) LOG.warn("Sanctuary access token was rejected");
return AccessCodes.parse(response.statusCode(), response.body());
} catch (Exception error) {
LOG.warn("Sanctuary access endpoint is unreachable ({})", error.getClass().getSimpleName());
return AccessCodes.Result.unreachable();
}
}
}
}
@@ -0,0 +1,83 @@
package fr.koka.sanctuary.progression;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.TreeMap;
import java.util.UUID;
/** Discord binding for an inhabitant. Pending entries finish a creation interrupted after the site consumed the code. */
public final class AccessLedger {
private static final int LIMIT = 1024 * 1024;
private final Path file;
private final long seed;
private byte[] previous;
private final TreeMap<UUID, Binding> pending = new TreeMap<>();
private final TreeMap<UUID, Binding> bound = new TreeMap<>();
private boolean failed;
public record Binding(String name, String discordId) { }
public AccessLedger(Path file, long seed) throws IOException {
this.file = file; this.seed = seed;
previous = AtomicJson.read(file, LIMIT);
if (previous == null) return;
var root = AtomicJson.parse(new String(previous, StandardCharsets.UTF_8)).getAsJsonObject();
AtomicJson.fields(root, "schema", "seed", "pending", "bound");
if (root.get("schema").getAsInt() != 1 || root.get("seed").getAsLong() != seed)
throw new IllegalArgumentException("Invalid access ledger");
read(root.getAsJsonObject("pending"), pending);
read(root.getAsJsonObject("bound"), bound);
}
public Binding known(UUID id) {
var open = pending.get(id);
return open != null ? open : bound.get(id);
}
public void remember(UUID id, String name, String discordId) throws IOException {
if (failed) throw new IOException("Access ledger failed earlier");
if (!AccessCodes.validUsername(name) || !AccessCodes.validDiscord(discordId))
throw new IllegalArgumentException("Invalid access binding");
if (bound.containsKey(id)) return;
pending.put(id, new Binding(name, discordId));
save();
}
public void commit(UUID id) throws IOException {
if (failed) throw new IOException("Access ledger failed earlier");
var binding = pending.remove(id);
if (binding == null) return;
bound.put(id, binding);
save();
}
private static void read(JsonObject object, TreeMap<UUID, Binding> into) {
for (var entry : object.entrySet()) {
AtomicJson.fields(entry.getValue(), "name", "discordId");
UUID id = UUID.fromString(entry.getKey());
var value = entry.getValue().getAsJsonObject();
String name = value.get("name").getAsString();
String discordId = value.get("discordId").getAsString();
if (!AccessCodes.validUsername(name) || !AccessCodes.validDiscord(discordId))
throw new IllegalArgumentException("Invalid access binding");
if (into.put(id, new Binding(name, discordId)) != null) throw new IllegalArgumentException("Duplicate access binding");
}
}
private void save() throws IOException {
var root = new JsonObject();
root.addProperty("schema", 1);
root.addProperty("seed", seed);
root.add("pending", write(pending));
root.add("bound", write(bound));
try {
previous = AtomicJson.write(file, previous, new GsonBuilder().setPrettyPrinting().create().toJson(root), LIMIT);
} catch (IOException | RuntimeException error) { failed = true; throw error; }
}
private static JsonObject write(TreeMap<UUID, Binding> map) {
var object = new JsonObject();
map.forEach((id, binding) -> {
var value = new JsonObject();
value.addProperty("name", binding.name);
value.addProperty("discordId", binding.discordId);
object.add(id.toString(), value);
});
return object;
}
}
@@ -0,0 +1,38 @@
package fr.koka.sanctuary.progression;
import fr.koka.sanctuary.SanctuaryMod;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
/** Additive Hello World handshake. Existing create packets stay unchanged when access codes are off. */
public final class AccessPayloads {
private AccessPayloads() { }
public record Gate(boolean required, boolean pending) implements CustomPacketPayload {
public static final Type<Gate> TYPE = new Type<>(SanctuaryMod.id("access_gate_v1"));
public static final StreamCodec<FriendlyByteBuf, Gate> CODEC = CustomPacketPayload.codec(
(p, b) -> { b.writeBoolean(p.required); b.writeBoolean(p.pending); },
b -> new Gate(b.readBoolean(), b.readBoolean()));
public Type<Gate> type() { return TYPE; }
}
public record Check(String code) implements CustomPacketPayload {
public static final Type<Check> TYPE = new Type<>(SanctuaryMod.id("access_check_v1"));
public static final StreamCodec<FriendlyByteBuf, Check> CODEC = CustomPacketPayload.codec(
(p, b) -> b.writeUtf(p.code, 32), b -> new Check(b.readUtf(32)));
public Type<Check> type() { return TYPE; }
}
public record Status(String code, String reason) implements CustomPacketPayload {
public static final Type<Status> TYPE = new Type<>(SanctuaryMod.id("access_status_v1"));
public static final StreamCodec<FriendlyByteBuf, Status> CODEC = CustomPacketPayload.codec(
(p, b) -> { b.writeUtf(p.code, 32); b.writeUtf(p.reason, 64); },
b -> new Status(b.readUtf(32), b.readUtf(64)));
public Type<Status> type() { return TYPE; }
}
public record Admit(String bio, int color, String mob, String name, String code) implements CustomPacketPayload {
public static final Type<Admit> TYPE = new Type<>(SanctuaryMod.id("access_admit_v1"));
public static final StreamCodec<FriendlyByteBuf, Admit> CODEC = CustomPacketPayload.codec(
(p, b) -> { b.writeUtf(p.bio, 256); b.writeInt(p.color); b.writeUtf(p.mob, 64); b.writeUtf(p.name, StarterCompanions.NAME_LIMIT); b.writeUtf(p.code, 32); },
b -> new Admit(b.readUtf(256), b.readInt(), b.readUtf(64), b.readUtf(StarterCompanions.NAME_LIMIT), b.readUtf(32)));
public Type<Admit> type() { return TYPE; }
}
}
@@ -50,6 +50,7 @@ public final class ProgressionService {
public StarterCompanions.Store starters;
public CycleConfig cycleConfig=CycleConfig.DEFAULT;
public TutorialConfig tutorialConfig=TutorialConfig.DEFAULT;
public AccessLedger access;
Session(InhabitantStore inhabitants, ProgressionConfig config, MobNamesConfig names) {
this.inhabitants=inhabitants; this.config=config; this.names=names;
}
@@ -61,6 +62,10 @@ public final class ProgressionService {
PayloadTypeRegistry.clientboundConfiguration().register(StarterPayloads.Welcome.TYPE,StarterPayloads.Welcome.CODEC);
PayloadTypeRegistry.serverboundConfiguration().register(StarterPayloads.Create.TYPE,StarterPayloads.Create.CODEC);
PayloadTypeRegistry.serverboundConfiguration().register(StarterPayloads.NamedCreate.TYPE,StarterPayloads.NamedCreate.CODEC);
PayloadTypeRegistry.clientboundConfiguration().register(AccessPayloads.Gate.TYPE,AccessPayloads.Gate.CODEC);
PayloadTypeRegistry.clientboundConfiguration().register(AccessPayloads.Status.TYPE,AccessPayloads.Status.CODEC);
PayloadTypeRegistry.serverboundConfiguration().register(AccessPayloads.Check.TYPE,AccessPayloads.Check.CODEC);
PayloadTypeRegistry.serverboundConfiguration().register(AccessPayloads.Admit.TYPE,AccessPayloads.Admit.CODEC);
SanctuarySitting.register();
fr.koka.sanctuary.inventory.HotbarService.register();
fr.koka.sanctuary.inventory.InventorySortService.register();
@@ -87,6 +92,9 @@ public final class ProgressionService {
});
ServerConfigurationConnectionEvents.CONFIGURE.register((handler,server) -> {
if (session(server) == null) return;
if (session(server).access != null && !ServerConfigurationNetworking.canSend(handler,AccessPayloads.Gate.TYPE)) {
handler.disconnect(label("access_version_required")); return;
}
if (!ServerConfigurationNetworking.canSend(handler,ProgressionPayloads.Hello.TYPE)) {
handler.disconnect(label("client_required")); return;
}
@@ -147,6 +155,12 @@ public final class ProgressionService {
ServerConfigurationNetworking.registerGlobalReceiver(StarterPayloads.NamedCreate.TYPE,(payload,context)->context.server().execute(()->{
HelloTask task=PENDING.get(context.packetListener());if(task!=null)task.createWithStarter(new StarterPayloads.Create(payload.bio(),payload.color(),payload.mob()),payload.name());
}));
ServerConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Check.TYPE,(payload,context)->context.server().execute(()->{
HelloTask task=PENDING.get(context.packetListener());if(task!=null)task.check(payload.code());
}));
ServerConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Admit.TYPE,(payload,context)->context.server().execute(()->{
HelloTask task=PENDING.get(context.packetListener());if(task!=null)task.admit(payload);
}));
ServerConfigurationNetworking.registerGlobalReceiver(ProgressionPayloads.IntroFinished.TYPE, (payload,context) ->
context.server().execute(() -> {
HelloTask task = PENDING.get(context.packetListener());
@@ -194,6 +208,7 @@ public final class ProgressionService {
private static void start(MinecraftServer server) {
if (!SanctuaryGameplay.supported(server)) return;
try {
AccessConfig.load(CONFIG);
var config=ProgressionConfig.load(CONFIG.resolve("progression.json"));
var names=MobNamesConfig.load(CONFIG.resolve("mob-names.json"));
Path world=server.getWorldPath(LevelResource.ROOT);
@@ -210,6 +225,7 @@ public final class ProgressionService {
session.cycleConfig=CycleConfig.load(CONFIG.resolve("cycle-factions.json"));
session.tutorialConfig=TutorialConfig.load(CONFIG.resolve("tutorial.json"));
session.cycles=new CycleStore(world.resolve("data/sanctuary-cycle-factions.json"),server.overworld().getSeed());
if (AccessConfig.enabled()) session.access=new AccessLedger(world.resolve("data/sanctuary-access.json"),server.overworld().getSeed());
for (var event:session.cycles.events()) {
if (inhabitants.get(event.actor())==null || (!event.target().isEmpty() && inhabitants.get(UUID.fromString(event.target()))==null))
throw new java.io.IOException("Cycle journal references a missing inhabitant");
@@ -233,7 +249,10 @@ public final class ProgressionService {
private static final Type TYPE=new Type("sanctuary:hello_world");
private final ServerConfigurationPacketListenerImpl handler;
private final MinecraftServer server;
private boolean awaitingIntro,awaitingStarter;
private boolean awaitingIntro,awaitingStarter,admitted,busy;
private String lastChecked="",queuedCheck;
private AccessPayloads.Admit queuedAdmit;
private long lastCheckAt;
HelloTask(ServerConfigurationPacketListenerImpl handler,MinecraftServer server) { this.handler=handler;this.server=server; }
public Type type() { return TYPE; }
public void start(Consumer<Packet<?>> sender) {
@@ -255,6 +274,8 @@ public final class ProgressionService {
void offer(String error) {
var profile=((ProgressionConfigurationAccess)handler).sanctuary$profile();
var session=session(server);var inhabitant=session.inhabitants.get(profile.id());var starters=session.starters.get(profile.id());
if (session.access != null && ServerConfigurationNetworking.canSend(handler,AccessPayloads.Gate.TYPE))
ServerConfigurationNetworking.send(handler,new AccessPayloads.Gate(true,session.access.known(profile.id())!=null));
if(starters!=null){
var identity=new ProgressionPayloads.Hello(profile.name(),inhabitant==null?session.inhabitants.available():java.util.List.of(inhabitant.color()),error);
ServerConfigurationNetworking.send(handler,new StarterPayloads.Welcome(identity,inhabitant==null?"":inhabitant.bio(),inhabitant!=null,starters.choices()));
@@ -263,6 +284,7 @@ public final class ProgressionService {
void create(ProgressionPayloads.Create payload) {
if (awaitingIntro || awaitingStarter) return;
if (!handler.isAcceptingMessages()) { PENDING.remove(handler); return; }
if (AccessConfig.enabled() && !admitted) { offer("code_required"); return; }
Session session=session(server);
if (session == null || !session.healthy) { handler.disconnect(label("storage_invalid")); return; }
try {
@@ -288,6 +310,7 @@ public final class ProgressionService {
}
void createWithStarter(StarterPayloads.Create payload,String name) {
if(awaitingIntro||!handler.isAcceptingMessages())return;
if (AccessConfig.enabled() && !admitted) { offer("code_required"); return; }
var session=session(server);if(session==null||!session.healthy){handler.disconnect(label("storage_invalid"));return;}
try {
var profile=((ProgressionConfigurationAccess)handler).sanctuary$profile();var uuid=profile.id();
@@ -303,6 +326,94 @@ public final class ProgressionService {
awaitingStarter=false;beginIntro(uuid);
}catch(Exception error){fail(error);}
}
void check(String code) {
if (awaitingIntro || !handler.isAcceptingMessages()) return;
var session=session(server);
if (session == null || session.access == null) return;
var profile=((ProgressionConfigurationAccess)handler).sanctuary$profile();
String normalized=AccessCodes.normalize(code);
if (session.access.known(profile.id()) != null) { status(normalized,"code_recognized"); return; }
if (!AccessCodes.validFormat(normalized)) { status(normalized,"code_malformed"); return; }
if (!AccessCodes.validUsername(profile.name())) { status(normalized,"code_username"); return; }
if (busy) { queuedCheck=normalized; queuedAdmit=null; return; }
if (normalized.equals(lastChecked) && System.nanoTime()-lastCheckAt < 1_000_000_000L) return;
verify(profile.name(),normalized);
}
void verify(String username,String code) {
busy=true; lastChecked=code; lastCheckAt=System.nanoTime(); queuedCheck=null;
var directory=AccessConfig.directory();
java.util.concurrent.CompletableFuture.supplyAsync(() -> directory.verify(username,code)).whenComplete((result,error) -> server.execute(() -> {
busy=false;
if (PENDING.get(handler) != this || !handler.isAcceptingMessages()) return;
status(code, error != null || result == null ? "code_unreachable" : AccessCodes.verifyKey(result,username));
if (queuedAdmit != null) { var next=queuedAdmit; queuedAdmit=null; admit(next); return; }
if (queuedCheck != null && !queuedCheck.equals(code) && AccessCodes.validFormat(queuedCheck)) {
String next=queuedCheck; queuedCheck=null; verify(username,next);
}
}));
}
void status(String code,String reason) {
if (handler.isAcceptingMessages() && ServerConfigurationNetworking.canSend(handler,AccessPayloads.Status.TYPE))
ServerConfigurationNetworking.send(handler,new AccessPayloads.Status(code,reason));
}
void admit(AccessPayloads.Admit payload) {
if (awaitingIntro || !handler.isAcceptingMessages()) return;
String normalized=AccessCodes.normalize(payload.code());
if (busy) { queuedAdmit=payload; return; }
var session=session(server);
if (session == null || !session.healthy) { handler.disconnect(label("storage_invalid")); return; }
if (session.access == null) { offer("code_unconfigured"); return; }
var profile=((ProgressionConfigurationAccess)handler).sanctuary$profile();
var uuid=profile.id();
if (!ready(session,uuid,payload)) return;
if (session.access.known(uuid) != null) { finishAdmit(session,uuid,payload); drain(); return; }
if (!AccessCodes.validFormat(normalized)) { offer("code_malformed"); return; }
if (!AccessCodes.validUsername(profile.name())) { offer("code_username"); return; }
busy=true;
String username=profile.name();
var directory=AccessConfig.directory();
java.util.concurrent.CompletableFuture.supplyAsync(() -> directory.redeem(username,normalized)).whenComplete((result,error) -> server.execute(() -> {
busy=false;
if (PENDING.get(handler) != this || !handler.isAcceptingMessages()) return;
if (error != null || result == null) { offer("code_unreachable"); drain(); return; }
var decision=AccessCodes.redeem(result,username);
if (!decision.accepted()) { offer(decision.reason()); drain(); return; }
try {
session.access.remember(uuid,decision.minecraftUsername(),decision.discordId());
finishAdmit(session,uuid,payload);
} catch (Exception failure) { fail(failure); }
drain();
}));
}
boolean ready(Session session,UUID uuid,AccessPayloads.Admit payload) {
var starters=session.starters.get(uuid);
if (starters != null) {
if (starters.choices().stream().noneMatch(choice -> choice.mob().equals(payload.mob()))) { offer("starter_required"); return false; }
if (!StarterCompanions.validName(payload.name())) { offer("starter_name_invalid"); return false; }
} else if (!payload.mob().isEmpty()) { offer("starter_required"); return false; }
if (session.inhabitants.get(uuid) == null) {
if (!InhabitantStore.validBio(payload.bio())) { offer("bio_invalid"); return false; }
if (!session.inhabitants.available().contains(payload.color())) { offer("color_taken"); return false; }
}
return true;
}
void drain() {
if (!handler.isAcceptingMessages() || PENDING.get(handler) != this) return;
if (queuedAdmit != null) { var next=queuedAdmit; queuedAdmit=null; admit(next); }
else if (queuedCheck != null && AccessCodes.validFormat(queuedCheck)) {
String next=queuedCheck; queuedCheck=null;
verify(((ProgressionConfigurationAccess)handler).sanctuary$profile().name(),next);
}
}
void finishAdmit(Session session,UUID uuid,AccessPayloads.Admit payload) {
admitted=true;
try {
if (payload.mob().isEmpty()) create(new ProgressionPayloads.Create(payload.bio(),payload.color()));
else createWithStarter(new StarterPayloads.Create(payload.bio(),payload.color(),payload.mob()),payload.name());
} finally { admitted=false; }
try { if (session.inhabitants.get(uuid) != null) session.access.commit(uuid); }
catch (Exception error) { fail(error); }
}
void fail(Exception error){
session(server).healthy=false;LOG.error("Could not persist Hello World starter",error);handler.disconnect(label("storage_invalid"));
}
@@ -325,6 +436,7 @@ public final class ProgressionService {
if (!restore(player)) return;
sendNames(player,session);
try {
if (session.access != null) session.access.commit(player.getUUID());
StarterCompanions.deliver(player,session.starters);
if (session.inhabitants.arrive(player.getUUID())) player.level().getServer().getPlayerList()
.broadcastSystemMessage(Component.literal("helloworld ").append(player.getDisplayName()),false);
@@ -267,6 +267,21 @@
"sanctuary.progression.choose_color": "Choose your colour",
"sanctuary.progression.cancel": "Cancel",
"sanctuary.progression.enter": "Enter Sanctuary",
"sanctuary.progression.code": "Access code",
"sanctuary.progression.code_hint": "SANC-XXXX-XXXX",
"sanctuary.progression.code_recognized": "Code recognised.",
"sanctuary.progression.code_checking": "Checking the code…",
"sanctuary.progression.code_malformed": "The code should look like SANC-XXXX-XXXX.",
"sanctuary.progression.code_invalid": "This code does not match this name.",
"sanctuary.progression.code_used": "This code has already been used.",
"sanctuary.progression.code_replay": "This code is already used and Sanctuary cannot recover the account. Try again later.",
"sanctuary.progression.code_unreachable": "Sanctuary is unreachable, try again.",
"sanctuary.progression.code_unauthorized": "The server cannot reach Sanctuary.",
"sanctuary.progression.code_unconfigured": "The server has no Sanctuary token.",
"sanctuary.progression.code_required": "Enter the code sent in a private message.",
"sanctuary.progression.code_username": "This name cannot receive a code.",
"sanctuary.progression.code_mismatch": "The code does not match this session.",
"sanctuary.progression.access_version_required": "This server requires the Sanctuary access code. Update the client.",
"sanctuary.progression.joining": "Character saved, joining…",
"sanctuary.progression.bio_invalid": "Use plain text for your bio (256 characters).",
"sanctuary.progression.color_taken": "Colour just taken. Choose another one.",
@@ -2319,10 +2334,5 @@
"sanctuary.shaders.colored_light_distance_value": "Colored lights: %s blocks",
"sanctuary.shaders.colored_light_distance_help": "View distance: 8 to 64 blocks. Longer distances use more memory and computation. Smooth range fade; each source keeps its native reach.",
"sanctuary.shaders.warm_torches": "Warm torches",
"sanctuary.shaders.warm_torches_help": "Off: ordinary torches keep the native color temperature. On: orange tint. Other sources retain their own colors.",
"sanctuary.shaders.natural_water": "Natural water",
"sanctuary.shaders.natural_water_help": "Biome colour, clarity from the bed. Clear oceans. Also changes underwater visibility.",
"sanctuary.shaders.water_intensity": "Water intensity",
"sanctuary.shaders.water_intensity_help": "At 0%, keeps native rendering. At 100%, applies all three profiles.",
"sanctuary.shaders.water_intensity_value": "Water intensity: %s%%"
"sanctuary.shaders.warm_torches_help": "Off: ordinary torches keep the native color temperature. On: orange tint. Other sources retain their own colors."
}
@@ -267,6 +267,21 @@
"sanctuary.progression.choose_color": "Choisis ta couleur",
"sanctuary.progression.cancel": "Annuler",
"sanctuary.progression.enter": "Entrer dans Sanctuary",
"sanctuary.progression.code": "Code daccès",
"sanctuary.progression.code_hint": "SANC-XXXX-XXXX",
"sanctuary.progression.code_recognized": "Code reconnu.",
"sanctuary.progression.code_checking": "Vérification du code…",
"sanctuary.progression.code_malformed": "Le code doit ressembler à SANC-XXXX-XXXX.",
"sanctuary.progression.code_invalid": "Ce code ne correspond pas à ce pseudo.",
"sanctuary.progression.code_used": "Ce code a déjà été utilisé.",
"sanctuary.progression.code_replay": "Ce code est déjà consommé et Sanctuary ne peut pas retrouver le compte. Réessaie plus tard.",
"sanctuary.progression.code_unreachable": "Sanctuary est injoignable, réessaie.",
"sanctuary.progression.code_unauthorized": "Le serveur ne peut pas joindre Sanctuary.",
"sanctuary.progression.code_unconfigured": "Le serveur na pas de jeton Sanctuary.",
"sanctuary.progression.code_required": "Entre le code reçu en message privé.",
"sanctuary.progression.code_username": "Ce pseudo ne peut pas recevoir de code.",
"sanctuary.progression.code_mismatch": "Le code ne correspond pas à cette session.",
"sanctuary.progression.access_version_required": "Ce serveur exige le code daccès Sanctuary. Mets le client à jour.",
"sanctuary.progression.joining": "Création enregistrée, arrivée en cours…",
"sanctuary.progression.bio_invalid": "La bio doit contenir du texte simple (256 caractères).",
"sanctuary.progression.color_taken": "Couleur déjà prise. Choisis-en une autre.",
@@ -2319,10 +2334,5 @@
"sanctuary.shaders.colored_light_distance_value": "Lumières colorées : %s blocs",
"sanctuary.shaders.colored_light_distance_help": "Distance d'affichage : 8 à 64 blocs. Les grandes distances utilisent plus de mémoire et de calcul. Fondu aux limites ; la portée de chaque source reste native.",
"sanctuary.shaders.warm_torches": "Torches chaudes",
"sanctuary.shaders.warm_torches_help": "Désactivé : les torches ordinaires conservent la température de couleur native. Activé : teinte orangée. Les autres sources gardent leurs propres couleurs.",
"sanctuary.shaders.natural_water": "Eau naturelle",
"sanctuary.shaders.natural_water_help": "Couleur du biome, clarté selon le fond. Océans clairs. Modifie aussi la visibilité sous leau.",
"sanctuary.shaders.water_intensity": "Intensité de leau",
"sanctuary.shaders.water_intensity_help": "À 0 %, conserve le rendu natif. À 100 %, applique les trois profils.",
"sanctuary.shaders.water_intensity_value": "Intensité de leau: %s%%"
"sanctuary.shaders.warm_torches_help": "Désactivé : les torches ordinaires conservent la température de couleur native. Activé : teinte orangée. Les autres sources gardent leurs propres couleurs."
}
@@ -1,4 +0,0 @@
{
"light": ["minecraft:sand", "minecraft:red_sand", "minecraft:gravel", "minecraft:clay"],
"pond": ["minecraft:dirt", "minecraft:coarse_dirt", "minecraft:rooted_dirt", "minecraft:grass_block", "minecraft:podzol", "minecraft:mycelium", "minecraft:mud", "minecraft:muddy_mangrove_roots"]
}
@@ -278,12 +278,7 @@
"client.PixelShadowRendererMixin",
"client.PixelShadowLevelMixin",
"client.PixelShadowDirtyMixin",
"client.PreferredGraphicsApiMixin",
"client.NaturalWaterRegionMixin",
"client.NaturalWaterFluidMixin",
"client.NaturalWaterFogMixin",
"client.NaturalWaterFogColorMixin",
"client.NaturalWaterSectionCopyAccess"
"client.PreferredGraphicsApiMixin"
],
"injectors": {
"defaultRequire": 1
@@ -0,0 +1,48 @@
package fr.koka.sanctuary.progression;
import java.nio.file.Files;
import java.util.UUID;
public final class AccessCodeSmoke {
public static void main(String[] args) throws Exception {
require(AccessCodes.normalize(" sanc-ab3d-k7pq ").equals("SANC-AB3D-K7PQ"), "Codes are trimmed and uppercased");
require(AccessCodes.validFormat("SANC-AB3D-K7PQ"), "Documented code shape is accepted");
require(!AccessCodes.validFormat("SANC-AB3D-K7P") && !AccessCodes.validFormat("SANC-AB3D-K7PQX"), "Partial codes are refused");
require(AccessCodes.validUsername("Steve") && !AccessCodes.validUsername("A") && !AccessCodes.validUsername("bad name"), "Session names stay Minecraft names");
var unused = AccessCodes.parse(200, "{\"valid\":true,\"reason\":\"unused\",\"minecraft_username\":\"Steve\",\"discord_id\":\"123456789012345678\"}");
require(AccessCodes.verifyKey(unused, "steve").equals("code_recognized"), "Verify matches the session name without consuming the code");
require(AccessCodes.verifyKey(AccessCodes.parse(200, "{\"valid\":false,\"reason\":\"invalid\"}"), "Steve").equals("code_invalid"), "Unknown code stays on the screen");
require(AccessCodes.verifyKey(AccessCodes.parse(200, "{\"valid\":false,\"reason\":\"already_used\"}"), "Steve").equals("code_used"), "Used code is not a fresh verification");
require(AccessCodes.parse(401, "").reason().equals("unauthorized"), "A bad token is not a character");
require(AccessCodes.parse(503, "nope").reason().equals("unreachable"), "An outage does not invent a character");
var redeemed = AccessCodes.parse(200, "{\"valid\":true,\"reason\":\"redeemed\",\"minecraft_username\":\"Steve\",\"discord_id\":\"42\"}");
var decision = AccessCodes.redeem(redeemed, "Steve");
require(decision.accepted() && decision.discordId().equals("42"), "Redeem is the commitment");
require(!AccessCodes.redeem(redeemed, "Alex").accepted(), "Another username cannot use the code");
var replay = AccessCodes.parse(200, "{\"valid\":false,\"reason\":\"already_used\",\"minecraft_username\":\"Steve\",\"discord_id\":\"42\"}");
require(AccessCodes.redeem(replay, "Steve").accepted(), "A consumed code can finish an interrupted creation when the account comes back");
var stuck = AccessCodes.parse(200, "{\"valid\":false,\"reason\":\"already_used\"}");
require(AccessCodes.redeem(stuck, "Steve").reason().equals("code_replay"), "A consumed code without an account does not create anyone");
var path = Files.createTempDirectory("sanctuary-access-").resolve("access.json");
UUID id = UUID.fromString("01234567-89ab-cdef-fedc-ba9876543210");
var ledger = new AccessLedger(path, 42L);
ledger.remember(id, "Steve", "42");
var reopened = new AccessLedger(path, 42L);
require(reopened.known(id).discordId().equals("42"), "A crash after redeem keeps the Discord account");
reopened.commit(id);
var committed = new AccessLedger(path, 42L);
require(committed.known(id).name().equals("Steve"), "The binding survives the character write");
committed.commit(id);
committed.remember(id, "Steve", "42");
String saved = Files.readString(path);
require(saved.indexOf(id.toString()) == saved.lastIndexOf(id.toString()), "An existing character is not returned to pending");
byte[] broken = "{".getBytes();
Files.write(path, broken);
boolean refused = false;
try { new AccessLedger(path, 42L); } catch (IllegalArgumentException error) { refused = true; }
require(refused && Files.readAllBytes(path).length == 1, "A corrupt ledger is preserved and refused");
System.out.println("ACCESS-01: code contract, replay and ledger passed");
}
private static void require(boolean value, String message) { if (!value) throw new AssertionError(message); }
}
-30
View File
@@ -1,30 +0,0 @@
#!/usr/bin/env python3
"""Check the eight alternating water benchmark passages per scene.
Usage: python3 scripts/check_water_performance.py build/water144-vulkan.log
Writes the detailed receipt beside the log, and rejects incomplete/over-budget runs.
The caller must also verify that no other graphics test ran during measurement.
"""
import json,re,sys
from pathlib import Path
log_path=Path(sys.argv[1])
backend=log_path.stem
log=log_path.read_text()
rows={key:json.loads(data) for key,data in re.findall(r"WATER144_PERF (\S+) (\[[^\n]+\])",log)}
assert len(rows)==24, ("Incomplete measurements",len(rows))
assert log.count("WATER144_CONDITIONS limit=260 throttle=NONE")==24, "Measurements must be uncapped"
result={"backend":backend,"samples":rows,"scenes":{}}
for scene in ("mixed","large","moving"):
off=[v for k,v in rows.items() if k.startswith(scene+"-false-")]
on=[v for k,v in rows.items() if k.startswith(scene+"-true-")]
assert len(off)==len(on)==4 and all(v[0]>50 for v in off+on)
if scene=="moving":
assert all(v[3]>0 for v in off+on), "Moving scene must compile terrain sections"
median_off=sum(v[1] for v in off)/len(off);median_on=sum(v[1] for v in on)/len(on)
p95_off=sum(v[2] for v in off)/len(off);p95_on=sum(v[2] for v in on)/len(on)
value={"offMedianMs":median_off,"onMedianMs":median_on,"medianIncreasePercent":100*(median_on/median_off-1),"offP95Ms":p95_off,"onP95Ms":p95_on,"p95IncreaseMs":p95_on-p95_off}
value["passed"]=value["medianIncreasePercent"]<=5 and value["p95IncreaseMs"]<=2
result["scenes"][scene]=value
log_path.with_name(log_path.stem+"-performance.json").write_text(json.dumps(result,indent=2)+"\n")
print(json.dumps(result["scenes"],indent=2))
assert all(v["passed"] for v in result["scenes"].values()), "Performance acceptance not met"
-3
View File
@@ -8,6 +8,3 @@ ajoute les ombres portées pixélisées optionnelles, configurables depuis
Ce dossier reste réservé aux futurs packs externes. Leur ajout au manifeste
packwiz nécessitera version, URL, hash, licence et compatibilité vérifiée pour
la version exacte du jeu et du moteur Iris.
La beta.144 ajoute l[eau naturelle](../docs/natural-water-beta144.md) dans
le rendu natif : profils selon le fond, océans clairs et brume sous-marine.