Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8de569bbf1 | ||
|
|
580b2414da |
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## beta.144 — Code d’accès à la création
|
||||
|
||||
- Hello World demande le code `SANC-XXXX-XXXX` quand le serveur a l’URL et le jeton du site.
|
||||
- Le client ne contacte pas le site. Le serveur vérifie, puis consomme le code au moment de créer l’habitant.
|
||||
- Le lien Discord est enregistré à part. Une création interrompue après `redeemed` peut se terminer sans nouveau code si le compte est encore connu.
|
||||
- [Contrat](docs/access-code-beta144.md).
|
||||
|
||||
## beta.110 — Atelier d’argile inclus
|
||||
|
||||
- Une seule livraison réunit l’atelier d’argile, les statuaires et l’import GLB de beta.106 avec les œufs et la neige saisonnière de beta.107 à beta.109.
|
||||
|
||||
@@ -26,8 +26,8 @@ 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.142 est publiée et synchronisée dans l’instance Sanctuary Beta ;
|
||||
les 923 fichiers personnels suivis sont conservés jusqu'à la prochaine livraison.
|
||||
La beta.143 est publiée et synchronisée dans l’instance 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à
|
||||
l’atelier, les œufs et la neige saisonnière dans l’instance **Sanctuary Beta**.
|
||||
Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées.
|
||||
@@ -44,7 +44,9 @@ 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).
|
||||
[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
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# beta.144 — Code d’accè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 l’API, avec le jeton `SANCTUARY_API_TOKEN`.
|
||||
|
||||
Sans ces réglages, Hello World reste celui de beta.143 : biographie, couleur et
|
||||
familier, sans code. Dès que l’URL et le jeton sont présents, un nouveau
|
||||
personnage exige un code reconnu. Un habitant déjà enregistré entre sans écran.
|
||||
|
||||
## Réglage serveur
|
||||
|
||||
Variables d’environnement, ou fichier `config/sanctuary/access.json` si une
|
||||
variable manque :
|
||||
|
||||
```json
|
||||
{"appUrl":"https://exemple.sanctuary","token":"..."}
|
||||
```
|
||||
|
||||
`appUrl` est l’origine du site, sans barre finale. Le serveur appelle
|
||||
`POST {appUrl}/api/v1/access-codes/verify` puis `redeem`. Le jeton n’est pas
|
||||
écrit dans les logs, le client ou le pack. Une URL ou un jeton illisible ferme
|
||||
la création : aucun personnage n’est inventé hors ligne.
|
||||
|
||||
## Parcours
|
||||
|
||||
1. La whitelist laisse passer le pseudo. L’écran s’ouvre tant que l’UUID n’a pas
|
||||
d’habitant.
|
||||
2. Le joueur saisit le code. L’affichage du champ et de l’exemple utilise
|
||||
l’alphabet galactique standard (`minecraft:alt`, la police de la table
|
||||
d’enchantement). La valeur envoyée reste le texte tapé. Un format complet
|
||||
déclenche `verify`, pas chaque frappe. « Code reconnu » n’écrit rien.
|
||||
3. Confirmer envoie le code de la session, la biographie, la couleur et le
|
||||
familier. Le pseudo envoyé au site est celui de la session.
|
||||
4. `redeem` répond `redeemed` avec `discord_id` : le lien est enregistré, puis
|
||||
l’habitant est créé. Les connexions suivantes sautent l’écran.
|
||||
5. Erreur, site injoignable ou jeton refusé : le joueur reste sur Hello World.
|
||||
|
||||
Une déconnexion avant `redeemed` ne consomme pas le code. Si le site a répondu
|
||||
`redeemed` et que l’écriture de l’habitant est interrompue, le lien Discord
|
||||
reste dans `data/sanctuary-access.json` (`pending`, schéma 1, graine du monde).
|
||||
La confirmation suivante termine le personnage sans rappeler `redeem`.
|
||||
|
||||
Si le code est déjà consommé et que le site ne renvoie plus `discord_id`, le
|
||||
joueur reste bloqué avec un message de reprise. Le site doit alors renvoyer
|
||||
`minecraft_username` et `discord_id` pour le même pseudo. Le mod accepte cette
|
||||
réponse, que `valid` soit vrai ou faux, et finit la création.
|
||||
|
||||
Le registre des habitants ne change pas. Le Discord est un fichier à part.
|
||||
Un fichier illisible est conservé et refuse l’accueil.
|
||||
|
||||
## Limites
|
||||
|
||||
Le gel dans le monde n’est pas ajouté : Hello World reste avant l’entrée, comme
|
||||
aujourd’hui. Inventaire, commandes et dimensions ne sont pas accessibles tant
|
||||
que l’habitant n’existe pas. La whitelist RCON reste celle du site. Un pseudo
|
||||
ajouté à la main, sans code, voit l’écran sans pouvoir le valider.
|
||||
@@ -14,6 +14,19 @@ 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
|
||||
|
||||
@@ -54,3 +54,15 @@ Vérifié le 18 septembre 2026 sur macOS, Java 25.
|
||||
| `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
@@ -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.143
|
||||
pack_version=beta.143
|
||||
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
|
||||
|
||||
@@ -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,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; }
|
||||
}
|
||||
}
|
||||
+113
-1
@@ -50,6 +50,7 @@ public final class ProgressionService {
|
||||
public StarterCompanions.Store starters;
|
||||
public CycleConfig cycleConfig=CycleConfig.DEFAULT;
|
||||
public TutorialConfig tutorialConfig=TutorialConfig.DEFAULT;
|
||||
public AccessLedger access;
|
||||
Session(InhabitantStore inhabitants, ProgressionConfig config, MobNamesConfig names) {
|
||||
this.inhabitants=inhabitants; this.config=config; this.names=names;
|
||||
}
|
||||
@@ -61,6 +62,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.",
|
||||
|
||||
@@ -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 d’accès",
|
||||
"sanctuary.progression.code_hint": "SANC-XXXX-XXXX",
|
||||
"sanctuary.progression.code_recognized": "Code reconnu.",
|
||||
"sanctuary.progression.code_checking": "Vérification du code…",
|
||||
"sanctuary.progression.code_malformed": "Le code doit ressembler à SANC-XXXX-XXXX.",
|
||||
"sanctuary.progression.code_invalid": "Ce code ne correspond pas à ce pseudo.",
|
||||
"sanctuary.progression.code_used": "Ce code a déjà été utilisé.",
|
||||
"sanctuary.progression.code_replay": "Ce code est déjà consommé et Sanctuary ne peut pas retrouver le compte. Réessaie plus tard.",
|
||||
"sanctuary.progression.code_unreachable": "Sanctuary est injoignable, réessaie.",
|
||||
"sanctuary.progression.code_unauthorized": "Le serveur ne peut pas joindre Sanctuary.",
|
||||
"sanctuary.progression.code_unconfigured": "Le serveur n’a pas de jeton Sanctuary.",
|
||||
"sanctuary.progression.code_required": "Entre le code reçu en message privé.",
|
||||
"sanctuary.progression.code_username": "Ce pseudo ne peut pas recevoir de code.",
|
||||
"sanctuary.progression.code_mismatch": "Le code ne correspond pas à cette session.",
|
||||
"sanctuary.progression.access_version_required": "Ce serveur exige le code d’accès Sanctuary. Mets le client à jour.",
|
||||
"sanctuary.progression.joining": "Création enregistrée, arrivée en cours…",
|
||||
"sanctuary.progression.bio_invalid": "La bio doit contenir du texte simple (256 caractères).",
|
||||
"sanctuary.progression.color_taken": "Couleur déjà prise. Choisis-en une autre.",
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "beta.143"
|
||||
version = "beta.144"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
Reference in New Issue
Block a user