Compare commits

..
Author SHA1 Message Date
ChrisM-PekandCursor 8de569bbf1 Add access-code gate to Hello World creation (beta.144).
Build Sanctuary / build (push) Waiting to run
Build Sanctuary / build (pull_request) 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
koka 580b2414da Document beta.143 publication and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 12:44:49 +02:00
koka 97ff2b54cb Slow seasonal snow pacing in beta.143
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 12:41:27 +02:00
koka 59346d7f0a Document verified beta.142 publication and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 05:08:09 +02:00
23 changed files with 817 additions and 57 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.
+18 -5
View File
@@ -20,11 +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.142** utilise Vulkan comme moteur graphique par défaut,
stabilise les rafraîchissements du relief PBR et règle son intensité initiale
à 80 %. Les lectures de textures et calculs de reflets sont sécurisés contre
les résultats indéfinis. Le [ticket](docs/pbr-refresh-beta142.md) distingue les
corrections vérifiées du bruit signalé en mouvement sur le pilote de l'utilisateur.
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.
@@ -35,6 +38,16 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
l’étude de Xaero et des alternatives libres.
## beta.143 — Neige saisonnière en temps réel
Accumulation ralentie par un facteur 12 et fonte saisonnière ralentie par le
facteur naturel 72, tout en conservant le sampler natif des colonnes de
précipitation. Deux blocs de neige fondent en environ 2,7 jours réels en été,
5,4 au printemps et 11 en automne.
[Contrat et vérifications](docs/realtime-snow-melt-beta143.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.143/Sanctuary-beta.143.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.143/Sanctuary-Test-beta.143.mrpack).
## beta.142 — Vulkan par défaut et stabilité PBR
Moteur natif Vulkan prioritaire, relief conservé pendant les reconstructions,
+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.
+27
View File
@@ -14,6 +14,33 @@ 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.143 — Neige saisonnière en temps réel
Accumulation espacée par un facteur 12 et fonte par le facteur temps réel 72,
avec le sampler natif de précipitation conservé. Protections inchangées pour
la neige construite, les biomes froids, l'hiver, les profils neigeux et la
météo Vanilla. [Contrat et vérifications](realtime-snow-melt-beta143.md).
[Release beta.143](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.143)
publiée ; canal `bafff5e9fcdd95c5dd184953a34ada805e455797`. Deux synchronisations
isolées puis deux dans l'instance Sanctuary Beta réussies. Les 923 fichiers
personnels suivis gardent leurs hashes. Aucun monde personnel ouvert ;
sauvegarde préalable `sanctuary-backups/before-beta.143/`. Archives vérifiées
dans `sanctuary-beta/build/`.
## beta.142 — Vulkan par défaut et stabilité PBR
Vulkan natif prioritaire, rafraîchissements PBR stables et intensité initiale
80 %, calculs de reflets bornés. [Contrat et vérifications](pbr-refresh-beta142.md).
[Release beta.142](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.142)
publiée ; canal `d661260e2bbabb0326c86d6cc543daf986979596`. Deux synchronisations isolées
puis deux dans l'instance Sanctuary Beta réussies. Les 923 fichiers personnels
suivis gardent leurs hashes. Aucun monde personnel ouvert ; sauvegarde préalable
`sanctuary-backups/before-beta.142/`. Archives vérifiées dans `sanctuary-beta/build/`.
Un choix OpenGL explicite reste conservé : sélectionner Vulkan dans les réglages
vidéo et redémarrer pour changer ce choix. La capture Windows de bruit en
mouvement reste à reconfirmer sur son pilote ; les contrôles OpenGL/Vulkan Mac
et la correction du domaine numérique du reflet sont documentés dans le ticket.
## beta.141 — Direction des lumières pour les normales PBR
Directions locales mises en cache, sources portées, confiance réduite lorsque
+19
View File
@@ -152,3 +152,22 @@ SHA-256 du JAR : `0bc2da9df2ef004e6dc78932cfde7b31b7717c63c83ceed789d151177c41bc
- `Sanctuary-Test-beta.142.mrpack` : 10483672 octets ; SHA-256 `d56b81a4aea6649a40a3c49dedee904989943a518ad0ae7c520b808dd0f9b9a7`.
Reçu détaillé ignoré : `build/shader142-artifact.json`.
## Livraison réalisée
[Release beta.142](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.142)
publiée depuis `ea151dd5df8c7c5abd0107656f40c723efbf2ebe`. Canal stable :
`d661260e2bbabb0326c86d6cc543daf986979596`. Archives normale/Test, ZIP d'amorçage Prism et template
publiés avec vérification de leurs téléchargements ; tag et artefacts immuables.
Deux synchronisations isolées puis deux synchronisations de l'instance
**Sanctuary Beta** réussies. Les 923 fichiers personnels suivis ont conservé
leurs hashes, notamment mondes, réglages et packs personnels. Aucun monde
personnel ouvert. Sauvegarde préalable : `sanctuary-backups/before-beta.142/`.
L'instance locale conserve son réglage graphique natif « default », qui démarre
désormais avec Vulkan prioritaire. Un Windows explicitement réglé sur OpenGL
doit sélectionner Vulkan dans les réglages vidéo et redémarrer.
Archives locales également dans `sanctuary-beta/build/`. Le checkout de travail
historique modifié `sanctuary-beta` n'a pas été réinitialisé ; seules ces archives
ignorées y ont été déposées. Sources sur `codex/pbr-refresh-beta142`.
+68
View File
@@ -0,0 +1,68 @@
# SNOW-143 — Neige saisonnière en temps réel
Branche `codex/realtime-snow-melt-beta143`, socle beta.142. Minecraft 26.3 /
Java 25. Livraison beta.143.
## Comportement
L'accumulation et la fonte saisonnière conservent le sampler natif de
précipitation `ServerLevel.tickPrecipitation`. Sanctuary ne scanne donc toujours
pas les chunks et ne force aucun chargement de terrain. Le changement porte
seulement sur la probabilité des mutations de neige.
Pendant une chute de neige, Sanctuary ajoute désormais une couche une fois sur
12 échantillons natifs en moyenne. La neige reste visible pendant une session,
mais les colonnes ne montent plus instantanément. À `randomTickSpeed = 3`, une
couche apparaît en moyenne après environ 40 minutes de neige continue.
Pour la fonte, chaque tirage saisonnier est multiplié par `72`, le rapport
entre une journée réelle de 24 heures et une journée Minecraft de 20 minutes.
Une couche fond désormais en moyenne en 4 h 05 en été,
8 h 10 au printemps et 16 h 25 en automne. Avec le plafond actuel de deux blocs,
une colonne météo suivie met environ 2,7 jours réels à disparaître en été,
5,4 jours au printemps et 11 jours en automne. L'hiver ne fond toujours pas.
Les règles de protection restent celles de beta.109 : seule la neige déposée
par la météo Sanctuary depuis l'ajout du suivi `sanctuary:natural_snow_v1`
peut fondre. La neige construite, l'ancienne neige non suivie, les couches
manuelles, les biomes froids, les profils neigeux et la météo Vanilla restent
préservés. Aucun format de sauvegarde ne change.
## Vérifications
Vérifié le 18 septembre 2026 sur macOS, Java 25.
- `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuaryMelt109ClientTests=true -PsanctuaryClientNoVsync=true
-PsanctuaryQuickTests=true` : réussi en 57 s, avec le marqueur
`MELT109_NATIVE_PASS`. Le test couvre l'accumulation beta.108 ralentie,
la fonte couche par couche, la provenance persistante, les saisons, la
gamerule, la météo Vanilla, les profils neigeux, les biomes froids et le
rechargement de monde.
- `./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest` :
réussi en 2 min 31 s, 126 tâches. Le GameTest serveur dédié reste exclu
conformément au refus antérieur de son EULA ; les contrôles natifs utilisent
le serveur intégré.
- `python3 scripts/pack.py check` : versions, icône et index packwiz vérifiés.
- Exports MRpack produits avec `packwiz modrinth export`, intégrité ZIP vérifiée.
Les deux MRpack embarquent exactement le JAR Sanctuary construit.
| Artefact | Taille | SHA-256 |
| --- | ---: | --- |
| `sanctuary-beta.143.jar` | 11 323 665 octets | `fd4b663ac2b2a106624c42b09997db229cc14d6c39d44ae83079bc6aa9d39a08` |
| `Sanctuary-beta.143.mrpack` | 10 464 931 octets | `e55caaa41c5a3a2666ff5415846e18f44d504ac34b698f3d974091cfb52364c5` |
| `Sanctuary-Test-beta.143.mrpack` | 10 483 858 octets | `b14bca04c4a48771209d0f7e234ff9ef6b800eb236fb969a6f0dfcee5620dafc` |
| `Sanctuary-Template-beta.143.zip` | 4 354 153 octets | `071fd8da67d1d0224b89889a5fcbc4cd269673dddb7a52f89aa78802f154605a` |
## Publication
[Release beta.143](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.143)
publiée depuis le commit `97ff2b54cb63afe8fdd88bfa8f2ebb25dbe3b86d`.
Le canal packwiz stable pointe sur
`bafff5e9fcdd95c5dd184953a34ada805e455797`.
Deux synchronisations isolées, puis deux synchronisations dans l'instance
Sanctuary Beta ont réussi. Les 923 fichiers personnels suivis gardent leurs
hashes ; aucun monde personnel n'a été ouvert. Sauvegarde préalable :
`sanctuary-backups/before-beta.143/`.
+2 -2
View File
@@ -9,8 +9,8 @@ loom_version=1.17.20
fabric_api_version=0.160.5+26.3
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
mod_version=beta.142
pack_version=beta.142
mod_version=beta.144
pack_version=beta.144
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+8
View File
@@ -1137,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') }
}
@@ -12,6 +12,7 @@ import java.time.Instant;
import java.time.ZoneOffset;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BooleanSupplier;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
@@ -50,24 +51,29 @@ public final class Melt109ClientChecks implements FabricClientGameTest {
for(int x=-15;x<=15;x++)for(int z=-15;z<=15;z++)l.setBlockAndUpdate(new BlockPos(x,79,z),Blocks.STONE.defaultBlockState());
clock(l,"2026-07-10T12:00:00Z",SeasonalWeather.Season.SUMMER);
check(l.getGameRules().get(SeasonalSnowMelt.ENABLED),"Melt enabled by default");
check(SeasonalSnowMelt.REALTIME_SCALE==72,"Real-time snow melt scale is one real day per 72 native days");
check(SeasonalSnowMelt.chance(SeasonalWeather.Season.SUMMER)==72
&&SeasonalSnowMelt.chance(SeasonalWeather.Season.SPRING)==144
&&SeasonalSnowMelt.chance(SeasonalWeather.Season.AUTUMN)==288
&&SeasonalSnowMelt.chance(SeasonalWeather.Season.WINTER)==0,"Seasonal melt chances include the 72x real-time scale");
deposit(l,AT,16);check(l.getBlockState(AT.above()).is(Blocks.SNOW_BLOCK),"Default two-block accumulation retained");
clear(l);step(l,AT,1);
check(layers(l,AT.above())==7&&layers(l,AT)==8,"Summer melts only one top layer");
step(l,AT,7);check(l.getBlockState(AT.above()).isAir()&&layers(l,AT)==8,"Top block melts completely before the lower block");
step(l,AT,8);check(l.getBlockState(AT).isAir()&&!tracked(l,AT),"Natural column vanishes without water; receipt removed");
clear(l);
until(l,AT,5000,()->layers(l,AT.above())==7&&layers(l,AT)==8,"Summer melts only one top layer");
until(l,AT,20000,()->l.getBlockState(AT.above()).isAir()&&layers(l,AT)==8,"Top block melts completely before the lower block");
until(l,AT,20000,()->l.getBlockState(AT).isAir()&&!tracked(l,AT),"Natural column vanishes without water; receipt removed");
var built=new BlockPos(7,80,4);l.setBlockAndUpdate(built,Blocks.SNOW_BLOCK.defaultBlockState());step(l,built,64);check(layers(l,built)==8,"Untracked snow block preserved");
var old=new BlockPos(8,80,4);snow(l,old,5);step(l,old,64);check(layers(l,old)==5,"Untracked old layers preserved");
var base=new BlockPos(9,80,4);snow(l,base,3);deposit(l,base,5);clear(l);step(l,base,16);check(layers(l,base)==3&&!tracked(l,base),"Weather above manual layers melts only back to its base");
var atop=new BlockPos(9,80,6);l.setBlockAndUpdate(atop,Blocks.SNOW_BLOCK.defaultBlockState());deposit(l,atop,8);clear(l);step(l,atop,16);check(layers(l,atop)==8&&l.getBlockState(atop.above()).isAir(),"Weather on a snow construction melts; construction stays");
var base=new BlockPos(9,80,4);snow(l,base,3);deposit(l,base,5);clear(l);until(l,base,20000,()->layers(l,base)==3&&!tracked(l,base),"Weather above manual layers melts only back to its base");
var atop=new BlockPos(9,80,6);l.setBlockAndUpdate(atop,Blocks.SNOW_BLOCK.defaultBlockState());deposit(l,atop,8);clear(l);until(l,atop,20000,()->layers(l,atop)==8&&l.getBlockState(atop.above()).isAir(),"Weather on a snow construction melts; construction stays");
var replaced=new BlockPos(10,80,4);deposit(l,replaced,8);l.setBlockAndUpdate(replaced,Blocks.AIR.defaultBlockState());l.setBlockAndUpdate(replaced,Blocks.SNOW_BLOCK.defaultBlockState());clear(l);step(l,replaced,32);check(layers(l,replaced)==8&&!tracked(l,replaced),"Break and replacement cannot inherit melt permission");
var thickened=new BlockPos(10,80,6);deposit(l,thickened,2);snow(l,thickened,6);clear(l);step(l,thickened,32);check(layers(l,thickened)==6&&!tracked(l,thickened),"Manual layer edit relinquishes natural provenance");
var capped=new BlockPos(12,80,6);deposit(l,capped,8);snow(l,capped.above(),1);clear(l);step(l,capped,32);check(layers(l,capped)==8&&layers(l,capped.above())==1,"Manual thin snow cap protects its natural support");
var roof=new BlockPos(11,80,4);deposit(l,roof,8);l.setBlockAndUpdate(roof.above(3),Blocks.STONE.defaultBlockState());clear(l);step(l,roof,32);check(layers(l,roof)==8,"Covered snow remains untouched");l.setBlockAndUpdate(roof.above(3),Blocks.AIR.defaultBlockState());step(l,roof,1);check(layers(l,roof)==7,"Uncovered natural snow resumes melt");
var roof=new BlockPos(11,80,4);deposit(l,roof,8);l.setBlockAndUpdate(roof.above(3),Blocks.STONE.defaultBlockState());clear(l);step(l,roof,32);check(layers(l,roof)==8,"Covered snow remains untouched");l.setBlockAndUpdate(roof.above(3),Blocks.AIR.defaultBlockState());until(l,roof,10000,()->layers(l,roof)==7,"Uncovered natural snow resumes melt");
command(l,"gamerule sanctuary:seasonal_snow_melt false");step(l,roof,32);check(layers(l,roof)==7,"Operator disables melting");command(l,"gamerule sanctuary:seasonal_snow_melt true");
clock(l,"2026-01-10T12:00:00Z",SeasonalWeather.Season.WINTER);clear(l);step(l,roof,64);check(layers(l,roof)==7,"Winter never melts even with clear weather");
clock(l,"2026-04-10T12:00:00Z",SeasonalWeather.Season.SPRING);clear(l);step(l,roof,64);check(l.getBlockState(roof).isAir(),"Spring progressively melts");
deposit(l,roof,8);clock(l,"2026-10-10T12:00:00Z",SeasonalWeather.Season.AUTUMN);clear(l);step(l,roof,128);check(l.getBlockState(roof).isAir(),"Autumn progressively melts");
clock(l,"2026-04-10T12:00:00Z",SeasonalWeather.Season.SPRING);clear(l);until(l,roof,30000,()->l.getBlockState(roof).isAir(),"Spring progressively melts");
deposit(l,roof,8);clock(l,"2026-10-10T12:00:00Z",SeasonalWeather.Season.AUTUMN);clear(l);until(l,roof,50000,()->l.getBlockState(roof).isAir(),"Autumn progressively melts");
deposit(l,roof,8);clock(l,"2026-07-10T12:00:00Z",SeasonalWeather.Season.SUMMER);
command(l,"sanctuary weather snow");l.getWeatherData().setRaining(false);l.setRainLevel(0);step(l,roof,64);check(layers(l,roof)==8,"Forced summer snowfall blocks melting");
command(l,"sanctuary weather snow_showers");l.getWeatherData().setRaining(false);l.setRainLevel(0);step(l,roof,64);check(layers(l,roof)==8,"Pauses in snow showers also block melting");
@@ -84,7 +90,7 @@ public final class Melt109ClientChecks implements FabricClientGameTest {
var l=server.overworld();clock(l,"2026-07-10T12:00:00Z",SeasonalWeather.Season.SUMMER);clear(l);
check(!l.getGameRules().get(SeasonalSnowMelt.ENABLED),"Melt rule persists across restart");
check(layers(l,PERSIST)==8&&layers(l,PERSIST.above())==8&&tracked(l,PERSIST)&&tracked(l,PERSIST.above()),"Chunk attachment survives native save/reload");
command(l,"gamerule sanctuary:seasonal_snow_melt true");step(l,PERSIST,16);
command(l,"gamerule sanctuary:seasonal_snow_melt true");until(l,PERSIST,30000,()->l.getBlockState(PERSIST).isAir()&&l.getBlockState(PERSIST.above()).isAir()&&!tracked(l,PERSIST), "Loaded natural snow melts and clears provenance");
check(l.getBlockState(PERSIST).isAir()&&l.getBlockState(PERSIST.above()).isAir()&&!tracked(l,PERSIST),"Loaded natural snow melts and clears provenance: base="+l.getBlockState(PERSIST)+", top="+l.getBlockState(PERSIST.above())+", sky="+l.canSeeSky(PERSIST.above(2))+", active="+WeatherService.active(l)+", biome="+l.getBiome(PERSIST)+", season="+WeatherService.season(server));
check(layers(l,new BlockPos(7,80,4))==8&&layers(l,new BlockPos(9,80,4))==3,"Built and preexisting snow preserved across restart");
});
@@ -92,13 +98,15 @@ public final class Melt109ClientChecks implements FabricClientGameTest {
System.out.println("MELT109_NATIVE_PASS: accumulation regression, top-down melt, seasons, profiles, cold biomes, constructions, edit invalidation, gamerule, native chunk save/reload");
}finally{intro.set(false);}
}
private static void deposit(ServerLevel l,BlockPos pos,int count){l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,1,l.getServer());command(l,"sanctuary weather snow");l.getWeatherData().setRaining(true);l.setRainLevel(1);step(l,pos,count);l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,0,l.getServer());}
private static void deposit(ServerLevel l,BlockPos pos,int count){l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,1,l.getServer());command(l,"sanctuary weather snow");l.getWeatherData().setRaining(true);l.setRainLevel(1);int target=depth(l,pos)+count;until(l,pos,Math.max(1000,count*200),()->depth(l,pos)>=target,"Weather snow reaches depth "+target+" at "+pos);l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,0,l.getServer());}
private static void clear(ServerLevel l){command(l,"sanctuary weather clear");l.getWeatherData().setRaining(false);l.setRainLevel(0);}
private static void clock(ServerLevel l,String time,SeasonalWeather.Season expected){try{var field=RealtimeService.Session.class.getDeclaredField("clock");field.setAccessible(true);field.set(RealtimeService.session(l.getServer()),Clock.fixed(Instant.parse(time),ZoneOffset.UTC));check(WeatherService.season(l.getServer())==expected,"Test calendar: "+expected);}catch(ReflectiveOperationException e){throw new AssertionError(e);}}
private static boolean tracked(ServerLevel l,BlockPos p){var m=l.getChunkAt(p).getAttached(SeasonalSnowMelt.NATURAL);return m!=null&&m.containsKey(Long.toString(p.asLong()));}
private static void snow(ServerLevel l,BlockPos p,int layers){l.setBlockAndUpdate(p,Blocks.SNOW.defaultBlockState().setValue(SnowLayerBlock.LAYERS,layers));}
private static int layers(ServerLevel l,BlockPos p){return SeasonalSnowMelt.layers(l.getBlockState(p));}
private static int depth(ServerLevel l,BlockPos p){int total=0;for(int i=0;i<32;i++){int layers=layers(l,p.above(i));if(layers==0)break;total+=layers;}return total;}
private static void step(ServerLevel l,BlockPos p,int n){for(int i=0;i<n;i++)l.tickPrecipitation(p);}
private static void until(ServerLevel l,BlockPos p,int max,BooleanSupplier condition,String message){for(int i=0;i<max&&!condition.getAsBoolean();i++)l.tickPrecipitation(p);check(condition.getAsBoolean(),message);}
private static void command(ServerLevel l,String s){l.getServer().getCommands().performPrefixedCommand(l.getServer().createCommandSourceStack(),s);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
@@ -6,6 +6,7 @@ import fr.koka.sanctuary.progression.SanctuaryGameplay;
import fr.koka.sanctuary.weather.SnowAccumulation;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BooleanSupplier;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
@@ -45,42 +46,45 @@ public final class Snow108ClientChecks implements FabricClientGameTest {
if(enabled) {
command(l,"sanctuary weather snow");l.setRainLevel(1);
check(l.getGameRules().get(SnowAccumulation.MAX_HEIGHT)==2,"Default two blocks");
for(int i=1;i<=7;i++){l.tickPrecipitation(AT);check(layers(l,AT)==i,"One layer per precipitation event: "+i);}
check(SnowAccumulation.REALTIME_SCALE==12,"Snow accumulation uses the 12x real-time arrival scale");
for(int i=1;i<=7;i++){int expected=i;until(l,AT,1000,()->layers(l,AT)==expected,"Layer "+i+" arrives through the native sampler");}
var pig=EntityTypes.PIG.create(l,EntitySpawnReason.COMMAND);pig.setNoAi(true);pig.setNoGravity(true);pig.setPos(4.5,80.75,4.5);l.addFreshEntity(pig);
l.tickPrecipitation(AT);check(l.getBlockState(AT).is(Blocks.SNOW_BLOCK),"Eighth layer becomes solid snow block");
until(l,AT,1000,()->l.getBlockState(AT).is(Blocks.SNOW_BLOCK),"Eighth layer becomes solid snow block");
check(l.getBlockState(AT).getCollisionShape(l,AT,CollisionContext.empty()).bounds().maxY==1,"Full block collision");
check(pig.getY()>=81,"Growth lifts a standing entity");pig.discard();
l.tickPrecipitation(AT);check(layers(l,AT.above())==1,"New layer begins above solid snow");
for(int i=0;i<30;i++)l.tickPrecipitation(AT);
until(l,AT,1000,()->layers(l,AT.above())==1,"New layer begins above solid snow");
until(l,AT,2000,()->l.getBlockState(AT.above()).is(Blocks.SNOW_BLOCK),"Default cap fills a second block");
check(l.getBlockState(AT.above()).is(Blocks.SNOW_BLOCK)&&l.getBlockState(AT.above(2)).isAir(),"Default cap at two blocks");
command(l,"gamerule sanctuary:snow_accumulation_height 4");
for(int i=0;i<30;i++)l.tickPrecipitation(AT);
until(l,AT,5000,()->l.getBlockState(AT.above(3)).is(Blocks.SNOW_BLOCK),"Raised cap fills the fourth block");
check(l.getBlockState(AT.above(3)).is(Blocks.SNOW_BLOCK)&&l.getBlockState(AT.above(4)).isAir(),"Operator raises cap to four blocks");
command(l,"gamerule sanctuary:snow_accumulation_height -1");
for(int i=0;i<16;i++)l.tickPrecipitation(AT);
until(l,AT,3000,()->l.getBlockState(AT.above(5)).is(Blocks.SNOW_BLOCK),"Unlimited mode fills beyond the cap");
check(l.getBlockState(AT.above(5)).is(Blocks.SNOW_BLOCK),"Unlimited mode continues upward");
var fresh=new BlockPos(-5,80,-5);command(l,"gamerule sanctuary:snow_accumulation_height 0");l.tickPrecipitation(fresh);check(l.getBlockState(fresh).isAir(),"Sanctuary zero stops deposits");
command(l,"gamerule sanctuary:snow_accumulation_height 2");l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,0,server);l.tickPrecipitation(fresh);check(l.getBlockState(fresh).isAir(),"Native zero remains authoritative");l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,1,server);
l.setRainLevel(0);l.getWeatherData().setRaining(false);l.tickPrecipitation(fresh);check(l.getBlockState(fresh).isAir(),"No accumulation between showers");
command(l,"sanctuary weather snow_showers");l.setRainLevel(1);l.getWeatherData().setRaining(true);l.tickPrecipitation(fresh);check(layers(l,fresh)==1,"Snow showers accumulate while falling");
var flower=new BlockPos(-6,80,-6);l.setBlockAndUpdate(flower.below(),Blocks.GRASS_BLOCK.defaultBlockState());l.setBlockAndUpdate(flower,Blocks.DANDELION.defaultBlockState());l.tickPrecipitation(flower);check(l.getBlockState(flower).is(Blocks.DANDELION),"Plant preserved");
var water=new BlockPos(-7,79,-7);l.setBlockAndUpdate(water,Blocks.WATER.defaultBlockState());l.tickPrecipitation(water);check(l.getBlockState(water).is(Blocks.WATER)&&l.getBlockState(water.above()).isAir(),"Open water not filled with snow");
var roof=new BlockPos(7,83,-7);l.setBlockAndUpdate(roof,Blocks.STONE.defaultBlockState());l.tickPrecipitation(roof);check(layers(l,roof.above())==1&&l.getBlockState(roof.below()).isAir(),"Snow on roof, not indoors");
var fresh=new BlockPos(-5,80,-5);command(l,"gamerule sanctuary:snow_accumulation_height 0");step(l,fresh,200);check(l.getBlockState(fresh).isAir(),"Sanctuary zero stops deposits");
command(l,"gamerule sanctuary:snow_accumulation_height 2");l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,0,server);step(l,fresh,200);check(l.getBlockState(fresh).isAir(),"Native zero remains authoritative");l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,1,server);
l.setRainLevel(0);l.getWeatherData().setRaining(false);step(l,fresh,200);check(l.getBlockState(fresh).isAir(),"No accumulation between showers");
command(l,"sanctuary weather snow_showers");l.setRainLevel(1);l.getWeatherData().setRaining(true);until(l,fresh,1000,()->layers(l,fresh)==1,"Snow showers accumulate while falling");
var flower=new BlockPos(-6,80,-6);l.setBlockAndUpdate(flower.below(),Blocks.GRASS_BLOCK.defaultBlockState());l.setBlockAndUpdate(flower,Blocks.DANDELION.defaultBlockState());step(l,flower,200);check(l.getBlockState(flower).is(Blocks.DANDELION),"Plant preserved");
var water=new BlockPos(-7,79,-7);l.setBlockAndUpdate(water,Blocks.WATER.defaultBlockState());step(l,water,200);check(l.getBlockState(water).is(Blocks.WATER)&&l.getBlockState(water.above()).isAir(),"Open water not filled with snow");
var roof=new BlockPos(7,83,-7);l.setBlockAndUpdate(roof,Blocks.STONE.defaultBlockState());until(l,roof,1000,()->layers(l,roof.above())==1,"Snow on roof arrives");check(layers(l,roof.above())==1&&l.getBlockState(roof.below()).isAir(),"Snow on roof, not indoors");
var cauldron=new BlockPos(8,80,-8);l.setBlockAndUpdate(cauldron,Blocks.CAULDRON.defaultBlockState());for(int i=0;i<200;i++)l.tickPrecipitation(cauldron);check(l.getBlockState(cauldron).is(Blocks.POWDER_SNOW_CAULDRON),"Native cauldron snow filling preserved");
command(l,"sanctuary weather rain");l.setRainLevel(1);var rainy=new BlockPos(10,80,0);l.tickPrecipitation(rainy);check(l.getBlockState(rainy).isAir(),"Temperate rain is not snow");
command(l,"sanctuary weather vanilla");cold(l);for(int i=0;i<8;i++)l.tickPrecipitation(new BlockPos(0,80,0));check(l.getBlockState(new BlockPos(0,80,0)).is(Blocks.SNOW_BLOCK),"Cold biome also accumulates with vanilla weather");
command(l,"sanctuary weather vanilla");cold(l);until(l,new BlockPos(0,80,0),2000,()->l.getBlockState(new BlockPos(0,80,0)).is(Blocks.SNOW_BLOCK),"Cold biome also accumulates with vanilla weather");
var light=new BlockPos(11,80,11);l.setBlockAndUpdate(light.below(),Blocks.GLOWSTONE.defaultBlockState());command(l,"sanctuary weather snow");l.setRainLevel(1);
} else {
cold(l);for(int i=0;i<40;i++)l.tickPrecipitation(AT);check(layers(l,AT)==1&&l.getBlockState(AT.above()).isAir(),"Non-Sanctuary world keeps native one-layer limit");
}
});
if(enabled){c.waitTicks(8);world.getServer().runOnServer(s->{var l=s.overworld();var light=new BlockPos(11,80,11);check(l.getBrightness(LightLayer.BLOCK,light)>=10,"Test light propagated");for(int i=0;i<16;i++)l.tickPrecipitation(light);check(l.getBlockState(light).isAir(),"Bright surfaces do not accumulate");});}
if(enabled){c.waitTicks(8);world.getServer().runOnServer(s->{var l=s.overworld();var light=new BlockPos(11,80,11);check(l.getBrightness(LightLayer.BLOCK,light)>=10,"Test light propagated");step(l,light,400);check(l.getBlockState(light).isAir(),"Bright surfaces do not accumulate");});}
}
System.out.println("SNOW108_NATIVE_PASS: layers, solid blocks, vertical stacking, entity lift, caps and commands, weather, light, supports, cauldron and vanilla world");
}finally{intro.set(false);}
}
private static void cold(ServerLevel l){command(l,"fillbiome -8 76 -8 8 88 8 minecraft:snowy_plains");l.getWeatherData().setRaining(true);l.setRainLevel(1);}
private static int layers(ServerLevel l,BlockPos p){var s=l.getBlockState(p);return s.is(Blocks.SNOW)?s.getValue(SnowLayerBlock.LAYERS):0;}
private static void step(ServerLevel l,BlockPos p,int n){for(int i=0;i<n;i++)l.tickPrecipitation(p);}
private static void until(ServerLevel l,BlockPos p,int max,BooleanSupplier condition,String message){for(int i=0;i<max&&!condition.getAsBoolean();i++)l.tickPrecipitation(p);check(condition.getAsBoolean(),message);}
private static void command(ServerLevel l,String s){l.getServer().getCommands().performPrefixedCommand(l.getServer().createCommandSourceStack(),s);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
@@ -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;
}
}
@@ -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);
@@ -21,6 +21,9 @@ import net.minecraft.world.level.levelgen.Heightmap;
/** Melt only known weather deposits, using the existing precipitation column sampler. */
public final class SeasonalSnowMelt {
/** One real day lasts 72 native Minecraft days. Keep the native sampler, slow only its melt chance. */
public static final int REALTIME_SCALE = 72;
public static final GameRule<Boolean> ENABLED = GameRuleBuilder.forBoolean(true)
.category(GameRuleCategory.UPDATES).buildAndRegister(SanctuaryMod.id("seasonal_snow_melt"));
@@ -77,12 +80,7 @@ public final class SeasonalSnowMelt {
var chunk = level.getChunkAt(column);
var entries = chunk.getAttached(NATURAL);
if (entries == null || entries.isEmpty()) return;
int chance = switch (WeatherService.season(level.getServer())) {
case WINTER -> 0;
case SPRING -> 2;
case SUMMER -> 1;
case AUTUMN -> 4;
};
int chance = chance(WeatherService.season(level.getServer()));
if (chance == 0 || level.getRandom().nextInt(chance) != 0) return;
var pos = level.getHeightmapPos(Heightmap.Types.MOTION_BLOCKING, column);
// Thin layers are above the heightmap; a full snow block is just below it.
@@ -103,5 +101,15 @@ public final class SeasonalSnowMelt {
if (level.setBlockAndUpdate(pos, after)) remember(level, pos, entry.base, after);
}
public static int chance(SeasonalWeather.Season season) {
int seasonal = switch (season) {
case WINTER -> 0;
case SPRING -> 2;
case SUMMER -> 1;
case AUTUMN -> 4;
};
return seasonal == 0 ? 0 : seasonal * REALTIME_SCALE;
}
private static String key(BlockPos pos) { return Long.toString(pos.asLong()); }
}
@@ -16,8 +16,11 @@ import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.gamerules.GameRule;
import net.minecraft.world.level.gamerules.GameRuleCategory;
/** One real layer per native precipitation tick; no additional chunk or entity scan. */
/** Weather snow accumulation, using the native precipitation sampler without chunk scans. */
public final class SnowAccumulation {
/** Snowfall should remain visible during a session, but no longer stack instantly. */
public static final int REALTIME_SCALE = 12;
public static final GameRule<Integer> MAX_HEIGHT = GameRuleBuilder.forInteger(2)
.range(-1, 128).category(GameRuleCategory.UPDATES)
.buildAndRegister(SanctuaryMod.id("snow_accumulation_height"));
@@ -38,7 +41,7 @@ public final class SnowAccumulation {
/** Return whether vanilla should perform its own deposit. */
public static boolean replaceNative(ServerLevel level, BlockPos pos, boolean canSnow) {
if (!canSnow || !ENABLED.contains(level.getServer())) return canSnow;
if (canSnow) deposit(level, pos);
if (level.getRandom().nextInt(REALTIME_SCALE) == 0) deposit(level, pos);
return 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.",
@@ -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.",
@@ -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); }
}
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.142"
version = "beta.144"
pack-format = "packwiz:1.1.0"
[index]