Synchronize verified Sanctuary beta.092 sources and release documentation
Build Sanctuary / build (push) Canceled after 0s

This commit is contained in:
koka
2026-09-16 11:41:27 +02:00
parent 85e062ed91
commit 49feb5a376
586 changed files with 30916 additions and 234 deletions
+8 -4
View File
@@ -1,9 +1,9 @@
# Just Enough Items — Sanctuary preview
# Just Enough Items — portage Sanctuary
Source officielle : https://github.com/mezz/JustEnoughItems/tree/26.2
Commit : `aae2dfcfb82e6b5bce787ba72bb2eda7339b274f`.
Version source 30.32.0 ; fork `30.32.0-sanctuary.2` pour **Minecraft 26.3-pre-2**
(Fabric normalise ce nom en `26.3-pre.2`), Loader 0.19.5, API 0.160.0+26.3, Java 25.
Version source 30.32.0 ; fork `30.32.0-sanctuary.3` pour **Minecraft 26.3**
(finale), Loader 0.19.5, API 0.160.5+26.3, Java 25.
Licence MIT, copyright 20142015 mezz. `LICENSE.txt` est inclus sans modification.
Le fork adapte les entrées SDL, les pipelines RenderPearl, les recettes devenues
@@ -37,9 +37,13 @@ Le mod JEI est imbriqué dans le JAR Sanctuary avec deux dépendances :
Amecs 26.2 sert uniquement à compiler le point dintégration optionnel historique ;
il nest ni embarqué ni annoncé compatible 26.3. Le client vérifié utilise les touches
natives sans Amecs. Les fichiers de licences des dépendances sont ajoutés au JAR JEI.
Ce fork expérimental ne revendique pas une validation upstream de cette préversion.
Ce fork expérimental ne revendique pas une validation upstream de ce portage.
La révision Sanctuary.2 retire le bouton de configuration du catalogue lorsque
Sanctuary le demande (dessin, infobulle et interactions), et adapte les libellés
FR/EN des favoris, de lhistorique et des raccourcis. Les attributions, identifiants
JEI, formats de réglages et mécanismes de recettes restent conservés.
La révision Sanctuary.3 cible Minecraft 26.3 finale et Fabric API 0.160.5+26.3.
La version Minecraft et les dépendances sont désormais reprises des propriétés
communes du projet ; le comportement du catalogue Sanctuary est conservé.
+6 -6
View File
@@ -1,7 +1,7 @@
plugins { id 'net.fabricmc.fabric-loom' }
version='30.32.0-sanctuary.2'
version=rootProject.jei_version
group='mezz.jei'
base { archivesName='jei-sanctuary-26.3-pre-2' }
base { archivesName="jei-sanctuary-${rootProject.minecraft_version}" }
repositories { mavenCentral(); maven { url='https://maven.siphalor.de/' }; maven { url='https://maven.blamejared.com/' } }
providers.exec { workingDir rootProject.projectDir; commandLine 'python3', 'scripts/prepare_jei.py' }.result.get()
def upstream=rootProject.file('build/jei-fork')
@@ -9,9 +9,9 @@ sourceSets.main.java.setSrcDirs(['Common/src/main/java','Common/src/api/java','F
sourceSets.main.resources.setSrcDirs(['Common/src/main/resources','Fabric/src/main/resources','Library/src/main/resources','Gui/src/main/resources'].collect{new File(upstream,it)})
loom { accessWidenerPath=new File(upstream,'Fabric/src/main/resources/jei.accesswidener') }
dependencies {
minecraft 'com.mojang:minecraft:26.3-pre-2'
implementation 'net.fabricmc:fabric-loader:0.19.5'
implementation 'net.fabricmc.fabric-api:fabric-api:0.160.0+26.3'
minecraft "com.mojang:minecraft:${rootProject.minecraft_version}"
implementation "net.fabricmc:fabric-loader:${rootProject.loader_version}"
implementation "net.fabricmc.fabric-api:fabric-api:${rootProject.fabric_api_version}"
implementation 'org.jetbrains:annotations:26.0.2'
implementation 'org.jspecify:jspecify:1.0.0'
implementation 'net.mezzdev:baked-substring-index:0.1.0'
@@ -23,7 +23,7 @@ dependencies {
java { toolchain { languageVersion=JavaLanguageVersion.of(25) } }
tasks.withType(JavaCompile).configureEach { options.encoding='UTF-8'; options.compilerArgs+=['-Xmaxerrs','300'] }
processResources {
def values=[modId:'jei', version:project.version, modName:'Just Enough Items — Sanctuary preview', modDescription:'Experimental source port for Sanctuary, Minecraft 26.3-pre-2.', modAuthor:'mezz', curseHomepageUrl:'https://www.curseforge.com/minecraft/mc-mods/jei', githubUrl:'https://github.com/mezz/JustEnoughItems', fabricLoaderVersionRange:'>=0.19.5', fabricApiVersionRange:'>=0.160.0+26.3', modJavaVersion:'25', amecsVersionFabric:'1.0.2']
def values=[modId:'jei', version:project.version, modName:'Just Enough Items — Sanctuary preview', modDescription:"Source port for Sanctuary, Minecraft ${rootProject.minecraft_version}.", minecraftVersion:rootProject.minecraft_version, modAuthor:'mezz', curseHomepageUrl:'https://www.curseforge.com/minecraft/mc-mods/jei', githubUrl:'https://github.com/mezz/JustEnoughItems', fabricLoaderVersionRange:">=${rootProject.loader_version}", fabricApiVersionRange:">=${rootProject.fabric_api_version}", modJavaVersion:'25', amecsVersionFabric:'1.0.2']
inputs.properties(values)
filesMatching('fabric.mod.json') { expand(values) }
duplicatesStrategy=DuplicatesStrategy.EXCLUDE
+1 -1
View File
@@ -907,7 +907,7 @@
"accessWidener" : "jei.accesswidener",
"depends": {
+ "minecraft": "26.3-pre.2",
+ "minecraft": "${minecraftVersion}",
"fabricloader": "${fabricLoaderVersionRange}",
"fabric-api": "${fabricApiVersionRange}",
"java": ">=${modJavaVersion}"
+87 -3
View File
@@ -95,7 +95,7 @@ tasks.named('runGameTest') {
tasks.named('processGametestResources') {
def villageOnly = providers.gradleProperty('sanctuaryVillageOnly').map { it.toBoolean() }.getOrElse(false)
def focused = providers.gradleProperty('sanctuaryFocusedTests').getOrElse('').split(',').findAll { !it.isEmpty() }
def focusClasses = [starter052: 'Starter052GameTests', dispenser051: 'Dispenser051GameTests', head050: 'Head050GameTests', boatshead: 'BoatsHead049GameTests', poultry: 'Poultry048GameTests', starinspect: 'StarInspection047GameTests', skyflight: 'SkyFlight046GameTests', eggaudit: 'Companion044GameTests', headblocks: 'HeadBlocks043GameTests', operator: 'OperatorStars040GameTests', starter: 'Starter039GameTests', gravelayout: 'GraveInventory039GameTests', foodknowledge: 'FoodKnowledge039GameTests', graves: 'GravesFood038GameTests', realtime: 'Realtime037GameTests', corrections: 'Corrections036GameTests', collections: 'Collections035GameTests', notifications: 'Notifications034GameTests', menus: 'Menus033GameTests', aerial: 'Aerial24WorldGameTests', transit: 'Transit29WorldGameTests', village: 'Village306GameTests', drained: 'Drained307GameTests', expeditions: 'AncientExpeditions001GameTests', progression: 'Progression003GameTests', map: 'Atlas005GameTests', demeure: 'Demeure006GameTests', mining: 'Mining008GameTests', building: 'Building009GameTests', inventory: 'Inventory010GameTests', cycle: 'Cycle011GameTests', inventoryflow: 'Inventory012GameTests', sorting: 'Inventory014GameTests', tutorial: 'Tutorial016GameTests', movement: 'Movement017GameTests', accessories: 'Accessory018GameTests', companions: 'Companion019GameTests', familiar: 'Familiar020GameTests', carry: 'Carry027GameTests', familiarhit: 'Familiar029GameTests', refonte: 'Companion032GameTests', lights: 'Dynamic030GameTests', cosmetics: 'HeadCosmetic021GameTests', startup: 'StartupPerformanceGameTests', recipes: 'Recipes024GameTests']
def focusClasses = [plans072: 'Plans072GameTests', starter052: 'Starter052GameTests', dispenser051: 'Dispenser051GameTests', head050: 'Head050GameTests', boatshead: 'BoatsHead049GameTests', poultry: 'Poultry048GameTests', starinspect: 'StarInspection047GameTests', skyflight: 'SkyFlight046GameTests', eggaudit: 'Companion044GameTests', headblocks: 'HeadBlocks043GameTests', operator: 'OperatorStars040GameTests', starter: 'Starter039GameTests', gravelayout: 'GraveInventory039GameTests', foodknowledge: 'FoodKnowledge039GameTests', graves: 'GravesFood038GameTests', realtime: 'Realtime037GameTests', corrections: 'Corrections036GameTests', collections: 'Collections035GameTests', notifications: 'Notifications034GameTests', menus: 'Menus033GameTests', aerial: 'Aerial24WorldGameTests', transit: 'Transit29WorldGameTests', village: 'Village306GameTests', drained: 'Drained307GameTests', expeditions: 'AncientExpeditions001GameTests', progression: 'Progression003GameTests', map: 'Atlas005GameTests', demeure: 'Demeure006GameTests', mining: 'Mining008GameTests', building: 'Building009GameTests', inventory: 'Inventory010GameTests', cycle: 'Cycle011GameTests', inventoryflow: 'Inventory012GameTests', sorting: 'Inventory014GameTests', tutorial: 'Tutorial016GameTests', movement: 'Movement017GameTests', accessories: 'Accessory018GameTests', companions: 'Companion019GameTests', familiar: 'Familiar020GameTests', carry: 'Carry027GameTests', familiarhit: 'Familiar029GameTests', refonte: 'Companion032GameTests', lights: 'Dynamic030GameTests', cosmetics: 'HeadCosmetic021GameTests', startup: 'StartupPerformanceGameTests', recipes: 'Recipes024GameTests']
focused.each { if (!focusClasses.containsKey(it)) throw new GradleException('Unknown focused native test: ' + it) }
inputs.property('focusedNative', focused)
def introClient = providers.gradleProperty('sanctuaryIntroClientTests').map { it.toBoolean() }.getOrElse(false)
@@ -114,6 +114,85 @@ tasks.named('processGametestResources') {
if (skillsClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Skills025ClientChecks') }
def fogClient = providers.gradleProperty('sanctuaryFogClientTests').map { it.toBoolean() }.getOrElse(false)
def sky056Client = providers.gradleProperty('sanctuarySky056ClientTests').map { it.toBoolean() }.getOrElse(false)
def cloud077Client = providers.gradleProperty('sanctuaryCloud077ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryCloud077ClientTests', cloud077Client)
if (cloud077Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Cloud077ClientChecks') }
def navigation076Client = providers.gradleProperty('sanctuaryNavigation076ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryNavigation076ClientTests', navigation076Client)
if (navigation076Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Navigation076ClientChecks') }
def welcome083Client = providers.gradleProperty('sanctuaryWelcome083ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryWelcome083ClientTests', welcome083Client)
if (welcome083Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Welcome083ClientChecks') }
def piston087Client = providers.gradleProperty('sanctuaryPiston087ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPiston087ClientTests', piston087Client)
if (piston087Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Piston087ClientChecks') }
def fut086Client = providers.gradleProperty('sanctuaryFut086ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryFut086ClientTests', fut086Client)
if (fut086Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Fut086ClientChecks') }
def fourneau085Client = providers.gradleProperty('sanctuaryFourneau085ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryFourneau085ClientTests', fourneau085Client)
if (fourneau085Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Fourneau085ClientChecks') }
def pick092Client = providers.gradleProperty('sanctuaryPick092ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPick092ClientTests', pick092Client)
if (pick092Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Pick092ClientChecks') }
def boatPassengers091Client = providers.gradleProperty('sanctuaryBoatPassengers091ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryBoatPassengers091ClientTests', boatPassengers091Client)
if (boatPassengers091Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.BoatPassengers091ClientChecks') }
def multiblocks084Client = providers.gradleProperty('sanctuaryMultiblocks084ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryMultiblocks084ClientTests', multiblocks084Client)
if (multiblocks084Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Multiblocks084ClientChecks') }
def menus083Client = providers.gradleProperty('sanctuaryMenus083ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryMenus083ClientTests', menus083Client)
if (menus083Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Menus083ClientChecks') }
def weather082Client = providers.gradleProperty('sanctuaryWeather082ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryWeather082ClientTests', weather082Client)
if (weather082Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Weather082ClientChecks') }
def balance080Client = providers.gradleProperty('sanctuaryBalance080ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryBalance080ClientTests', balance080Client)
if (balance080Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Balance080ClientChecks') }
def solo079Client = providers.gradleProperty('sanctuarySolo079ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySolo079ClientTests', solo079Client)
if (solo079Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Solo079ClientChecks') }
def autonomy074Client = providers.gradleProperty('sanctuaryAutonomy074ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryAutonomy074ClientTests', autonomy074Client)
if (autonomy074Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Autonomy074ClientChecks') }
def arena073Client = providers.gradleProperty('sanctuaryArena073ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('arena073Client', arena073Client)
if (arena073Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Arena073ClientChecks') }
def plans072Client = providers.gradleProperty('sanctuaryPlans072ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('plans072Client', plans072Client)
if (plans072Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Plans072ClientChecks') }
def familiar071Client = providers.gradleProperty('sanctuaryFamiliar071ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('familiar071Client', familiar071Client)
if (familiar071Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Familiar071ClientChecks') }
def resourcePack070Client = providers.gradleProperty('sanctuaryResourcePack070ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('resourcePack070Client', resourcePack070Client)
if (resourcePack070Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.ResourcePack070ClientChecks') }
def loading068Client = providers.gradleProperty('sanctuaryLoading068ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('loading068Client', loading068Client)
if (loading068Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Loading068ClientChecks') }
def familiar067Client = providers.gradleProperty('sanctuaryFamiliar067ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('familiar067Client', familiar067Client)
if (familiar067Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Familiar067ClientChecks') }
def cosmetic066Client = providers.gradleProperty('sanctuaryCosmetic066ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('cosmetic066Client', cosmetic066Client)
if (cosmetic066Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Cosmetic066ClientChecks') }
def boat065Client = providers.gradleProperty('sanctuaryBoat065ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('boat065Client', boat065Client)
if (boat065Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Boat065ClientChecks') }
def loading064Client = providers.gradleProperty('sanctuaryLoading064ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('loading064Client', loading064Client)
if (loading064Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Loading064ClientChecks') }
def gameplay063Client = providers.gradleProperty('sanctuaryGameplay063ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('gameplay063Client', gameplay063Client)
if (gameplay063Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Gameplay063ClientChecks') }
def loading063Client = providers.gradleProperty('sanctuaryLoading063ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('loading063Client', loading063Client)
inputs.property('loading063Entry', 'fr.koka.sanctuary.client.Loading063ClientAudit')
if (loading063Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Loading063ClientAudit') }
def sunlight062Client = providers.gradleProperty('sanctuarySunlight062ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sunlight062Client', sunlight062Client)
if (sunlight062Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Sunlight062ClientChecks') }
def arrivalMount061Client = providers.gradleProperty('sanctuaryArrivalMount061ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('arrivalMount061Client', arrivalMount061Client)
if (arrivalMount061Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.ArrivalMount061ClientChecks') }
@@ -311,10 +390,15 @@ tasks.withType(JavaCompile).configureEach {
}
processResources {
// The launcher uses 26.3-pre-2; Fabric's runtime normalizes it to 26.3-pre.2.
from(rootProject.file('ressources-pack/sanctuary')) {
include 'assets/**', 'pack.mcmeta', 'pack.png'
exclude '**/.DS_Store'
into 'resourcepacks/textures'
}
// Normalize launcher pre-release IDs when targeting one; final releases retain their exact ID.
def values = [version: project.version, minecraft_version: rootProject.minecraft_version,
minecraft_dependency_version: rootProject.minecraft_version.replace('-pre-', '-pre.'),
loader_version: rootProject.loader_version, fabric_api_version: rootProject.fabric_api_version]
loader_version: rootProject.loader_version, fabric_api_version: rootProject.fabric_api_version, jei_version: rootProject.jei_version]
inputs.properties(values)
filesMatching('fabric.mod.json') { expand(values) }
}
@@ -0,0 +1,139 @@
package fr.koka.sanctuary.client;
import com.mojang.authlib.GameProfile;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.arena.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.Connection;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.network.protocol.PacketFlow;
import net.minecraft.network.protocol.game.*;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.*;
import net.minecraft.server.network.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
/** Real integrated server, synthetic network players, native combat, escrow and FR/EN UI. */
public final class Arena073ClientChecks implements FabricClientGameTest {
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
private final List<ServerPlayer> remotes=new ArrayList<>();
private Arenas.Match current;
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){Starter039TestSupport.selectFirst(screen);screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);}});
ServerTickEvents.END_SERVER_TICK.register(s->{for(var p:List.copyOf(remotes))if(p.level().getServer()==s&&!p.isRemoved()){p.tickCount++;CompanionService.tick(p);FamiliarService.tick(p);FamiliarBattle.tick(p);}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Arena073 "+UUID.randomUUID());s.setSeed("73");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=host(s);p.teleportTo(.5,1,.5);p.getInventory().clearContent();p.getAbilities().invulnerable=false;s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set night");p.level().getGameRules().set(net.minecraft.world.level.gamerules.GameRules.PVP,false,s);
for(int x=-16;x<=16;x++)for(int z=-16;z<=16;z++)p.level().setBlockAndUpdate(new net.minecraft.core.BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
for(int i=0;i<5;i++)remotes.add(remote(p,"Arena073_"+i,i+2));
for(var e:all(p)){fresh(e);e.getInventory().setItem(0,new ItemStack(Items.EMERALD,10));}
check(Arenas.create(p,new Arenas.Rules(Arenas.Mode.FAMILIARS,false,true,24,180),0,2).equals("created"),"Create familiar arena");current=Arenas.match(p);
check(Arenas.join(current,remotes.get(0)).equals("joined")&&Arenas.join(current,remotes.get(1)).equals("joined"),"Third participant accepted");
check(Arenas.bet(current,p,p.getUUID(),1).equals("participant_bet"),"Participant cannot speculate");
check(Arenas.bet(current,remotes.get(2),p.getUUID(),4).equals("bet_placed"),"First spectator stake");
check(Arenas.bet(current,remotes.get(3),p.getUUID(),2).equals("bet_placed"),"Second spectator stake");
check(Arenas.bet(current,remotes.get(4),remotes.get(0).getUUID(),3).equals("bet_placed"),"Losing-side spectator stake");
check(Arenas.join(current,remotes.get(2)).equals("bettor"),"Spectator cannot join after betting");
check(Arenas.start(current,p).equals("not_ready"),"All entrants must confirm immutable rules");
check(Arenas.ledger(s).pot(current.id,true)==6&&Arenas.ledger(s).pot(current.id,false)==9,"Separate entry and spectator pots");
for(var e:current.entrants.values())Arenas.ready(current,e.player);
});
c.runOnClient(m->ArenaClient.open(null));c.waitFor(m->m.gui.screen() instanceof ArenaScreen,100);
for(var locale:List.of("fr_fr","en_us")){c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new ArenaScreen(null));});c.waitTicks(5);c.takeScreenshot("arena073-board-"+locale);}
server.runOnServer(s->ArenaNetwork.request(host(s),new ArenaNetwork.Request("open",current.id.toString(),"",0,-1,0,0,"")));c.waitTicks(5);c.takeScreenshot("arena073-registration");
server.runOnServer(s->{var p=host(s);check(Arenas.start(current,p).equals("started"),"Unanimous start");check(Arenas.bet(current,remotes.get(2),p.getUUID(),1).equals("closed"),"Bets close before countdown");current.until=FamiliarBattle.now();Arenas.tick(s);
var pet=FamiliarService.active(p);var rival=FamiliarService.active(remotes.get(0));check(FamiliarBattle.enemy(p,rival)&&!FamiliarBattle.enemy(p,remotes.get(0)),"Familiar-only consent boundary");check(!rival.hurtServer(p.level(),p.damageSources().playerAttack(p),10),"Owner cannot attack a rival pet");check(!p.hurtServer(p.level(),p.damageSources().playerAttack(remotes.get(2)),10),"Bystander cannot attack organizer");
pet.setPos(.5,1,2);rival.setPos(1.5,1,2);FamiliarBattle.engage(p,rival,false);
});c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(65);
server.runOnServer(s->{var p=host(s);check(current.phase==Arenas.Phase.FIGHT,"Three-way arena running");check(current.entrants.values().stream().anyMatch(e->FamiliarBattle.bond(e.player).health()<FamiliarBattle.profile(e.player).health()),"Native autonomous attacks cause wounds");
for(int i=0;i<2;i++){var rival=FamiliarService.active(remotes.get(i));if(rival!=null){var pet=FamiliarService.active(p);pet.setPos(.5,1,2);rival.setPos(1.5,1,2);var cast=new FamiliarTechniques.Cast(pet,FamiliarBattle.profile(p).initiative(),false,rival,rival.position());for(int hit=0;hit<50&&current.phase==Arenas.Phase.FIGHT&&!current.entrants.get(remotes.get(i).getUUID()).out;hit++)FamiliarTechniques.hit(cast,rival,4);}}
check(current.phase==Arenas.Phase.FINISHED&&p.getUUID().equals(current.winner),"Last living familiar wins");for(var e:all(p))Arenas.ledger(s).claim(e);
check(count(p)==14&&count(remotes.get(0))==8&&count(remotes.get(1))==8,"Winner receives exact entry pool");check(count(remotes.get(2))==12&&count(remotes.get(3))==11&&count(remotes.get(4))==7,"Spectator pari-mutuel payout conserves all nine items");
Arenas.ledger(s).claim(p);check(count(p)==14,"No duplicate payout");System.out.println("ARENA073_FAMILIARS_PASS: three entrants, native AI, external interference, registration and spectator pools");
});c.waitTicks(110);
server.runOnServer(s->{var p=host(s);var q=remotes.get(0);var r=remotes.get(1);for(var e:all(p))fresh(e);
check(Arenas.create(q,new Arenas.Rules(Arenas.Mode.PLAYERS,false,false,24,180),-1,0).equals("created"),"Players-only arena");current=Arenas.match(q);check(Arenas.join(current,p).equals("joined")&&Arenas.join(current,r).equals("joined"),"Three human entrants");for(var e:current.entrants.values())Arenas.ready(current,e.player);Arenas.start(current,q);current.until=FamiliarBattle.now();Arenas.tick(s);
check(p.canHarmPlayer(q),"Consensual arena bypasses global PvP=false");check(!p.canHarmPlayer(remotes.get(2)),"Global PvP remains off for spectators");check(!FamiliarBattle.enemy(p,FamiliarService.active(q)),"Familiars excluded from human-only fight");
check(!p.gameMode.destroyBlock(new net.minecraft.core.BlockPos(0,0,0)),"Arena blocks native/grouped mining");
check(p.gameMode.useItem(p,p.level(),new ItemStack(Items.COOKED_BEEF),net.minecraft.world.InteractionHand.MAIN_HAND)==net.minecraft.world.InteractionResult.FAIL,"Food disabled by arena rules");
int items=count(q);q.setInvulnerableTime(0);check(q.hurtServer(p.level(),p.damageSources().playerAttack(p),100),"Native player damage accepted");check(q.getHealth()<=0&&!q.isAlive()&&count(q)==0&&current.entrants.get(q.getUUID()).out,"Native lethal hit kills and moves inventory to a grave");
check(!q.hurtServer(p.level(),p.damageSources().playerAttack(p),100),"Eliminated players cannot be hit again");r.setInvulnerableTime(0);r.hurtServer(p.level(),p.damageSources().playerAttack(p),100);check(current.phase==Arenas.Phase.FINISHED&&p.getUUID().equals(current.winner),"Player arena resolves by actual death");System.out.println("ARENA073_PLAYERS_PASS: native damage, real death, PvP consent and pet exclusion");
});c.waitTicks(110);
server.runOnServer(s->{var p=host(s);var q=remotes.get(0);var r=remotes.get(1);for(int i=0;i<2;i++){var old=remotes.get(i);var petOld=FamiliarService.active(old);if(petOld!=null)petOld.discard();old.discard();remotes.set(i,remote(p,"Rejoined073_"+i,3+i));}q=remotes.get(0);r=remotes.get(1);for(var e:all(p))fresh(e);
check(Arenas.create(r,new Arenas.Rules(Arenas.Mode.MIXED,true,true,24,180),-1,0).equals("created"),"Mixed duel creation");current=Arenas.match(r);check(Arenas.join(current,p).equals("joined"),"Mixed duel second entrant");check(Arenas.join(current,q).equals("unavailable"),"Only duels cap entry at two");for(var e:current.entrants.values())Arenas.ready(current,e.player);Arenas.start(current,r);current.until=FamiliarBattle.now();Arenas.tick(s);
var rival=FamiliarService.active(r);var pet=FamiliarService.active(p);pet.setPos(.5,1,2);rival.setPos(1.5,1,2);check(FamiliarBattle.enemy(p,r)&&FamiliarBattle.enemy(p,rival),"Mixed fight allows both target kinds");check(rival.hurtServer(p.level(),p.damageSources().playerAttack(p),1000),"Players can KO rival pets in mixed fight");check(!current.entrants.get(r.getUUID()).out&&current.phase==Arenas.Phase.FIGHT,"Pet KO leaves its human in mixed fight");
Arenas.eliminate(r,"forfeit");check(p.getUUID().equals(current.winner),"Mixed forfeit settles winner");
escrow(s,p,q);System.out.println("ARENA073_MIXED_ESCROW_PASS: two-player duel, mixed combat, refund, restart and item components");
});c.waitTicks(110);
server.runOnServer(s->{var p=host(s);var q=remotes.get(0);fresh(p);fresh(q);p.teleportTo(.5,1,.5);q.setPos(.5,1,4);check(FamiliarDuels.invite(p,q).equals("duel.sent"),"Legacy duel stays available");var duel=FamiliarDuels.match(p);var board=Arenas.get(s,duel.id);check(board!=null&&board.legacy&&board.entrants.size()==2,"Legacy duel publicly listed");check(Arenas.bet(board,remotes.get(2),p.getUUID(),1).equals("bet_placed"),"Spectators can bet on legacy duels");int before=count(remotes.get(2));FamiliarDuels.finish(duel,-1,"cancelled");Arenas.ledger(s).claim(remotes.get(2));check(count(remotes.get(2))==before+1,"Cancelled legacy duel refunds spectators");
fresh(p);check(Arenas.create(p,new Arenas.Rules(Arenas.Mode.PLAYERS,false,true,96,600),-1,0).equals("created"),"Large roster fixture");current=Arenas.match(p);
for(int i=0;i<24;i++){var other=remote(p,"Roster073_"+i,2+i%5);remotes.add(other);check(Arenas.join(current,other).equals("joined"),"No artificial arena player cap at entrant "+(i+2));}
check(current.entrants.size()==25,"Arena roster exceeds legacy and page limits");var doomed=remotes.getLast();int beforeSize=current.entrants.size();doomed.setInvulnerableTime(0);doomed.hurtServer(p.level(),doomed.damageSources().generic(),100);check(!doomed.isAlive()&&current.phase==Arenas.Phase.OPEN&&current.entrants.size()==beforeSize-1,"Death during registration withdraws only that entrant");var replacement=remote(p,"Roster073_new",6);remotes.add(replacement);check(Arenas.join(current,replacement).equals("joined"),"Free place stays open after registration death");ArenaNetwork.request(p,new ArenaNetwork.Request("open",current.id.toString(),"",0,-1,0,1,""));
});c.waitTicks(8);c.runOnClient(m->{check(ArenaClient.data.get("total").getAsInt()==25&&ArenaClient.data.getAsJsonObject("match").getAsJsonArray("entrants").size()==12,"Paginated bounded roster payload");});c.takeScreenshot("arena073-roster-page2");
server.runOnServer(s->{Arenas.finish(current,null,"cancelled");for(var q:remotes){FamiliarDuels.disconnect(q);FamiliarBattle.release(q);var pet=FamiliarService.active(q);if(pet!=null)pet.discard();q.discard();}remotes.clear();});
c.waitTicks(210);server.runOnServer(s->fresh(host(s)));
c.runOnClient(m->ArenaClient.open(null));c.waitTicks(8);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.arena.create").onPress(enter()));c.waitTicks(4);c.takeScreenshot("arena073-create");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.arena.create_confirm").onPress(enter()));c.waitTicks(8);
server.runOnServer(s->{check(Arenas.match(host(s))!=null,"Real GUI creates event through network");Arenas.finish(Arenas.match(host(s)),null,"cancelled");});c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(110);
fr.koka.sanctuary.gametest.Duel054Checks.run(c,server);
}finally{intro.set(false);remotes.clear();}
System.out.println("ARENA073_PASS");
}
private void escrow(MinecraftServer s,ServerPlayer p,ServerPlayer q){
var ledger=Arenas.ledger(s);var id=UUID.randomUUID();p.getInventory().clearContent();q.getInventory().clearContent();p.getInventory().setItem(0,new ItemStack(Items.EMERALD,7));q.getInventory().setItem(0,new ItemStack(Items.EMERALD,5));
check(ledger.deposit(id,p,p.getUUID(),true,new ItemStack(Items.EMERALD),7)&&ledger.deposit(id,q,q.getUUID(),true,new ItemStack(Items.EMERALD),5),"Durable entry deposits");
for(int slot:CompanionInventory.slots(p,true))p.getInventory().setItem(slot,new ItemStack(Items.STONE,64));ledger.finish(id,p.getUUID());ledger.claim(p);check(ledger.pending(p),"Full inventory retains winnings");
p.getInventory().clearContent();ledger.claim(p);check(count(p)==12&&!ledger.pending(p),"Deferred payout exact");
var weights=new TreeMap<UUID,Long>();weights.put(new UUID(0,1),1L);weights.put(new UUID(0,2),2L);check(ArenaLedger.split(10,weights).values().stream().mapToLong(Long::longValue).sum()==10,"Integer remainder conserved");
var interrupted=UUID.randomUUID();check(ledger.deposit(interrupted,p,p.getUUID(),false,new ItemStack(Items.EMERALD),3),"Restart fixture deposited");
try{var recovered=new ArenaLedger(s);recovered.claim(p);check(count(p)==12,"Restart refunds live event");recovered.claim(p);check(count(p)==12,"Restart refund is idempotent");
var withdrawal=UUID.randomUUID();p.getInventory().setItem(0,new ItemStack(Items.EMERALD,12));q.getInventory().setItem(0,new ItemStack(Items.EMERALD,5));
check(recovered.deposit(withdrawal,q,q.getUUID(),true,new ItemStack(Items.EMERALD),2)&&recovered.deposit(withdrawal,p,q.getUUID(),false,new ItemStack(Items.EMERALD),3),"Withdrawal fixture");recovered.leave(withdrawal,q.getUUID());recovered.claim(p);recovered.claim(q);check(count(p)==12&&count(q)==5,"Withdrawal refunds both entry and bets on withdrawn entrant");
// Fresh journal replaces the test-owned ledger only after all old entries are settled.
var field=Arenas.class.getDeclaredField("LEDGERS");field.setAccessible(true);((Map<MinecraftServer,ArenaLedger>)field.get(null)).put(s,recovered);
var special=new ItemStack(Items.CHEST);special.set(DataComponents.CUSTOM_NAME,Component.literal("Arena archive"));special.set(DataComponents.CONTAINER,net.minecraft.world.item.component.ItemContainerContents.fromItems(List.of(new ItemStack(Items.DIAMOND,3))));p.getInventory().setItem(1,special.copy());var custom=UUID.randomUUID();check(recovered.deposit(custom,p,p.getUUID(),true,special,1),"Component-bearing item deposited");recovered.finish(custom,null);recovered.claim(p);check(CompanionInventory.slots(p,true).stream().anyMatch(slot->ItemStack.matches(special,p.getInventory().getItem(slot))),"Nested container components preserved");
var journal=s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("sanctuary/arena-stakes-v1.json");
var crash=UUID.randomUUID();check(recovered.deposit(crash,p,p.getUUID(),true,new ItemStack(Items.EMERALD),3),"Crash after native debit fixture");
var root=com.google.gson.JsonParser.parseString(java.nio.file.Files.readString(journal)).getAsJsonObject();var entry=root.getAsJsonObject("deposits").entrySet().iterator().next();var template=entry.getValue().getAsJsonObject().deepCopy();entry.getValue().getAsJsonObject().addProperty("state","pending");java.nio.file.Files.writeString(journal,root.toString());
recovered=new ArenaLedger(s);recovered.claim(p);check(count(p)==12,"Prepared debit with saved receipt refunds exactly once");
root=com.google.gson.JsonParser.parseString(java.nio.file.Files.readString(journal)).getAsJsonObject();template.addProperty("state","pending");root.getAsJsonObject("deposits").add(UUID.randomUUID().toString(),template);java.nio.file.Files.writeString(journal,root.toString());
recovered=new ArenaLedger(s);recovered.claim(p);check(count(p)==12,"Prepared debit without receipt never invents a refund");
var payment=UUID.randomUUID();check(recovered.deposit(payment,p,p.getUUID(),true,new ItemStack(Items.EMERALD),3),"Crash after payout fixture");recovered.finish(payment,p.getUUID());
root=com.google.gson.JsonParser.parseString(java.nio.file.Files.readString(journal)).getAsJsonObject();recovered.claim(p);var due=root.getAsJsonObject("payouts").entrySet().iterator().next().getValue().getAsJsonObject();due.addProperty("transfer",p.getAttachedOrElse(ArenaLedger.RECEIPT,""));due.addProperty("moving",3);java.nio.file.Files.writeString(journal,root.toString());
recovered=new ArenaLedger(s);recovered.claim(p);recovered.claim(p);check(count(p)==12,"Saved payout receipt prevents paying the same items again");
String valid=java.nio.file.Files.readString(journal);var invalid=com.google.gson.JsonParser.parseString(valid).getAsJsonObject();invalid.addProperty("schema",2);java.nio.file.Files.writeString(journal,invalid.toString());boolean refused=false;try{new ArenaLedger(s);}catch(Exception expected){refused=true;}check(refused&&java.nio.file.Files.readString(journal).equals(invalid.toString()),"Unknown journal schema rejected without overwrite");java.nio.file.Files.writeString(journal,valid);recovered=new ArenaLedger(s);((Map<MinecraftServer,ArenaLedger>)field.get(null)).put(s,recovered);
System.out.println("ARENA073_RECOVERY_PASS: before debit, after debit, after payout, unknown schema");
}catch(Exception e){throw new AssertionError(e);}
}
private static ServerPlayer remote(ServerPlayer p,String name,int z){var s=p.level().getServer();var profile=new GameProfile(UUID.randomUUID(),name);var other=new ServerPlayer(s,p.level(),profile,ClientInformation.createDefault());var connection=new Connection(PacketFlow.SERVERBOUND);new io.netty.channel.embedded.EmbeddedChannel(connection);other.connection=new ServerGamePacketListenerImpl(s,connection,other,CommonListenerCookie.createInitial(profile,false));other.setPos(.5,1,z);other.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(other,ProgressRecord.INITIAL);other.setHealth(6);other.connection.handleAcceptPlayerLoad(new ServerboundPlayerLoadedPacket());p.connection.send(ClientboundPlayerInfoUpdatePacket.createPlayerInitializing(List.of(other)));p.level().addNewPlayer(other);return other;}
private List<ServerPlayer> all(ServerPlayer p){var list=new ArrayList<>(remotes);list.addFirst(p);return list;}
private static void fresh(ServerPlayer p){p.setHealth(p.getMaxHealth());p.getAbilities().invulnerable=false;p.setInvulnerableTime(0);p.stopRiding();CarryService.detach(p);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);p.removeAttached(FamiliarBattle.COMBAT_LOCK);p.removeAttached(FamiliarBattle.LAST_FAMILIAR);var egg=new ItemStack(Items.WOLF_SPAWN_EGG);var profile=FamiliarBattle.world(p.level().getServer()).catalog.forEgg(egg);var id=UUID.randomUUID();FamiliarBattle.world(p.level().getServer()).store.put(FamiliarBond.create(id,profile,0,"test",p.getUUID()));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,FamiliarIdentity.assign(egg,id));CompanionService.tick(p);p.tickCount+=10-p.tickCount%10;FamiliarService.tick(p);check(FamiliarService.active(p)!=null,"Spawn test familiar");}
private static ServerPlayer host(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static int count(ServerPlayer p){return CompanionInventory.slots(p,true).stream().mapToInt(i->p.getInventory().getItem(i).is(Items.EMERALD)?p.getInventory().getItem(i).getCount():0).sum();}
private static java.util.stream.Stream<net.minecraft.client.gui.components.events.GuiEventListener> widgets(net.minecraft.client.gui.components.events.GuiEventListener root){return java.util.stream.Stream.concat(java.util.stream.Stream.of(root),root instanceof net.minecraft.client.gui.components.events.ContainerEventHandler c?c.children().stream().flatMap(Arena073ClientChecks::widgets):java.util.stream.Stream.empty());}
private static Button button(net.minecraft.client.gui.screens.Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
}
@@ -0,0 +1,138 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.companion.CompanionService;
import fr.koka.sanctuary.companion.CompanionWorld;
import net.minecraft.client.KeyMapping;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.CustomData;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.Vec3;
/** Exercises server decisions, native navigation/damage and actual short/hold keyboard gestures. */
public final class Autonomy074ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
var forbidden=new AtomicReference<UUID>();
CompanionWorld.ALLOW.register((p,pos,op)->forbidden.get()==null||p.level().getEntity(forbidden.get())==null||!p.level().getEntity(forbidden.get()).blockPosition().equals(pos));
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Autonomy074 "+UUID.randomUUID());s.setSeed("74");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.getAbilities().invulnerable=true;p.getInventory().clearContent();p.teleportTo(.5,1,.5);
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set night");
for(int x=-20;x<=20;x++)for(int z=-20;z<=20;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
});
for(var personality:FamiliarPersonality.values()){
var victim=new AtomicReference<Mob>();
server.runOnServer(s->{var p=player(s);p.teleportTo(.5,1,.5);p.setDeltaMovement(Vec3.ZERO);equip(p,Items.WOLF_SPAWN_EGG,personality,1000);FamiliarService.active(p).setPos(.5,1,1.5);victim.set(enemy(p,9.5,1,1.5));});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check((FamiliarBattle.target(p)!=null)==(personality==FamiliarPersonality.AGGRESSIVE),"12-block hunt: "+personality);FamiliarBattle.cancel(p);victim.get().setPos(4.5,1,1.5);});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check((FamiliarBattle.target(p)!=null)==(personality==FamiliarPersonality.AGGRESSIVE||personality==FamiliarPersonality.PROTECTIVE),"6-block guard: "+personality+" player="+p.position()+" pet="+FamiliarService.active(p).position()+" victim="+victim.get().position()+" target="+FamiliarBattle.target(p));
FamiliarBattle.cancel(p);FamiliarBattle.damageEvent(victim.get(),p.damageSources().playerAttack(p),1);
check((FamiliarBattle.target(p)!=null)==(personality==FamiliarPersonality.AGGRESSIVE),"Only aggressive joins unsolicited owner attack: "+personality);
FamiliarBattle.cancel(p);p.getAbilities().invulnerable=false;p.setInvulnerableTime(0);p.hurtServer(p.level(),p.damageSources().mobAttack(victim.get()),1);p.getAbilities().invulnerable=true;
check((FamiliarBattle.target(p)!=null)==(personality!=FamiliarPersonality.PACIFIST),"Native owner hurt: "+personality);
if(personality==FamiliarPersonality.PACIFIST){check(FamiliarBattle.state(p).evadeUntil>p.tickCount,"Pacifist retreats");FamiliarBattle.engage(p,victim.get(),false);}
});c.waitTicks(110);
server.runOnServer(s->{var p=player(s);check(victim.get().getHealth()<100,"Real autonomous navigation/damage: "+personality+" distance="+FamiliarService.active(p).distanceTo(victim.get()));victim.get().discard();FamiliarBattle.cancel(p);p.setHealth(p.getMaxHealth());
System.out.println("AUTONOMY074_POLICY_PASS "+personality);
});
}
// Protective support species can intercept, really strike, then come back to guard.
var first=new AtomicReference<Mob>();var next=new AtomicReference<Mob>();
server.runOnServer(s->{var p=player(s);p.teleportTo(.5,1,.5);p.setDeltaMovement(Vec3.ZERO);equip(p,Items.VILLAGER_SPAWN_EGG,FamiliarPersonality.PROTECTIVE,1000);FamiliarService.active(p).setPos(.5,1,1.5);p.getAbilities().invulnerable=false;first.set(enemy(p,4.5,1,1.5));});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==first.get(),"Guard first engages an idle intruder");next.set(enemy(p,5.5,1,-1.5));next.get().setTarget(p);});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==next.get(),"Guard prioritizes a monster targeting the owner: selected="+FamiliarBattle.target(p)+" threat="+next.get()+" threatTarget="+next.get().getTarget()+" p="+p.position());});c.waitTicks(100);
server.runOnServer(s->{var p=player(s);check(next.get().getHealth()<100,"Protective villager really defends without a shield");next.get().discard();});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==first.get(),"Reacquires next intruder after target dies");first.get().setPos(13.5,1,1.5);});c.waitTicks(70);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==null,"Guard gives up pursuit beyond ten blocks");check(FamiliarService.active(p).distanceToSqr(p)<25,"Guard returns to owner");first.get().discard();p.getAbilities().invulnerable=true;
System.out.println("AUTONOMY074_GUARD_PASS");
});
server.runOnServer(s->{var p=player(s);equip(p,Items.VILLAGER_SPAWN_EGG,FamiliarPersonality.PASSIVE,1000);
p.getAbilities().invulnerable=false;var bee=EntityTypes.BEE.create(p.level(),EntitySpawnReason.COMMAND);bee.setNoAi(true);bee.setPos(3.5,1,1.5);p.level().addFreshEntity(bee);
check(!FamiliarBattle.enemy(p,bee),"Peaceful bee is not an enemy");bee.setTarget(p);check(FamiliarBattle.enemy(p,bee),"An attacking neutral bee can be defended against");
FamiliarBattle.damageEvent(p,p.damageSources().mobAttack(bee),1);check(FamiliarBattle.target(p)==bee,"Passive companion defends against a neutral aggressor");bee.discard();p.getAbilities().invulnerable=true;FamiliarBattle.cancel(p);
equip(p,Items.VILLAGER_SPAWN_EGG,FamiliarPersonality.PACIFIST,1000);FamiliarBattle.state(p).lastRequest=-100;FamiliarBattle.command(p,"protect");first.set(enemy(p,4.5,1,1.5));
});c.waitTicks(90);
server.runOnServer(s->{var p=player(s);check(first.get().getHealth()<100,"Pacifist obeys explicit guard order and really strikes");next.set(enemy(p,8.5,1,1.5));FamiliarBattle.engage(p,next.get(),false);
FamiliarBattle.automatic(p,first.get(),FamiliarPersonality.Trigger.OWNER_HURT);check(FamiliarBattle.target(p)==next.get()&&FamiliarBattle.state(p).explicitTarget,"Explicit target has priority over another attacker");
first.get().discard();next.get().discard();FamiliarBattle.cancel(p);System.out.println("AUTONOMY074_NEUTRAL_AND_ORDERS_PASS");
});
// A pet's sight is independent from the owner's, but neither sees through walls.
server.runOnServer(s->{var p=player(s);equip(p,Items.WOLF_SPAWN_EGG,FamiliarPersonality.AGGRESSIVE,1000);var pet=FamiliarService.active(p);pet.setPos(.5,1,4.5);pet.setNoAi(true);first.set(enemy(p,8.5,1,4.5));
for(int y=1;y<=4;y++)for(int z=0;z<=2;z++)p.level().setBlockAndUpdate(new BlockPos(3,y,z),Blocks.STONE.defaultBlockState());
check(!p.hasLineOfSight(first.get())&&pet.hasLineOfSight(first.get()),"Different sightlines fixture");
});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==first.get(),"Own sight selects a target hidden from player");FamiliarBattle.cancel(p);for(int y=1;y<=4;y++)for(int z=-3;z<=9;z++)p.level().setBlockAndUpdate(new BlockPos(3,y,z),Blocks.STONE.defaultBlockState());});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==null,"No acquisition through a solid wall");FamiliarBattle.automatic(p,first.get(),FamiliarPersonality.Trigger.OWNER_HURT);
for(int x=7;x<=10;x++)for(int z=3;z<=6;z++)for(int y=1;y<=4;y++)if(x==7||x==10||z==3||z==6||y==4)p.level().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.STONE.defaultBlockState());
FamiliarService.active(p).setNoAi(false);});c.waitTicks(115);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==null,"Loses enclosed target instead of looping at wall: pet="+FamiliarService.active(p).position()+" victim="+first.get().position());check(first.get().getUUID().equals(FamiliarBattle.state(p).unreachable),"Unreachable target has bounded retry delay");first.get().discard();
for(int x=7;x<=10;x++)for(int z=3;z<=6;z++)for(int y=1;y<=4;y++)p.level().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());
for(int y=1;y<=4;y++)for(int z=-3;z<=9;z++)p.level().setBlockAndUpdate(new BlockPos(3,y,z),Blocks.AIR.defaultBlockState());
FamiliarBattle.cancel(p);first.set(enemy(p,4.5,1,1.5));forbidden.set(first.get().getUUID());
});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==null,"Permission veto prevents automatic acquisition");forbidden.set(null);FamiliarBattle.state(p).workMode=true;});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==null,"Work mode suppresses autonomy");FamiliarBattle.state(p).workMode=false;FamiliarBattle.state(p).lastRequest=-100;FamiliarBattle.command(p,"follow");});c.waitTicks(40);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==null,"Follow order has a five-second grace period");});c.waitTicks(85);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==first.get(),"Autonomy resumes after follow grace: p="+p.position()+" pet="+FamiliarService.active(p).position()+" tick="+p.tickCount+" after="+FamiliarBattle.state(p).automaticAfter+" target="+FamiliarBattle.target(p)+" victim="+first.get().position());first.get().discard();FamiliarBattle.cancel(p);System.out.println("AUTONOMY074_SIGHT_WORK_FOLLOW_PASS");});
// Server aims the short order; native F stays untouched, and long holds never release an attack.
server.runOnServer(s->{var p=player(s);equip(p,Items.WOLF_SPAWN_EGG,FamiliarPersonality.PACIFIST,1000);p.teleportTo(.5,1,.5);first.set(enemy(p,.5,1,7.5));});c.waitTicks(15);
c.getInput().lookAt(new BlockPos(0,2,7));c.waitTicks(5);c.getInput().pressKey(InputConstants.KEY_H);c.waitTicks(10);
c.runOnClient(m->check(m.gui.screen()==null,"Short H does not open a screen"));
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==first.get()&&FamiliarBattle.state(p).explicitTarget,"Short H attacks selected mob even with pacifist");});
c.getInput().lookAt(new BlockPos(0,20,-8));c.waitTicks(5);c.getInput().pressKey(InputConstants.KEY_H);c.waitTicks(10);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==null&&FamiliarBattle.state(p).order==FamiliarBattle.Order.FOLLOW,"Short H without a target returns to follow");});
c.getInput().lookAt(new BlockPos(0,2,7));c.waitTicks(5);c.getInput().holdKey(InputConstants.KEY_H);c.waitFor(m->m.gui.screen() instanceof FamiliarOrdersScreen,100);c.getInput().releaseKey(InputConstants.KEY_H);c.waitTicks(5);
c.takeScreenshot("autonomy074-orders");c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(10);
server.runOnServer(s->check(FamiliarBattle.target(player(s))==null,"Releasing long H does not fire a quick command"));
c.runOnClient(m->{KeyMapping.get("sanctuary.familiar.orders_key").setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_Y));KeyMapping.resetMapping();});
c.getInput().pressKey(InputConstants.KEY_Y);c.waitTicks(10);server.runOnServer(s->check(FamiliarBattle.target(player(s))==first.get(),"Remapped short Y orders work"));
c.getInput().holdKey(InputConstants.KEY_Y);c.waitFor(m->m.gui.screen() instanceof FamiliarOrdersScreen,100);c.getInput().releaseKey(InputConstants.KEY_Y);c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(5);
server.runOnServer(s->{var p=player(s);p.getInventory().setItem(0,new ItemStack(Items.DIAMOND));p.getInventory().setSelectedSlot(0);p.setItemInHand(net.minecraft.world.InteractionHand.OFF_HAND,new ItemStack(Items.GOLD_INGOT));p.inventoryMenu.broadcastChanges();});c.waitTicks(10);c.getInput().pressKey(InputConstants.KEY_F);c.waitTicks(10);
server.runOnServer(s->{var p=player(s);check(p.getMainHandItem().is(Items.GOLD_INGOT)&&p.getOffhandItem().is(Items.DIAMOND),"F remains vanilla swap hands");first.get().discard();FamiliarBattle.cancel(p);});
c.runOnClient(m->{var key=KeyMapping.get("sanctuary.familiar.orders_key");key.setKey(key.getDefaultKey());KeyMapping.resetMapping();});
for(String locale:List.of("fr_fr","en_us")){
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());FamiliarClient.open(null);});c.waitTicks(5);c.takeScreenshot("autonomy074-personality-"+locale);c.runOnClient(m->m.gui.setScreen(null));
}
System.out.println("AUTONOMY074_INPUT_PASS: native H tap/hold, remapped Y, F swap, FR/EN screens");
}finally{intro.set(false);forbidden.set(null);c.getInput().releaseKey(InputConstants.KEY_H);c.getInput().releaseKey(InputConstants.KEY_Y);}
System.out.println("AUTONOMY074_PASS");
}
private static void equip(ServerPlayer p,Item item,FamiliarPersonality personality,int size){
p.stopRiding();CarryService.detach(p);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);p.removeAttached(FamiliarBattle.COMBAT_LOCK);p.removeAttached(FamiliarBattle.LAST_FAMILIAR);
var egg=new ItemStack(item);var scale=new CompoundTag();scale.putInt("schema",1);scale.putInt("per_mille",size);CustomData.update(DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,scale));
var profile=FamiliarBattle.world(p.level().getServer()).catalog.forEgg(egg);UUID id;do{id=UUID.randomUUID();}while(FamiliarPersonality.of(id,profile.id())!=personality);
FamiliarBattle.world(p.level().getServer()).store.put(FamiliarBond.create(id,profile,0,"test",p.getUUID()));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,FamiliarIdentity.assign(egg,id));
CompanionService.tick(p);p.tickCount+=10-p.tickCount%10;FamiliarService.tick(p);check(FamiliarService.active(p)!=null,"Familiar spawned: "+item);p.inventoryMenu.broadcastChanges();
}
private static Mob enemy(ServerPlayer p,double x,double y,double z){var e=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);e.setNoAi(true);e.getAttribute(Attributes.MAX_HEALTH).setBaseValue(100);e.setHealth(100);e.setPos(x,y,z);p.level().addFreshEntity(e);return e;}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
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);}
}
@@ -0,0 +1,131 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.building.*;
import fr.koka.sanctuary.mining.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.ai.attributes.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
/** Actual input, native prediction, play packets and authoritative blocks in a disposable solo world. */
public final class Balance080ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Balance080 "+UUID.randomUUID());s.setSeed("80");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.getInventory().clearContent();p.getAbilities().invulnerable=true;
for(int x=-4;x<=4;x++)for(int z=-2;z<=10;z++){p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.BEDROCK.defaultBlockState());for(int y=1;y<=6;y++)p.level().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());}
p.teleportTo(.5,1,.75);p.setOnGround(true);curve(p);
});
// The base skill places nearby, rejects a normal 4.25-block click, and consumes no stock on rejection.
setup(c,server,0,0,3,Items.COBBLESTONE,false);use(c);server.waitFor(s->player(s).level().getBlockState(pos(3).north()).is(Blocks.COBBLESTONE),100);
setup(c,server,0,0,5,Items.COBBLESTONE,false);use(c);c.waitTicks(12);
server.runOnServer(s->{var p=player(s);check(p.level().getBlockState(pos(5).north()).isAir()&&p.getMainHandItem().getCount()==64,"Rank zero rejects far placement without consuming stock");});
// Construction alone reaches 5.25 blocks; Mining stays at its own rank-zero reach.
setup(c,server,0,7,6,Items.COBBLESTONE,false);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult h&&h.getBlockPos().equals(pos(6))&&h.getType()==HitResult.Type.BLOCK,"Extended construction target picked"));
c.getInput().holdKey(o->o.keyAttack);c.waitTicks(25);c.getInput().releaseKey(o->o.keyAttack);
server.runOnServer(s->check(player(s).level().getBlockState(pos(6)).is(Blocks.STONE),"Construction does not extend mining"));
use(c);server.waitFor(s->player(s).level().getBlockState(pos(6).north()).is(Blocks.COBBLESTONE),100);
setup(c,server,0,7,6,Items.COBBLESTONE,true);use(c);server.waitFor(s->player(s).level().getBlockState(pos(6).north()).is(Blocks.COBBLESTONE),100);
// beta.081: the newly earned half-block works through the actual pick and play packet.
setup(c,server,0,7,6,Items.COBBLESTONE,false);server.runOnServer(s->player(s).teleportTo(.5,1,.25));
c.waitFor(m->Math.abs(m.player.getZ()-.25)<.01,100);c.getInput().lookAt(pos(6));c.waitTicks(5);use(c);
server.waitFor(s->player(s).level().getBlockState(pos(6).north()).is(Blocks.COBBLESTONE),100);
// Outside six blocks, a forged hit is rejected even within the native packet tolerance.
setup(c,server,0,7,6,Items.COBBLESTONE,false);server.runOnServer(s->player(s).teleportTo(.5,1,-.25));
c.waitFor(m->Math.abs(m.player.getZ()+.25)<.01,100);c.getInput().lookAt(pos(6));c.waitTicks(5);
c.runOnClient(m->m.gameMode.useItemOn(m.player,InteractionHand.MAIN_HAND,new BlockHitResult(new Vec3(.5,2.5,6),Direction.NORTH,pos(6),false)));c.waitTicks(15);
server.runOnServer(s->{var p=player(s);check(p.level().getBlockState(pos(6).north()).isAir()&&p.getMainHandItem().getCount()==64,"Six-block maximum is enforced by the server");});
// A deliberately forged out-of-range native packet cannot place or consume an item.
setup(c,server,7,7,8,Items.COBBLESTONE,false);
c.runOnClient(m->m.gameMode.useItemOn(m.player,InteractionHand.MAIN_HAND,new BlockHitResult(new Vec3(.5,2.5,8),Direction.NORTH,pos(8),false)));c.waitTicks(15);
server.runOnServer(s->{var p=player(s);check(p.level().getBlockState(pos(8).north()).isAir()&&p.getMainHandItem().getCount()==64,"Server rejects placement beyond maximum plus native packet tolerance");});
// High Mining + low Construction: mine the far stone, but still cannot place at the same distance.
setup(c,server,7,0,6,Items.COBBLESTONE,false);use(c);c.waitTicks(12);
server.runOnServer(s->check(player(s).level().getBlockState(pos(6).north()).isAir(),"Mining does not extend placement"));
setup(c,server,7,0,6,Items.DIAMOND_PICKAXE,false);
c.getInput().holdKey(o->o.keyAttack);server.waitFor(s->player(s).level().getBlockState(pos(6)).isAir(),140);c.getInput().releaseKey(o->o.keyAttack);
// Low Mining rejects the same block despite the normal interaction highlight.
setup(c,server,0,7,5,Items.DIAMOND_PICKAXE,false);c.getInput().holdKey(o->o.keyAttack);c.waitTicks(30);c.getInput().releaseKey(o->o.keyAttack);
server.runOnServer(s->check(player(s).level().getBlockState(pos(5)).is(Blocks.STONE),"Starting mining reach is reduced"));
// A distant chest is only a building support, not a newly extended container interaction.
setup(c,server,0,7,6,Items.COBBLESTONE,false);server.runOnServer(s->player(s).level().setBlockAndUpdate(pos(6),Blocks.CHEST.defaultBlockState()));c.waitTicks(8);use(c);
server.waitFor(s->player(s).level().getBlockState(pos(6).north()).is(Blocks.COBBLESTONE),100);
server.runOnServer(s->check(player(s).containerMenu==player(s).inventoryMenu,"Far support does not open a container"));
// Offhand building reach must not silently extend a tool held in the main hand.
setup(c,server,0,7,6,Items.FLINT_AND_STEEL,false);server.runOnServer(s->{player(s).setItemInHand(InteractionHand.OFF_HAND,new ItemStack(Items.COBBLESTONE,64));player(s).inventoryMenu.broadcastChanges();});c.waitTicks(10);use(c);
server.waitFor(s->player(s).level().getBlockState(pos(6).north()).is(Blocks.COBBLESTONE),100);
server.runOnServer(s->check(player(s).getMainHandItem().getDamageValue()==0,"Offhand reach does not extend flint and steel"));
// A full plane still places outside the anchor's range, through the existing grouped contract.
setup(c,server,7,7,6,Items.COBBLESTONE,false);server.runOnServer(s->{for(int x=-1;x<=1;x++)for(int y=1;y<=3;y++)player(s).level().setBlockAndUpdate(new BlockPos(x,y,6),Blocks.STONE.defaultBlockState());});c.waitTicks(8);
c.getInput().holdKey(InputConstants.KEY_R);c.waitTicks(4);
c.runOnClient(m->{for(int i=0;i<6;i++)BuildingClient.scroll(-1);});c.waitFor(m->BuildingClient.previewSize()==9,150);
c.getInput().holdKey(o->o.keyUse);server.waitFor(s->player(s).getMainHandItem().getCount()==55,200);c.getInput().releaseKey(o->o.keyUse);c.getInput().releaseKey(InputConstants.KEY_R);
server.runOnServer(s->{for(int x=-1;x<=1;x++)for(int y=1;y<=3;y++)check(player(s).level().getBlockState(new BlockPos(x,y,5)).is(Blocks.COBBLESTONE),"All nine grouped cells placed");});
// Vein mining starts beyond vanilla reach with Mining, using the same frozen-anchor rules.
setup(c,server,7,0,6,Items.DIAMOND_PICKAXE,false);server.runOnServer(s->{for(int x=-1;x<=1;x++)for(int y=1;y<=3;y++)player(s).level().setBlockAndUpdate(new BlockPos(x,y,6),Blocks.STONE.defaultBlockState());});c.waitTicks(8);
c.getInput().holdKey(InputConstants.KEY_R);c.waitFor(m->MiningClient.previewSize()==9,150);c.getInput().holdKey(o->o.keyAttack);
server.waitFor(s->MiningManager.active(player(s)),100);c.getInput().lookAt(new BlockPos(0,4,-3));
server.waitFor(s->player(s).level().getBlockState(pos(6)).isAir(),300);c.getInput().releaseKey(o->o.keyAttack);c.getInput().releaseKey(InputConstants.KEY_R);
server.runOnServer(s->{for(int x=-1;x<=1;x++)for(int y=1;y<=3;y++)check(player(s).level().getBlockState(new BlockPos(x,y,6)).isAir(),"Looking away preserves and completes all nine vein blocks");});
// Water placement uses its own native raycast and must receive the same reach.
setup(c,server,7,7,6,Items.LILY_PAD,false);server.runOnServer(s->{var p=player(s);p.level().setBlockAndUpdate(pos(6),Blocks.AIR.defaultBlockState());p.level().setBlockAndUpdate(new BlockPos(0,1,5),Blocks.WATER.defaultBlockState());});c.waitTicks(8);
c.getInput().lookAt(new BlockPos(0,1,5));c.waitTicks(5);use(c);server.waitFor(s->player(s).level().getBlockState(new BlockPos(0,2,5)).is(Blocks.LILY_PAD),100);
// Prestige/reset, creative and unbound vanilla players keep coherent defaults.
server.runOnServer(s->{var p=player(s);setRanks(p,0,0);check(BuildReach.range(p)==3&&MiningReach.range(p)==3,"Reset immediately restores both starting reaches");p.setGameMode(GameType.CREATIVE);check(BuildReach.range(p)==p.blockInteractionRange()&&MiningReach.range(p)==p.blockInteractionRange(),"Creative retains native ranges");p.setGameMode(GameType.SURVIVAL);ProgressEffects.clear(p);check(BuildReach.range(p)==4.5&&MiningReach.range(p)==4.5,"Non-Sanctuary players keep vanilla ranges");setRanks(p,7,7);});
c.waitFor(m->ProgressionClient.progress.mining()==7&&ProgressionClient.progress.building()==7,200);
c.takeScreenshot("balance080-complete");System.out.println("BALANCE080_PASS: independent reach, native placement/mining, offhand, forged packet, group, locked vein, water, utility isolation, ranks and modifiers");
}
}
private static void curve(ServerPlayer p) {
double[] speed={.75,.85,.95,1,1.075,1.15,1.225,1.30},reach={3,3.5,4,4.5,4.75,5,5.25,5.5},buildReach={3,3.5,4,4.5,4.75,5,5.5,6};
var extra=SanctuaryMod.id("test/balance080");var attribute=p.getAttribute(Attributes.BLOCK_BREAK_SPEED);
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.DIAMOND_PICKAXE));setRanks(p,3,3);
var state=Blocks.STONE.defaultBlockState();float baseline=state.getDestroyProgress(p,p.level(),new BlockPos(0,0,0));
for(int rank=0;rank<=7;rank++) {setRanks(p,rank,rank);near(attribute.getValue(),speed[rank],"Mining speed rank "+rank);near(state.getDestroyProgress(p,p.level(),new BlockPos(0,0,0))/baseline,speed[rank],"Native block breaking progress rank "+rank);near(BuildReach.range(p),buildReach[rank],"Build reach rank "+rank);near(MiningReach.range(p),reach[rank],"Mining reach rank "+rank);}
attribute.addTransientModifier(new AttributeModifier(extra,.5,AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL));setRanks(p,7,7);near(attribute.getValue(),1.95,"Mining composes with external boost");attribute.removeModifier(extra);
var blockRange=p.getAttribute(Attributes.BLOCK_INTERACTION_RANGE);blockRange.addTransientModifier(new AttributeModifier(extra,2,AttributeModifier.Operation.ADD_VALUE));near(BuildReach.range(p),8,"Construction preserves external range bonuses");near(MiningReach.range(p),7.5,"Mining preserves external range bonuses");blockRange.removeModifier(extra);
near(p.blockInteractionRange(),4.5,"Base interactions remain native");near(p.entityInteractionRange(),3,"Entity interactions remain native");
}
private static void setup(ClientGameTestContext c,TestServerContext server,int mining,int building,int z,Item item,boolean offhand) {
server.runOnServer(s->{var p=player(s);MiningManager.forget(p);BuildingManager.forget(p);setRanks(p,mining,building);p.teleportTo(.5,1,.75);
for(int x=-1;x<=1;x++)for(int y=1;y<=3;y++)for(int zz=2;zz<=9;zz++)p.level().setBlockAndUpdate(new BlockPos(x,y,zz),Blocks.AIR.defaultBlockState());
p.level().setBlockAndUpdate(pos(z),Blocks.STONE.defaultBlockState());
p.setItemInHand(InteractionHand.MAIN_HAND,offhand?ItemStack.EMPTY:new ItemStack(item,item.getDefaultMaxStackSize()));p.setItemInHand(InteractionHand.OFF_HAND,offhand?new ItemStack(item,64):ItemStack.EMPTY);p.inventoryMenu.broadcastChanges();
});c.waitFor(m->ProgressionClient.progress.mining()==mining&&ProgressionClient.progress.building()==building&&m.level.getBlockState(pos(z)).is(Blocks.STONE)&&(offhand?m.player.getOffhandItem():m.player.getMainHandItem()).is(item),200);c.waitTicks(8);c.getInput().lookAt(pos(z));c.waitTicks(5);
}
private static void use(ClientGameTestContext c){c.getInput().holdKey(o->o.keyUse);c.waitTicks(2);c.getInput().releaseKey(o->o.keyUse);}
private static void setRanks(ServerPlayer p,int mining,int building){var r=ProgressRecord.INITIAL;var config=ProgressionService.session(p.level().getServer()).config;for(int i=0;i<mining;i++)r=r.buy("mining",r.cost("mining",config));for(int i=0;i<building;i++)r=r.buy("building",r.cost("building",config));p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");}
private static BlockPos pos(int z){return new BlockPos(0,2,z);}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void near(double actual,double expected,String reason){check(Math.abs(actual-expected)<.00001,reason+" actual="+actual+" expected="+expected);}
private static void check(boolean value,String reason){if(!value)throw new AssertionError(reason);}
}
@@ -0,0 +1,66 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.sky.VortexClouds;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.client.CloudStatus;
import net.minecraft.client.GraphicsPreset;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.level.GameType;
/** Native cloud passes on a new empty laboratory, plus the single connected/cached shape. */
public final class Cloud077ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
for(int diameter:List.of(512,724,1024))geometry(diameter);
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0));
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0));intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Cloud077 "+UUID.randomUUID());s.setSeed("77");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_gametest:vortex")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1500);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(VortexClouds.settings(p.level())==null,"A flat generator never receives a vortex automatically");p.setGameMode(GameType.SPECTATOR);p.teleportTo(0,300,0);
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"weather clear");
});c.waitTicks(12);
c.runOnClient(m->{m.getWindow().setWindowed(1280,800);m.options.guiScale().set(2);m.resizeGui();m.options.cloudRange().set(128);m.options.cloudStatus().set(CloudStatus.FANCY);m.options.fov().set(110);});c.getInput().pressKey(InputConstants.KEY_F1);c.getInput().lookAt(new BlockPos(0,0,0));c.waitTicks(8);
c.takeScreenshot("cloud077-before");int before=c.computeOnClient(m->VortexCloudRenderer.draws());
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();ServerPlayNetworking.send(p,new VortexClouds.Settings(p.level().dimension().identifier(),0,0,724));});
c.waitFor(m->VortexCloudRenderer.draws()>before,100);c.waitTicks(8);c.takeScreenshot("cloud077-fancy-top");c.runOnClient(m->System.out.println("CLOUD077_CAMERA pos="+m.gameRenderer.mainCamera().position()+" pitch="+m.gameRenderer.mainCamera().xRot()+" player="+m.player.position()+" range="+m.options.cloudRange().get()));
int builds=c.computeOnClient(m->VortexCloudRenderer.builds());
c.runOnClient(m->m.level.setTimeFromServer(3000));c.waitTicks(2);c.takeScreenshot("cloud077-rotated");
c.runOnClient(m->m.options.cloudStatus().set(CloudStatus.FAST));c.waitTicks(6);c.takeScreenshot("cloud077-fast");
int oit=c.computeOnClient(m->VortexCloudRenderer.oitDraws());c.runOnClient(m->{m.options.applyGraphicsPreset(GraphicsPreset.FABULOUS);m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.cloudRange().set(128);});c.waitTicks(12);c.takeScreenshot("cloud077-fabulous");check(c.computeOnClient(m->VortexCloudRenderer.oitDraws())>oit,"Fabulous actually uses the OIT cloud passes");
check(c.computeOnClient(m->VortexCloudRenderer.builds())==builds,"Rotation and graphics mode keep cached geometry");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();for(int x=60;x<=140;x++)for(int z=-40;z<=40;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),net.minecraft.world.level.block.Blocks.AIR.defaultBlockState());p.teleportTo(100,-12,0);});c.waitTicks(12);c.getInput().lookAt(new BlockPos(100,1,0));c.waitTicks(4);c.takeScreenshot("cloud077-below");
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(100,1,0));c.waitTicks(12);c.getInput().lookAt(new BlockPos(100,1,100));c.waitTicks(4);c.takeScreenshot("cloud077-inside");
c.runOnClient(m->m.options.cloudStatus().set(CloudStatus.OFF));c.waitTicks(5);int off=c.computeOnClient(m->VortexCloudRenderer.draws());c.waitTicks(8);check(c.computeOnClient(m->VortexCloudRenderer.draws())==off,"Clouds off stops both layers");
c.runOnClient(m->m.options.cloudStatus().set(CloudStatus.FANCY));server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(20000,300,20000));c.waitTicks(15);int far=c.computeOnClient(m->VortexCloudRenderer.draws());c.waitTicks(8);check(c.computeOnClient(m->VortexCloudRenderer.draws())==far,"Disc does not tile across the world");
check(c.computeOnClient(m->VortexCloudRenderer.builds())==builds,"Moving the camera never rebuilds the mesh");c.getInput().pressKey(InputConstants.KEY_F1);
System.out.println("CLOUD077_PASS: connected 512/724/1024 geometry, native fancy/fast/Fabulous, rotation, Y0 crossing, clouds off, finite extent and cache");
}finally{intro.set(false);}
}
private static void geometry(int diameter){
Set<Long> all=new HashSet<>();int radius=(int)Math.ceil(diameter/12.0);
for(int x=-radius;x<radius;x++)for(int z=-radius;z<radius;z++)if(VortexCloudRenderer.occupied(x,z,diameter))all.add(key(x,z));
check(all.size()>2000&&all.size()<25000,"Bounded geometry "+diameter);
Set<Long> seen=new HashSet<>();var queue=new ArrayDeque<Long>();queue.add(all.iterator().next());
while(!queue.isEmpty()){long id=queue.remove();if(!seen.add(id))continue;int x=(int)(id>>32),z=(int)id;for(int[] d:new int[][]{{1,0},{-1,0},{0,1},{0,-1}}){long next=key(x+d[0],z+d[1]);if(all.contains(next)&&!seen.contains(next))queue.add(next);}}
check(seen.size()==all.size(),"Continuous spiral, no disconnected fragments: "+diameter+" "+seen.size()+"/"+all.size());
check(!VortexCloudRenderer.occupied(0,0,diameter)&&!VortexCloudRenderer.occupied(radius,0,diameter),"Open eye and finite outer edge");
System.out.println("CLOUD077_GEOMETRY "+diameter+" "+all.size()+" cells");
}
private static long key(int x,int z){return ((long)x<<32)|(z&0xffffffffL);}
private static void check(boolean value,String reason){if(!value)throw new AssertionError(reason);}
}
@@ -0,0 +1,117 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import com.mojang.blaze3d.vertex.PoseStack;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.inventory.*;
import java.lang.reflect.Proxy;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.*;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.entity.state.EnderDragonRenderState;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.client.model.monster.dragon.EnderDragonModel;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.CustomData;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
/** Native key events, client/server carry physics and the submitted dragon model's actual head direction. */
public final class Familiar067ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Familiar067 "+UUID.randomUUID());s.setSeed("67");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.getInventory().clearContent();p.getAbilities().invulnerable=true;
p.teleportTo(.5,1,.5);for(int x=-10;x<=10;x++)for(int z=-10;z<=20;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.DIAMOND));p.setItemInHand(InteractionHand.OFF_HAND,new ItemStack(Items.GOLD_INGOT));p.inventoryMenu.broadcastChanges();
});c.waitTicks(12);
c.runOnClient(m->check(orders().getDefaultKey().equals(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_H)),"H is the restored default"));
c.getInput().holdKey(InputConstants.KEY_H);c.waitFor(m->m.gui.screen() instanceof FamiliarOrdersScreen,100);c.getInput().releaseKey(InputConstants.KEY_H);c.waitTicks(5);
server.runOnServer(s->check(player(s).getMainHandItem().is(Items.DIAMOND)&&player(s).getOffhandItem().is(Items.GOLD_INGOT),"H orders must not swap hands"));
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);c.getInput().pressKey(InputConstants.KEY_F);c.waitTicks(8);c.runOnClient(m->check(m.gui.screen()==null,"F swaps hands without opening orders"));
server.runOnServer(s->check(player(s).getMainHandItem().is(Items.GOLD_INGOT)&&player(s).getOffhandItem().is(Items.DIAMOND),"Native F swaps both hands with default H orders"));
c.runOnClient(m->check(KeyMapping.get("sanctuary.companion.key").getDefaultKey().equals(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_G)),"Technique remains on G"));
c.runOnClient(m->{orders().setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_K));KeyMapping.resetMapping();});
c.getInput().pressKey(InputConstants.KEY_F);c.waitTicks(8);server.runOnServer(s->check(player(s).getMainHandItem().is(Items.DIAMOND)&&player(s).getOffhandItem().is(Items.GOLD_INGOT),"Native F swap works when orders use another key"));
c.getInput().holdKey(InputConstants.KEY_K);c.waitFor(m->m.gui.screen() instanceof FamiliarOrdersScreen,100);c.getInput().releaseKey(InputConstants.KEY_K);c.getInput().pressKey(InputConstants.KEY_ESCAPE);
c.runOnClient(m->{orders().setKey(orders().getDefaultKey());KeyMapping.resetMapping();orientation(m);});
System.out.println("FAMILIAR067_KEYS_ORIENTATION_PASS: real H/F/K input, native hand swap restored, native dragon head faces travel in four directions");
for(int size:new int[]{350,650,1500}){
server.runOnServer(s->{var p=player(s);CarryService.detach(p);p.stopRiding();AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);
p.teleportTo(.5,1,.5);p.setItemInHand(InteractionHand.MAIN_HAND,ItemStack.EMPTY);p.setItemInHand(InteractionHand.OFF_HAND,ItemStack.EMPTY);p.setShiftKeyDown(false);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg(size));p.inventoryMenu.broadcastChanges();});c.waitTicks(30);
int id=server.computeOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet!=null,"Equipped dragon spawns");pet.setNoAi(true);pet.setPos(p.position().add(0,0,2));pet.setDeltaMovement(Vec3.ZERO);return pet.getId();});
c.waitFor(m->m.level.getEntity(id)!=null&&m.player.getMainHandItem().isEmpty(),100);c.getInput().holdKey(o->o.keyShift);c.waitTicks(4);click(c,id);c.waitTicks(5);c.getInput().releaseKey(o->o.keyShift);c.waitTicks(5);
server.runOnServer(s->{var p=player(s);check(CarryService.carried(FamiliarService.active(p)),"Dragon is carried via native sneak-click: "+size);p.teleportTo(.5,8,.5);p.setDeltaMovement(0,-.5,0);p.setOnGround(false);p.fallDistance=12;});
c.waitTicks(20);
c.runOnClient(m->{check(CarryGlide.active(m.player),"Local player glides with dragon: "+size);check(m.player.getDeltaMovement().y>=-CarryGlide.DESCENT-.01,"Client descent bounded: "+m.player.getDeltaMovement());check(m.player.fallDistance<.2,"Accumulated fall distance cleared on client");});
server.runOnServer(s->{check(CarryGlide.supported(player(s)),"Server agrees on glide support");check(player(s).fallDistance<.2,"Server fall distance cleared");});
if(size==1500){
double base=server.computeOnServer(s->player(s).getY());c.getInput().holdKey(o->o.keyJump);c.waitTicks(100);
server.runOnServer(s->{check(player(s).getY()>base+8,"Holding jump flies upward with large dragon: "+player(s).getY()+" from "+base);check(player(s).connection.isAcceptingMessages(),"Sustained supported flight is not rejected as floating");});
c.getInput().releaseKey(o->o.keyJump);c.waitTicks(25);double top=server.computeOnServer(s->player(s).getY());c.waitTicks(15);server.runOnServer(s->check(player(s).getY()<top-.5,"Releasing jump resumes a gentle glide"));
c.runOnClient(m->{m.options.setCameraType(CameraType.THIRD_PERSON_BACK);});c.waitTicks(3);c.takeScreenshot("familiar067-large-dragon-flight");
server.runOnServer(s->{var p=player(s);int y=(int)Math.ceil(FamiliarService.active(p).getBoundingBox().maxY)+1;for(int x=-2;x<=2;x++)for(int z=-2;z<=2;z++)p.level().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.STONE.defaultBlockState());});
c.getInput().holdKey(o->o.keyJump);c.waitTicks(40);c.getInput().releaseKey(o->o.keyJump);
server.runOnServer(s->{var p=player(s);check(p.level().noBlockCollision(p,p.getBoundingBox().deflate(.02)),"Lift cannot push the player through a ceiling");});
}else{
c.getInput().holdKey(o->o.keyJump);c.waitTicks(6);c.getInput().releaseKey(o->o.keyJump);
c.runOnClient(m->check(m.player.getDeltaMovement().y<=0,"Small dragon glides but cannot lift"));
}
server.runOnServer(s->{var p=player(s);CarryService.detach(p);p.teleportTo(.5,1,.5);});c.waitTicks(5);
System.out.println("FAMILIAR067_CARRY_PASS size="+size);
}
// The existing colossal riding path must still work for the dragon itself.
server.runOnServer(s->{var p=player(s);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg(3500));p.teleportTo(5.5,1,.5);});c.waitTicks(30);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);pet.setPos(p.position().add(0,1,2));p.setShiftKeyDown(false);check(CarryService.interact(p,pet),"Colossal dragon mounts instead of being carried");});c.waitTicks(5);
double base=server.computeOnServer(s->FamiliarService.active(player(s)).getY());c.getInput().holdKey(o->o.keyJump);c.waitTicks(20);c.getInput().releaseKey(o->o.keyJump);
server.runOnServer(s->check(FamiliarService.active(player(s)).getY()>base+2,"Colossal dragon flies with native jump input"));
System.out.println("FAMILIAR067_PASS: native shortcuts, model orientation, tiny/small glide, large carried flight and colossal riding");
}finally{intro.set(false);c.getInput().releaseKey(o->o.keyShift);c.getInput().releaseKey(o->o.keyJump);}
}
private static void orientation(Minecraft mc){
for(int yaw:new int[]{0,90,180,270}){
var pet=FamiliarService.TYPE.create(mc.level,EntitySpawnReason.COMMAND);pet.setId(300000+yaw);pet.bind(null,egg(1000));pet.setPos(mc.player.position().add(0,5,8));pet.setYRot(yaw);pet.yRotO=yaw;pet.yBodyRot=pet.yBodyRotO=pet.yHeadRot=pet.yHeadRotO=yaw;
var renderer=(FamiliarRenderer)mc.getEntityRenderDispatcher().getRenderer(pet);var state=renderer.createRenderState(pet,1);var checked=new AtomicBoolean();
SubmitNodeCollector collector=(SubmitNodeCollector)Proxy.newProxyInstance(SubmitNodeCollector.class.getClassLoader(),new Class<?>[]{SubmitNodeCollector.class},(proxy,method,args)->{
if(method.getName().equals("order"))return proxy;
if(method.getName().equals("submitModel")&&args[0] instanceof EnderDragonModel dragon&&checked.compareAndSet(false,true)){
dragon.setupAnim((EnderDragonRenderState)args[1]);var pose=new PoseStack();pose.mulPose(((PoseStack)args[2]).last().pose());dragon.root().translateAndRotate(pose);
var head=dragon.root().getChild("head");var body=dragon.root().getChild("body");
var a=pose.last().pose().transformPosition(new org.joml.Vector3f(head.x/16,head.y/16,head.z/16));var b=pose.last().pose().transformPosition(new org.joml.Vector3f(body.x/16,body.y/16,body.z/16));
var forward=Vec3.directionFromRotation(0,yaw);double dot=(a.x-b.x)*forward.x+(a.z-b.z)*forward.z;
check(dot>0,"Submitted dragon head points forwards at yaw "+yaw+", dot="+dot);
}return null;
});
renderer.submit(state,new PoseStack(),collector,new CameraRenderState());check(checked.get(),"Actual native dragon model was submitted");
}
}
private static ItemStack egg(int size){var stack=new ItemStack(Items.ENDER_DRAGON_SPAWN_EGG);var tag=new CompoundTag();tag.putInt("schema",1);tag.putInt("per_mille",size);CustomData.update(DataComponents.CUSTOM_DATA,stack,t->t.put(FamiliarSize.KEY,tag));return stack;}
private static KeyMapping orders(){return KeyMapping.get("sanctuary.familiar.orders_key");}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void click(ClientGameTestContext c,int id){c.runOnClient(m->{var e=m.level.getEntity(id);m.gameMode.interact(m.player,e,new EntityHitResult(e,e.getEyePosition()),InteractionHand.MAIN_HAND);});}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,147 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.companion.CompanionService;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.CustomData;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.Vec3;
/** Native server AI and mount movement in an isolated client-created flat world. */
public final class Familiar071ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave save;
var savedId=new AtomicReference<UUID>();var savedPersonality=new AtomicReference<FamiliarPersonality>();
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Familiar071 "+UUID.randomUUID());s.setSeed("71");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
save=world.getWorldSave();c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.getAbilities().invulnerable=true;p.getInventory().clearContent();p.teleportTo(.5,1,.5);
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set night");
for(int x=-16;x<=16;x++)for(int z=-16;z<=16;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
distributions(s);
});
for(var personality:FamiliarPersonality.values()){
var victim=new AtomicReference<Mob>();
server.runOnServer(s->{var p=player(s);equip(p,Items.WOLF_SPAWN_EGG,personality,1000);FamiliarService.active(p).setPos(.5,1,1.5);victim.set(enemy(p,7.5,1,1.5));});c.waitTicks(25);
server.runOnServer(s->{var p=player(s);var target=victim.get();check((FamiliarBattle.target(p)!=null)==(personality==FamiliarPersonality.AGGRESSIVE),"Proximity reaction: "+personality);
FamiliarBattle.cancel(p);FamiliarBattle.state(p).automaticAfter=0;
FamiliarBattle.damageEvent(target,p.damageSources().playerAttack(p),1);
check((FamiliarBattle.target(p)!=null)==(personality==FamiliarPersonality.AGGRESSIVE),"Owner attack reaction: "+personality);
check(FamiliarBattle.fighting(p),"Combat lock remains active even if personality refuses to attack: "+personality);
FamiliarBattle.cancel(p);FamiliarBattle.damageEvent(p,p.damageSources().mobAttack(target),1);
check((FamiliarBattle.target(p)!=null)==(personality!=FamiliarPersonality.PACIFIST),"Owner hurt reaction: "+personality);
FamiliarBattle.cancel(p);FamiliarService.active(p).hurtServer(p.level(),p.damageSources().mobAttack(target),1);
check((FamiliarBattle.target(p)!=null)==(personality!=FamiliarPersonality.PACIFIST),"Pet hurt reaction: "+personality);
if(personality==FamiliarPersonality.PACIFIST)check(FamiliarBattle.state(p).evadeUntil>p.tickCount,"Pacifist chooses retreat");
FamiliarBattle.engage(p,target,false);check(FamiliarBattle.state(p).explicitTarget,"Manual attack is explicit: "+personality);
});c.waitTicks(100);
server.runOnServer(s->{var p=player(s);check(victim.get().getHealth()<100,"Actual navigation and damage on explicit order: "+personality+" distance="+FamiliarService.active(p).distanceTo(victim.get()));
System.out.println("FAMILIAR071_PERSONALITY_PASS "+personality+" targetHealth="+victim.get().getHealth()+" pursuit="+personality.pursuitSpeed());victim.get().discard();FamiliarBattle.cancel(p);
});
}
// Support species must really attack, rather than repeatedly trying a shield on an enemy.
var villagerTarget=new AtomicReference<Mob>();
server.runOnServer(s->{var p=player(s);equip(p,Items.VILLAGER_SPAWN_EGG,FamiliarPersonality.AGGRESSIVE,1000);FamiliarService.active(p).setPos(.5,1,1.5);villagerTarget.set(enemy(p,6.5,1,1.5));});c.waitTicks(120);
server.runOnServer(s->{var p=player(s);check(villagerTarget.get().getHealth()<100,"Aggressive villager initiates and really strikes without shield supply");check(!FamiliarBattle.state(p).explicitTarget,"Automatic technique remains automatic");
var second=enemy(p,1.5,1,.5);FamiliarBattle.engage(p,villagerTarget.get(),false);FamiliarBattle.automatic(p,second,FamiliarPersonality.Trigger.OWNER_HURT);check(FamiliarBattle.target(p)==villagerTarget.get(),"Explicit target is not overwritten by another attacker");second.discard();villagerTarget.get().discard();FamiliarBattle.cancel(p);
savedId.set(FamiliarBattle.bond(p).id());savedPersonality.set(FamiliarPersonality.of(FamiliarBattle.bond(p)));
var egg=FamiliarBattle.egg(p);egg.set(DataComponents.CUSTOM_NAME,Component.literal("Mistral"));FamiliarBattle.update(p,FamiliarBattle.bond(p).charge(100,FamiliarBattle.profile(p).health()));FamiliarBattle.flush(s);
});c.waitTicks(25);
for(String locale:List.of("fr_fr","en_us")){
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());FamiliarClient.open(null);});c.waitTicks(5);c.takeScreenshot("familiar071-personality-"+locale);c.runOnClient(m->m.gui.setScreen(null));
}
System.out.println("FAMILIAR071_VILLAGER_PASS: automatic melee, manual target priority and FR/EN character sheet");
// Native input drives two climb-capable mounts, with a ceiling and no open-air flight.
for(var item:List.of(Items.SPIDER_SPAWN_EGG,Items.CAVE_SPIDER_SPAWN_EGG)){
server.runOnServer(s->{var p=player(s);equip(p,item,FamiliarPersonality.PACIFIST,3500);p.teleportTo(.5,1,.5);
for(int x=-3;x<=3;x++)for(int y=1;y<=12;y++)p.level().setBlockAndUpdate(new BlockPos(x,y,4),Blocks.STONE.defaultBlockState());
for(int x=-3;x<=3;x++)for(int z=-1;z<=4;z++)p.level().setBlockAndUpdate(new BlockPos(x,10,z),Blocks.STONE.defaultBlockState());
});c.waitTicks(15);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);pet.setPos(.5,1,1.5);pet.setDeltaMovement(Vec3.ZERO);p.setShiftKeyDown(false);check(CarryService.interact(p,pet)&&p.getVehicle()==pet,"Mount colossal spider");});c.waitTicks(5);
c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);});c.getInput().holdKey(o->o.keyUp);c.waitTicks(70);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet.getY()>3,"Spider climbs real wall: "+item+" pos="+pet.position());check(p.level().noBlockCollision(p,p.getBoundingBox().deflate(.02)),"Rider cannot enter ceiling");check(p.level().noBlockCollision(pet,pet.getBoundingBox().deflate(.02)),"Mount cannot enter wall");System.out.println("FAMILIAR071_CLIMB_PASS "+item+" y="+pet.getY());});
c.takeScreenshot("familiar071-climb-"+item);c.getInput().releaseKey(o->o.keyUp);c.waitTicks(10);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);pet.setPos(8.5,1,1.5);pet.setDeltaMovement(Vec3.ZERO);});c.waitTicks(5);c.getInput().holdKey(o->o.keyUp);c.waitTicks(25);c.getInput().releaseKey(o->o.keyUp);
server.runOnServer(s->{var p=player(s);check(FamiliarService.active(p).getY()<1.5,"Climbing never lifts in open air");p.stopRiding();});
}
server.runOnServer(s->{var p=player(s);p.teleportTo(.5,1,.5);equip(p,Items.STRIDER_SPAWN_EGG,FamiliarPersonality.PACIFIST,3500);
for(int x=-14;x<=-4;x++)for(int z=-4;z<=12;z++)for(int y=1;y<=3;y++)p.level().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.LAVA.defaultBlockState());
var pet=FamiliarService.active(p);pet.setPos(-9.5,3.5,.5);pet.setDeltaMovement(Vec3.ZERO);p.teleportTo(-9.5,5.5,.5);p.setShiftKeyDown(false);check(CarryService.interact(p,pet)&&p.getVehicle()==pet,"Mount colossal Strider");p.getAbilities().invulnerable=false;
});c.waitTicks(20);c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);});c.getInput().holdKey(o->o.keyUp);c.waitTicks(20);c.getInput().releaseKey(o->o.keyUp);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet.getY()>=3.4,"Strider stays on lava surface: "+pet.position());check(pet.getZ()>2,"Strider actually moves across lava");check(!p.isOnFire()&&!p.isInLava(),"Rider stays safely above lava without fire immunity buff");check(!p.hasEffect(net.minecraft.world.effect.MobEffects.FIRE_RESISTANCE),"No invented rider fire resistance");p.getAbilities().invulnerable=true;p.stopRiding();p.teleportTo(.5,1,.5);
System.out.println("FAMILIAR071_STRIDER_PASS: native lava support and real directional input");
});
for(var item:List.of(Items.NAUTILUS_SPAWN_EGG,Items.ZOMBIE_NAUTILUS_SPAWN_EGG))for(int size:new int[]{2500,6000}){
server.runOnServer(s->{var p=player(s);p.getAbilities().invulnerable=true;p.removeEffect(net.minecraft.world.effect.MobEffects.BREATH_OF_THE_NAUTILUS);p.teleportTo(10.5,1,10.5);equip(p,item,FamiliarPersonality.PACIFIST,size);
for(int x=6;x<=15;x++)for(int z=6;z<=15;z++)for(int y=1;y<=12;y++)p.level().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.WATER.defaultBlockState());
check(!p.hasEffect(net.minecraft.world.effect.MobEffects.BREATH_OF_THE_NAUTILUS),"Equipping alone does not grant mount breath");
var pet=FamiliarService.active(p);pet.setPos(10.5,2,10.5);pet.setDeltaMovement(Vec3.ZERO);p.setShiftKeyDown(false);check(CarryService.interact(p,pet)&&p.getVehicle()==pet,"Mount Nautilus "+size);
check(p.hasEffect(net.minecraft.world.effect.MobEffects.BREATH_OF_THE_NAUTILUS),"Native effect applies immediately on mount");
});c.waitTicks(8);
server.runOnServer(s->{var p=player(s);check(p.isUnderWater(),"Rider is actually submerged");p.getAbilities().invulnerable=false;p.setAirSupply(40);});c.waitTicks(90);
server.runOnServer(s->{var p=player(s);check(p.getVehicle()==FamiliarService.active(p),"Underwater rider stays mounted");check(p.getAirSupply()==40,"Native breath freezes air across multiple refreshes: "+item+" size="+size+" air="+p.getAirSupply());
check(!p.hasEffect(net.minecraft.world.effect.MobEffects.WATER_BREATHING),"Uses Breath of the Nautilus, not regenerating Water Breathing");p.stopRiding();p.teleportTo(10.5,2,10.5);
});c.waitTicks(72);
server.runOnServer(s->{var p=player(s);check(!p.hasEffect(net.minecraft.world.effect.MobEffects.BREATH_OF_THE_NAUTILUS),"Effect expires naturally after dismount");check(p.getAirSupply()<40,"Air consumption resumes after dismount");p.getAbilities().invulnerable=true;p.setAirSupply(p.getMaxAirSupply());p.teleportTo(.5,1,.5);
System.out.println("FAMILIAR071_NAUTILUS_PASS "+item+" size="+size+" native frozen air and dismount expiry");
});
}
server.runOnServer(s->{var p=player(s);equip(p,Items.VILLAGER_SPAWN_EGG,FamiliarPersonality.AGGRESSIVE,1000);savedId.set(FamiliarBattle.bond(p).id());savedPersonality.set(FamiliarPersonality.of(FamiliarBattle.bond(p)));FamiliarBattle.flush(s);s.saveEverything(true,true,true);});
}finally{intro.set(false);c.getInput().releaseKey(o->o.keyUp);}
try(var world=save.open()){
c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
world.getServer().runOnServer(s->{var b=FamiliarBattle.bond(player(s));check(savedId.get().equals(b.id())&&savedPersonality.get()==FamiliarPersonality.of(b),"Individual and personality survive a real world reopen");});
}
System.out.println("FAMILIAR071_PASS: four automatic response policies, explicit damage, villager exceptions, 88 species distributions, native spider/cave-spider climbing, Nautilus/Zombie Nautilus rider breath at both size limits, Strider lava and persistence");
}
private static void distributions(MinecraftServer s){
var random=new Random(71);var profiles=FamiliarBattle.world(s).catalog.profiles();check(profiles.size()==88,"88 profiles remain loaded");
for(var profile:profiles){var counts=new EnumMap<FamiliarPersonality,Integer>(FamiliarPersonality.class);
for(int i=0;i<4000;i++){var id=new UUID(random.nextLong(),random.nextLong());var p=FamiliarPersonality.of(id,profile.id());counts.merge(p,1,Integer::sum);
var bond=FamiliarBond.create(id,profile,0,"test",UUID.randomUUID());check(p==FamiliarPersonality.of(bond.charge(100,profile.health()).remember("journey","test")),"Levels and memories preserve personality");
}
check(counts.size()==4,"Every species allows exceptions: "+profile.id());
if(profile.id().equals("sanctuary:villager"))check(counts.get(FamiliarPersonality.PACIFIST)>1800,"Villagers tend to be pacifist");
if(profile.id().equals("sanctuary:vindicator"))check(counts.get(FamiliarPersonality.AGGRESSIVE)>2500,"Vindicators tend to be aggressive");
}
System.out.println("FAMILIAR071_DISTRIBUTIONS_PASS: 352000 deterministic identities across 88 species");
}
private static void equip(ServerPlayer p,Item item,FamiliarPersonality personality,int size){
p.stopRiding();CarryService.detach(p);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);p.removeAttached(FamiliarBattle.COMBAT_LOCK);p.removeAttached(FamiliarBattle.LAST_FAMILIAR);
var egg=new ItemStack(item);var scale=new CompoundTag();scale.putInt("schema",1);scale.putInt("per_mille",size);CustomData.update(DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,scale));
var profile=FamiliarBattle.world(p.level().getServer()).catalog.forEgg(egg);UUID id;do{id=UUID.randomUUID();}while(FamiliarPersonality.of(id,profile.id())!=personality);
FamiliarBattle.world(p.level().getServer()).store.put(FamiliarBond.create(id,profile,0,"test",p.getUUID()));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,FamiliarIdentity.assign(egg,id));
CompanionService.tick(p);p.tickCount+=10-p.tickCount%10;FamiliarService.tick(p);check(FamiliarService.active(p)!=null,"Familiar spawned: "+item);p.inventoryMenu.broadcastChanges();
}
private static Mob enemy(ServerPlayer p,double x,double y,double z){var e=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);e.setNoAi(true);e.getAttribute(Attributes.MAX_HEALTH).setBaseValue(100);e.setHealth(100);e.setPos(x,y,z);p.level().addFreshEntity(e);return e;}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
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);}
}
@@ -0,0 +1,100 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import fr.koka.sanctuary.mixin.MultiblockFurnaceAccess;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.*;
import net.minecraft.world.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.entity.player.*;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.phys.*;
public final class Fourneau085ClientChecks implements FabricClientGameTest {
private static final BlockPos ORIGIN=new BlockPos(15,80,15);
private static MultiblockData.Machine oven;
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Fourneau085 "+UUID.randomUUID());s.setSeed("85");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var l=p.level();
p.setGameMode(GameType.CREATIVE);p.getAbilities().flying=true;p.onUpdateAbilities();
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
for(int x=8;x<25;x++)for(int z=8;z<25;z++)l.setBlockAndUpdate(new BlockPos(x,79,z),Blocks.STONE.defaultBlockState());
for(int x=0;x<3;x++)for(int y=0;y<3;y++)for(int z=0;z<3;z++)l.setBlockAndUpdate(ORIGIN.offset(x,y,z),Blocks.FURNACE.defaultBlockState());
((FurnaceBlockEntity)l.getBlockEntity(ORIGIN)).setItem(0,new ItemStack(Items.DIAMOND,17));
p.teleportTo(22.5,83,9.5);
});
c.waitFor(m->m.level.getBlockState(ORIGIN).is(Blocks.FURNACE)&&m.player.getY()>80,200);
c.runOnClient(m->{if(!m.gui.hud.isHidden())m.gui.hud.toggle();m.options.bobView().set(false);m.options.fov().set(60);m.getWindow().setWindowed(1280,800);});
c.getInput().lookAt(ORIGIN.offset(1,1,1));c.waitTicks(15);c.takeScreenshot("fourneau085-before");
for(var facing:Direction.Plane.HORIZONTAL){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var l=p.level();
if(oven!=null)Multiblocks.dissolve(l,oven);
oven=new MultiblockData.Machine(UUID.randomUUID(),ORIGIN,true,facing);
check(Multiblocks.assemble(p,oven),"Assembly for "+facing);
var right=facing.getCounterClockWise();
p.teleportTo(16.5+facing.getStepX()*7+right.getStepX()*5,83,16.5+facing.getStepZ()*7+right.getStepZ()*5);
});
c.waitFor(m->FourneauModels.modelAt(ORIGIN)!=null&&FourneauModels.modelAt(ORIGIN).getPath().contains(facing.getName()),200);
c.waitTicks(8);c.getInput().lookAt(ORIGIN.offset(1,1,1));c.waitTicks(15);
c.runOnClient(m->{for(int x=0;x<3;x++)for(int y=0;y<3;y++)for(int z=0;z<3;z++)check(FourneauModels.modelAt(ORIGIN.offset(x,y,z))!=null,"All 27 components synchronized across four chunks");});
c.takeScreenshot("fourneau085-"+facing.getName());
}
// Resource reload must retain the synchronized appearance and rebuild native models.
var reload=c.computeOnClient(m->m.reloadResourcePacks());c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(10);
c.takeScreenshot("fourneau085-reload");
c.runOnClient(m->{var before=FourneauModels.modelAt(ORIGIN);
FourneauModels.apply(new FourneauAppearance.Update(net.minecraft.resources.Identifier.withDefaultNamespace("the_nether"),net.minecraft.world.level.ChunkPos.containing(ORIGIN),ORIGIN,Direction.NORTH,FourneauAppearance.Action.CLEAR_CHUNK));
check(before.equals(FourneauModels.modelAt(ORIGIN)),"Other dimension cannot erase the current facade");});
server.runOnServer(s->{var l=s.getPlayerList().getPlayers().getFirst().level();var furnace=(FurnaceBlockEntity)l.getBlockEntity(ORIGIN.offset(1,0,0));
furnace.setItem(0,new ItemStack(Items.RAW_IRON,64));furnace.setItem(1,new ItemStack(Items.COAL,64));});
c.waitFor(m->m.level.getBlockState(ORIGIN.offset(1,0,0)).getValue(AbstractFurnaceBlock.LIT),200);c.waitTicks(10);
c.runOnClient(m->check(FourneauModels.modelAt(ORIGIN)!=null,"Lit updates retain assembled model"));c.takeScreenshot("fourneau085-lit");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var l=p.level();Multiblocks.dissolve(l,oven);
check(((FurnaceBlockEntity)l.getBlockEntity(ORIGIN)).getItem(0).getCount()==17,"Dissociation retains original furnace contents");});
c.waitFor(m->FourneauModels.modelAt(ORIGIN)==null,200);c.waitTicks(15);c.takeScreenshot("fourneau085-separated");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(Multiblocks.assemble(p,oven),"Reassemble");});
c.waitFor(m->FourneauModels.modelAt(ORIGIN)!=null,200);
// Real chunk unload/re-entry exercises native PlayerChunkSender synchronization.
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(400.5,83,400.5));
c.waitFor(m->FourneauModels.modelAt(ORIGIN)==null,500);
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(22.5,83,9.5));
c.waitFor(m->FourneauModels.modelAt(ORIGIN)!=null&&m.level.getBlockState(ORIGIN).is(Blocks.FURNACE),500);
c.getInput().lookAt(ORIGIN.offset(1,1,1));c.waitTicks(15);c.takeScreenshot("fourneau085-return");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.level().destroyBlock(ORIGIN.offset(2,2,2),true);});
c.waitFor(m->FourneauModels.modelAt(ORIGIN)==null,200);c.waitTicks(10);c.takeScreenshot("fourneau085-broken");
System.out.println("FOURNEAU085_PASS: four orientations, four chunks, native models, resource reload, unload/re-entry, stock preservation, dismantle and break");
}
}
private static net.minecraft.client.input.KeyEvent enter(){return new net.minecraft.client.input.KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,96 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import fr.koka.sanctuary.mixin.MultiblockFurnaceAccess;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.*;
import net.minecraft.world.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.entity.player.*;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.phys.*;
public final class Fut086ClientChecks implements FabricClientGameTest {
private static final BlockPos ORIGIN=new BlockPos(15,84,15),OVEN=new BlockPos(23,84,15);
private static MultiblockData.Machine cask,oven;
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Fut086 "+UUID.randomUUID());s.setSeed("86");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var l=p.level();
p.setGameMode(GameType.CREATIVE);p.getAbilities().flying=true;p.onUpdateAbilities();
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
for(int x=8;x<31;x++)for(int z=8;z<25;z++)l.setBlockAndUpdate(new BlockPos(x,79,z),Blocks.STONE.defaultBlockState());
cask=new MultiblockData.Machine(UUID.randomUUID(),ORIGIN,false,Direction.NORTH);
oven=new MultiblockData.Machine(UUID.randomUUID(),OVEN,true,Direction.NORTH);
for(var pos:cask.members())l.setBlockAndUpdate(pos,Blocks.BARREL.defaultBlockState());
for(var pos:oven.members())l.setBlockAndUpdate(pos,Blocks.FURNACE.defaultBlockState());
var stock=new MultiblockContainer(l,cask);stock.setItem(0,new ItemStack(Items.DIAMOND,17));stock.setItem(728,new ItemStack(Items.EMERALD,23));
p.teleportTo(22.5,87,9.5);
});
c.waitFor(m->m.level.getBlockState(ORIGIN).is(Blocks.BARREL)&&m.player.getY()>84,200);
c.runOnClient(m->{if(!m.gui.hud.isHidden())m.gui.hud.toggle();m.options.bobView().set(false);m.options.fov().set(60);m.getWindow().setWindowed(1280,800);});
c.getInput().lookAt(ORIGIN.offset(1,1,1));c.waitTicks(15);c.takeScreenshot("fut086-before");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(Multiblocks.assemble(p,cask),"Cask assembles");check(Multiblocks.assemble(p,oven),"Furnace still assembles");});
c.waitFor(m->FourneauModels.modelAt(ORIGIN)!=null&&FourneauModels.modelAt(OVEN)!=null,200);c.waitTicks(15);
c.runOnClient(m->{check(FourneauModels.modelAt(ORIGIN).getPath().startsWith("block/fut/"),"Cask packet selects its own textures");check(FourneauModels.modelAt(OVEN).getPath().startsWith("block/fourneau/"),"Furnace keeps its original textures");});
c.takeScreenshot("fut086-top-side");
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(22.5,79,9.5));
c.waitFor(m->m.player.getY()<81,200);c.getInput().lookAt(ORIGIN.offset(1,1,1));c.waitTicks(12);c.takeScreenshot("fut086-bottom-side");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(16.5,84,12.5);Multiblocks.open(p,cask,ORIGIN,0,"",0);});
c.waitFor(m->m.gui.screen() instanceof MultiblockScreen,200);c.waitTicks(5);c.takeScreenshot("fut086-inventory");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var menu=(MultiblockMenu)p.containerMenu;
check(menu.getSlot(0).remove(7).getCount()==7,"Shared inventory remains accessible");
var stock=new MultiblockContainer(p.level(),cask);check(stock.getContainerSize()==729&&stock.getItem(0).getCount()==10&&stock.getItem(728).getCount()==23,"Native stock spans all 729 cells");
p.closeContainer();p.teleportTo(22.5,87,9.5);});
c.waitFor(m->m.gui.screen()==null&&m.player.getY()>84,200);c.getInput().lookAt(ORIGIN.offset(1,1,1));
var reload=c.computeOnClient(m->m.reloadResourcePacks());c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(10);c.takeScreenshot("fut086-reload");
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(400.5,87,400.5));
c.waitFor(m->FourneauModels.modelAt(ORIGIN)==null&&FourneauModels.modelAt(OVEN)==null,500);
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(22.5,87,9.5));
c.waitFor(m->FourneauModels.modelAt(ORIGIN)!=null&&FourneauModels.modelAt(OVEN)!=null&&m.level.getBlockState(ORIGIN).is(Blocks.BARREL),500);
c.runOnClient(m->{for(var pos:cask.members())check(FourneauModels.modelAt(pos)!=null,"All 27 cask tiles return across four chunks");});
c.getInput().lookAt(ORIGIN.offset(1,1,1));c.waitTicks(12);c.takeScreenshot("fut086-return");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();Multiblocks.dissolve(p.level(),cask);
var stock=new MultiblockContainer(p.level(),cask);check(stock.getItem(0).getCount()==10&&stock.getItem(728).getCount()==23,"Dismantle preserves real stock");});
c.waitFor(m->FourneauModels.modelAt(ORIGIN)==null,200);c.waitTicks(12);c.takeScreenshot("fut086-separated");
c.runOnClient(m->check(FourneauModels.modelAt(OVEN)!=null,"Dismantling cask does not erase neighboring oven facade"));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(Multiblocks.assemble(p,cask),"Reassemble");});
c.waitFor(m->FourneauModels.modelAt(ORIGIN)!=null,200);
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().level().destroyBlock(ORIGIN.offset(2,2,2),true));
c.waitFor(m->FourneauModels.modelAt(ORIGIN)==null,200);c.runOnClient(m->check(FourneauModels.modelAt(OVEN)!=null,"Break stays scoped to its machine"));
System.out.println("FUT086_PASS: complete top/side/bottom, mixed furnace and cask sync, resource reload, native chunk re-entry, 729-slot inventory, dismantle and break");
}
}
private static net.minecraft.client.input.KeyEvent enter(){return new net.minecraft.client.input.KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,64 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import jdk.jfr.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave;
import net.fabricmc.fabric.api.event.lifecycle.v1.*;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
/** Observation only: fixed seed, full native world creation, same JVM and fresh development saves. */
public final class Loading063ClientAudit implements FabricClientGameTest {
private static volatile long origin;
private static volatile String scenario="menu";
private static final List<String> EVENTS=new java.util.concurrent.CopyOnWriteArrayList<>();
private static void mark(String event){String row=scenario+"\t"+((System.nanoTime()-origin)/1_000_000)+"\t"+event;EVENTS.add(row);System.out.println("LOADING063\t"+row);}
public void runTest(ClientGameTestContext c){
origin=System.nanoTime();mark("test starts; JVM max heap="+Runtime.getRuntime().maxMemory()+"; processors="+Runtime.getRuntime().availableProcessors()+"; launch="+ProcessHandle.current().info().startInstant().orElse(null));
var observing=new AtomicBoolean(true);var last=new AtomicReference<>("");var hello=new AtomicBoolean();var introFinished=new AtomicBoolean();
ClientTickEvents.END_CLIENT_TICK.register(m->{if(!observing.get())return;String screen=m.gui.screen()==null?"world":m.gui.screen().getClass().getSimpleName();if(!screen.equals(last.getAndSet(screen)))mark("screen "+screen);
if(m.gui.screen() instanceof IntroScreen intro&&intro.isFinished()&&introFinished.compareAndSet(false,true))mark("intro finished; waiting under flash");
if(m.gui.screen() instanceof HelloWorldScreen s&&hello.compareAndSet(false,true)){
mark("hello ready");IntroClient.setQuickTestIntro(false);Starter039TestSupport.selectFirst(s);
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());
}
});
ServerLifecycleEvents.SERVER_STARTING.register(s->{if(observing.get())mark("server starting; initialized="+s.getWorldData().overworldData().isInitialized());});
ServerLevelEvents.LOAD.register((s,l)->{if(observing.get())mark("level loaded "+l.dimension().identifier());});
ServerLifecycleEvents.SERVER_STARTED.register(s->{if(observing.get())mark("server started");});
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;});
TestWorldSave island=null;
try{
Files.createDirectories(Path.of("loading063"));
for(String preset:List.of("sanctuary:sanctuary","minecraft:flat","minecraft:normal")){
scenario=preset.replace(':','-');hello.set(false);introFinished.set(false);last.set("");origin=System.nanoTime();mark("create requested; seed=42; structures=true; render=8; simulation=8");
try(var recording=new Recording(Configuration.getConfiguration("profile"))){recording.start();
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Loading063 "+UUID.randomUUID());s.setSeed("42");s.setAllowCommands(true);s.setGenerateStructures(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals(preset)).findFirst().orElseThrow());}).create()){
if(preset.startsWith("sanctuary:"))island=world.getWorldSave();
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),3000);mark("playable");
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;mark("mode="+(fr.koka.sanctuary.progression.ProgressionService.session(s)!=null)+"; generator="+s.overworld().getChunkSource().getGenerator().getClass().getSimpleName());});
c.waitTicks(40);c.takeScreenshot("loading063-"+scenario);
}recording.stop();recording.dump(Path.of("loading063",scenario+".jfr"));
}
if(preset.startsWith("sanctuary:")){
scenario="sanctuary-reopen";hello.set(false);introFinished.set(false);last.set("");origin=System.nanoTime();mark("open requested");
try(var recording=new Recording(Configuration.getConfiguration("profile"))){recording.start();
try(var world=island.open()){c.waitFor(m->m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),3000);mark("playable");c.waitTicks(20);}
recording.stop();recording.dump(Path.of("loading063",scenario+".jfr"));
}
}
}
Files.write(Path.of("loading063","timings.tsv"),EVENTS);System.out.println("LOADING063_AUDIT_PASS");
}catch(Exception e){throw new AssertionError(e);}finally{observing.set(false);}
}
private static KeyEvent enter(){return new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);}
}
@@ -0,0 +1,125 @@
package fr.koka.sanctuary.client;
import com.google.gson.JsonParser;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.loading.*;
import fr.koka.sanctuary.worldgen.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.GenericMessageScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
public final class Loading064ClientChecks implements FabricClientGameTest {
private record Fixture(int value) {}
public void runTest(ClientGameTestContext c) {
cacheFailures();
var observing=new AtomicBoolean(true);var lastHello=new AtomicReference<HelloWorldScreen>();
var phases=new java.util.HashSet<String>();
ClientTickEvents.END_CLIENT_TICK.register(m->{if(!observing.get())return;
if(m.gui.screen() instanceof HelloWorldScreen s&&lastHello.getAndSet(s)!=s){
IntroClient.setQuickTestIntro(false);Starter039TestSupport.selectFirst(s);
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());
}
});
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;});
var baseline=new AtomicReference<Evidence>();var directory=new AtomicReference<Path>();TestWorldSave save;
long began=System.nanoTime();
try {
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Loading064 "+UUID.randomUUID());s.setSeed("42");s.setAllowCommands(true);s.setGenerateStructures(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());}).create()) {
save=world.getWorldSave();
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),3000);
System.out.println("LOADING064_COLD_PLAYABLE_MS="+(System.nanoTime()-began)/1_000_000);
world.getServer().runOnServer(s->{var g=(SanctuaryChunkGenerator)s.overworld().getChunkSource().getGenerator();
check(g.planCache().writes()>0,"Cold world saved derived plans");directory.set(g.planCache().directory());baseline.set(evidence(g));
s.getPlayerList().getPlayers().getFirst().getAbilities().invulnerable=true;
});
phases.addAll(PreloadProgress.observedPhases());
c.takeScreenshot("loading064-arrival");
c.runOnClient(m->{PreloadProgress.begin(m.getSingleplayerServer());PreloadProgress.stage(m.getSingleplayerServer(),"water");m.gui.setScreen(new GenericMessageScreen(Component.translatable("menu.preparingSpawn")));});
c.waitTicks(3);c.takeScreenshot("loading064-preload-en");
c.runOnClient(m->{m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());PreloadProgress.stage(m.getSingleplayerServer(),"heritage");});
c.waitTicks(3);c.takeScreenshot("loading064-preload-fr");
c.runOnClient(m->{m.gui.setScreen(null);PreloadProgress.clear(m.getSingleplayerServer());});
}
for(int pass=0;pass<2;pass++) {
if(pass==1)try(var files=Files.list(directory.get())) {
Path core=files.filter(p->p.getFileName().toString().startsWith("core-")).findFirst().orElseThrow();Files.writeString(core,"interrupted cache write");
}
began=System.nanoTime();
try(var world=save.open()) {
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),3000);
System.out.println("LOADING064_"+(pass==0?"WARM":"RECOVERED")+"_PLAYABLE_MS="+(System.nanoTime()-began)/1_000_000);
final int index=pass;
world.getServer().runOnServer(s->{var g=(SanctuaryChunkGenerator)s.overworld().getChunkSource().getGenerator();
check(g.planCache().hits()>0,"Reopened world read disk cache");
if(index==0)check(g.planCache().misses()==0&&g.planCache().writes()==0,"Warm reopen restores all plans without recomputing");
if(index==1)check(g.planCache().misses()>0&&g.planCache().writes()>0,"Corrupted core cache recomputed and replaced");
var now=evidence(g);check(baseline.get().equals(now),"Same plans, fluid geometry, block writes, loot and protection after restore/recovery");
System.out.println("LOADING064_CACHE_STATS hits="+g.planCache().hits()+" misses="+g.planCache().misses()+" writes="+g.planCache().writes()+" blockWrites="+now.writes()+" blockHash="+now.blocks());
});
check(PreloadProgress.current()==null,"Progress cleared after admission");
}
}
check(phases.containsAll(Set.of("cache_check","water","transit","heritage","village")),"Real phases observed: "+phases);
for(String lang:List.of("fr_fr","en_us"))c.runOnClient(m->{m.getLanguageManager().setSelected(lang);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());for(String phase:phases)check(!Component.translatable("sanctuary.preload."+phase).getString().startsWith("sanctuary."),"Localized phase "+lang+" "+phase);});
System.out.println("LOADING064_PASS phases="+phases);
}catch(Exception e){throw new AssertionError(e);}finally{observing.set(false);}
}
private record Evidence(com.google.gson.JsonElement core,PopulationHydrology.Plan.Snapshot water,PopulationLavaDeposit.Plan.Snapshot lava,long blocks,long writes,long protection) {}
private static Evidence evidence(SanctuaryChunkGenerator g) {
var root=g.source().islands().stream().filter(fr.koka.sanctuary.expansion.ExpansionIsland::origin).findFirst().orElseThrow();
var region=PopulationHydrologyRuntime.regionPlan(g.delegate(root),g.randomState(root),new IslandCapacity.Region(0,0));
// Reconstructed fluid indexes must agree with every retained geometric cell.
for(var cell:region.water().cells())check(region.water().cellsAt(cell.x(),cell.z()).contains(cell)&&region.water().cellsInChunk(cell.x()>>4,cell.z()>>4).contains(cell),"Hydrology indexes retain cells");
var writer=new Writer();
g.transitPlan21().modules().forEach(m->fr.koka.sanctuary.worldgen.transit.TransitRenderer21.render(m,writer));
g.ruinsPlan17().networks().forEach(n->n.rooms().forEach(r->fr.koka.sanctuary.worldgen.ruins.RuinsRenderer17.render(r,writer)));
g.heritagePlan19().sites().forEach(s->fr.koka.sanctuary.worldgen.heritage.HeritageRenderer19.render(s,writer));
g.heritagePlan19().segments().forEach(s->fr.koka.sanctuary.worldgen.heritage.HeritageRenderer19.renderSegment(s,writer));
if(g.mineralSanctuary23().chamber()!=null)fr.koka.sanctuary.worldgen.mineral.MineralSanctuaryRenderer23.render(g.mineralSanctuary23().chamber(),writer);
long protection=1;
for(int x=-352;x<=352;x+=16)for(int z=-352;z<=352;z+=16)for(int y=16;y<384;y+=8)protection=31*protection+(g.protectsCityDecoration(new BlockPos(x,y,z))?1:0);
return new Evidence(JsonParser.parseString(g.corePlanSignature()),region.water().snapshot(),region.lava().snapshot(),writer.hash,writer.writes,protection);
}
private static final class Writer implements fr.koka.sanctuary.worldgen.city.LostCityRenderer.BlockWriter {
long hash=0xcbf29ce484222325L,writes;
void add(long v){hash=(hash^v)*0x100000001b3L;}
public void set(int x,int y,int z,BlockState state){add(x);add(y);add(z);add(Block.getId(state));writes++;}
public void chest(int x,int y,int z,net.minecraft.resources.ResourceKey<net.minecraft.world.level.storage.loot.LootTable> table,long seed){add(x);add(y);add(z);add(table.identifier().toString().hashCode());add(seed);writes++;}
}
private static void cacheFailures() {
try {
Path root=Files.createTempDirectory("loading064-cache-");var cache=new DerivedPlanCache(root,"one");
check(cache.read("test",Fixture.class,x->x)==null,"Missing cache is a miss");cache.write("test",new Fixture(7));
check(cache.read("test",Fixture.class,x->x).value()==7,"Round trip");
var changed=new DerivedPlanCache(root,"two");check(changed.read("test",Fixture.class,x->x)==null,"Changed identity invalidates");
Files.createDirectories(changed.directory());Files.copy(cache.directory().resolve("test.json.gz"),changed.directory().resolve("test.json.gz"));
check(changed.read("test",Fixture.class,x->x)==null,"Even copied cache verifies embedded identity");
var file=cache.directory().resolve("test.json.gz");
com.google.gson.JsonObject envelope;
try(var in=new java.util.zip.GZIPInputStream(Files.newInputStream(file))){envelope=JsonParser.parseString(new String(in.readAllBytes(),java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject();}
envelope.addProperty("payload","{\"value\":8}");
try(var out=new java.util.zip.GZIPOutputStream(Files.newOutputStream(file))){out.write(envelope.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));}
check(cache.read("test",Fixture.class,x->x)==null,"Payload checksum verified before decoding");cache.write("test",new Fixture(7));
check(cache.read("test",Fixture.class,x->{throw new IllegalArgumentException("invalid geometry");})==null,"Validation failure falls back");
Files.writeString(cache.directory().resolve("test.json.gz"),"truncated");check(cache.read("test",Fixture.class,x->x)==null,"Truncated gzip falls back");
cache.write("test",new Fixture(9));check(cache.read("test",Fixture.class,x->x).value()==9,"Bad cache replaceable");
try(var files=Files.walk(root)){for(Path p:files.sorted(Comparator.reverseOrder()).toList())Files.delete(p);}
}catch(Exception e){throw new AssertionError(e);}
}
private static KeyEvent enter(){return new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String msg){if(!ok)throw new AssertionError(msg);}
}
@@ -0,0 +1,80 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.loading.PreloadProgress;
import java.util.concurrent.atomic.AtomicReference;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.screens.GenericMessageScreen;
import net.minecraft.client.gui.screens.LevelLoadingScreen;
import net.minecraft.client.multiplayer.LevelLoadTracker;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.progress.ChunkLoadStatusView;
import net.minecraft.server.level.progress.LevelLoadListener;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.resources.ResourceKey;
/** Render the actual mixins without creating a world or changing its loading lifecycle. */
public final class Loading068ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var previous=c.computeOnClient(m->m.gui.screen());
int scale=c.computeOnClient(m->m.options.guiScale().get());
String language=c.computeOnClient(m->m.getLanguageManager().getSelected());
var progress=progress();var old=progress.get();
var state=new PreloadProgress.State(null,"cache_read",System.nanoTime());
try {
progress.set(state);
c.runOnClient(m->{
m.options.guiScale().set(3);m.resizeGui();
m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
m.gui.setScreen(new GenericMessageScreen(Component.translatable("menu.preparingSpawn")) {
@Override public void tick() {} // No tick-based animation can pass this fixture.
});
});
for(int frame=0;frame<8;frame++) {
long until=System.nanoTime()+350_000_000L;
c.waitFor(m->System.nanoTime()>=until,1200);
c.takeScreenshot("loading068-animation-"+frame);
check(progress.get()==state,"Rendering must not invent or advance a server phase");
}
for(int gui:new int[]{2,4}) {
c.runOnClient(m->{m.options.guiScale().set(gui);m.resizeGui();});
c.waitTicks(3);c.takeScreenshot("loading068-fr-scale-"+gui);
}
c.runOnClient(m->{
m.options.guiScale().set(3);m.resizeGui();
m.getLanguageManager().setSelected("en_us");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
progress.set(new PreloadProgress.State(null,"water",state.started()));
});
c.waitTicks(3);c.takeScreenshot("loading068-en-water");
progress.set(null);
c.runOnClient(m->m.gui.setScreen(new GenericMessageScreen(Component.literal("Native message outside preparation"))));
c.waitTicks(3);c.takeScreenshot("loading068-native-message");
for(boolean grid:new boolean[]{false,true}) {
c.runOnClient(m->{
var tracker=new LevelLoadTracker();
tracker.start(LevelLoadListener.Stage.LOAD_INITIAL_CHUNKS,100);
tracker.update(LevelLoadListener.Stage.LOAD_INITIAL_CHUNKS,42,100);
if(grid)tracker.setServerChunkStatusView(new ChunkLoadStatusView() {
public void moveTo(ResourceKey<Level> level,ChunkPos pos) {}
public int radius(){return 11;}
public ChunkStatus get(int x,int z){return Math.abs(x)+Math.abs(z)<9?ChunkStatus.FULL:ChunkStatus.TERRAIN;}
});
m.gui.setScreen(new LevelLoadingScreen(tracker,LevelLoadingScreen.Reason.OTHER));
});
c.waitTicks(12);c.takeScreenshot("loading068-terrain-"+grid);
}
System.out.println("LOADING068_PASS: native FR/EN, GUI 2/3/4, eight frames without screen ticks, real phase unchanged, native chunk progress retained");
} finally {
progress.set(old);
c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(previous);});
}
}
@SuppressWarnings("unchecked")
private static AtomicReference<PreloadProgress.State> progress() {
try {var field=PreloadProgress.class.getDeclaredField("CURRENT");field.setAccessible(true);return (AtomicReference<PreloadProgress.State>)field.get(null);}
catch(ReflectiveOperationException e){throw new AssertionError(e);}
}
private static void check(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,125 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.cycle.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.input.MouseButtonInfo;
import net.minecraft.client.gui.screens.advancements.*;
import fr.koka.sanctuary.mixin.client.NotificationAdvancementTabAccess;
import net.minecraft.resources.Identifier;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
/** Real native routes and server-confirmed actions, including while the integrated world is paused. */
public final class Menus083ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Menus083 "+UUID.randomUUID());s.setSeed("83");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.getAbilities().invulnerable=true;p.giveExperienceLevels(30);ProgressionService.send(p,"");});c.waitTicks(5);
server.runOnServer(s->{var p=player(s);p.getInventory().setItem(0,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.STONE));p.awardStat(net.minecraft.stats.Stats.ITEM_PICKED_UP.get(net.minecraft.world.item.Items.BREAD),3);p.awardStat(net.minecraft.stats.Stats.ENTITY_KILLED.get(net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getValue(net.minecraft.resources.Identifier.withDefaultNamespace("zombie"))),2);p.awardStat(net.minecraft.stats.Stats.JUMP,12);});
server.runOnServer(s->{var p=player(s);for(String id:List.of("husbandry/root","husbandry/plant_seed")){var a=s.getAdvancements().get(Identifier.withDefaultNamespace(id));for(String key:a.value().criteria().keySet())p.getAdvancements().award(a,key);}p.getAdvancements().award(s.getAdvancements().get(Identifier.withDefaultNamespace("husbandry/balanced_diet")),"rotten_flesh");});
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3)){
c.runOnClient(m->{m.getWindow().setWindowed(1280,800);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new PauseScreen(true));});c.waitTicks(3);
c.runOnClient(m->{var b=button(m.gui.screen(),"sanctuary.arena.title");check(b.getY()>0&&b.getY()+b.getHeight()<m.gui.screen().height,"Combat button in viewport");});
c.takeScreenshot("menus083-pause-"+locale+"-"+scale);
c.runOnClient(m->{check(find(m.gui.screen(),"gui.advancements")==null&&find(m.gui.screen(),"gui.stats")==null,"Grouped native pause pages");check(button(m.gui.screen(),"sanctuary.cycle.faction").getY()==button(m.gui.screen(),"sanctuary.arena.title").getY(),"Faction and battles share row");});
click(c,"sanctuary.progression.blocks");c.waitTicks(10);c.takeScreenshot("menus083-discovery-"+locale+"-"+scale);
click(c,"sanctuary.discovery.general");c.waitFor(m->hasText(m.gui.screen(),"sanctuary.discovery.count"),100);c.takeScreenshot("menus083-general-"+locale+"-"+scale);
click(c,"sanctuary.discovery.items");c.waitFor(m->hasText(m.gui.screen(),"sanctuary.discovery.count"),100);
c.runOnClient(m->{check(find(m.gui.screen(),"item.minecraft.bread")==null,"Unowned item hidden initially");check(!button(m.gui.screen(),"sanctuary.discovery.owned").active,"Catalogue required for historical items");button(m.gui.screen(),"block.minecraft.stone");});
c.takeScreenshot("menus083-items-"+locale+"-"+scale);
click(c,"sanctuary.discovery.mobs");c.waitFor(m->hasText(m.gui.screen(),"sanctuary.discovery.count"),100);
c.runOnClient(m->{button(m.gui.screen(),"entity.minecraft.zombie");check(find(m.gui.screen(),"entity.minecraft.creeper")==null,"Unknown mob hidden");});
click(c,"entity.minecraft.zombie");c.takeScreenshot("menus083-mob-detail-"+locale+"-"+scale);c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
click(c,"sanctuary.discovery.recipes");c.takeScreenshot("menus083-recipes-"+locale+"-"+scale);c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
c.runOnClient(m->check(m.gui.screen() instanceof PauseScreen,"Sibling discovery tabs return straight to pause"));
click(c,"sanctuary.progression.history");c.runOnClient(m->check(m.gui.screen() instanceof SanctuaryAtlasScreen,"World opens map directly"));c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
click(c,"sanctuary.progression.identity");c.runOnClient(m->check(m.gui.screen() instanceof ProgressionScreen,"Inhabitant opens personal page"));c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
click(c,"sanctuary.cycle.faction");c.waitFor(m->CycleClient.view!=null,100);c.waitTicks(5);c.takeScreenshot("menus083-faction-"+locale+"-"+scale);c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
click(c,"sanctuary.arena.title");c.waitFor(m->m.gui.screen() instanceof ArenaScreen&&ArenaClient.view!=null,100);
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen() instanceof PauseScreen,"Arena returns to pause"));
c.runOnClient(m->{ArenaClient.open(m.gui.screen());m.gui.screen().onClose();});c.waitTicks(8);c.runOnClient(m->check(m.gui.screen() instanceof PauseScreen,"Late arena response does not reopen it"));
click(c,"sanctuary.progression.progress");c.runOnClient(m->{check(find(m.gui.screen(),"sanctuary.arena.title")==null,"No combat in progression");check(find(m.gui.screen(),"sanctuary.cycle.faction")==null,"No faction in progression");for(String tab:List.of("cycle","log"))button(m.gui.screen(),"sanctuary.cycle."+tab);button(m.gui.screen(),"gui.advancements");});
c.takeScreenshot("menus083-progress-"+locale+"-"+scale);
c.runOnClient(m->{var screen=(ProgressionScreen)m.gui.screen();check(button(screen,"sanctuary.cycle.cycle").getY()>=screen.contentTop+12,"XP has separate line");check(button(screen,"gui.advancements").getX()<screen.width/2,"Advancements above left skills");});
click(c,"gui.advancements");trackDiet(c);c.runOnClient(m->check(m.gui.screen() instanceof net.minecraft.client.gui.screens.advancements.AdvancementsScreen,"Full native advancements"));c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen() instanceof ProgressionScreen,"Advancements returns to progression"));c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
}
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());});
click(c,"sanctuary.progression.blocks");click(c,"sanctuary.discovery.recipes");click(c,"sanctuary.recipes.buy");c.waitFor(m->RecipeClient.knowledge!=null&&RecipeClient.knowledge.learned(),100);
click(c,"sanctuary.discovery.items");c.waitFor(m->hasText(m.gui.screen(),"sanctuary.discovery.count"),100);click(c,"sanctuary.discovery.owned");
c.runOnClient(m->{button(m.gui.screen(),"item.minecraft.bread");check(find(m.gui.screen(),"item.minecraft.diamond")==null,"Catalogue still hides unknown items");});
click(c,"item.minecraft.bread");c.takeScreenshot("menus083-item-detail");c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
click(c,"sanctuary.progression.progress");click(c,"sanctuary.cycle.cycle");c.waitFor(m->CycleClient.view!=null,100);c.waitTicks(5);
c.runOnClient(m->check(!button(m.gui.screen(),"sanctuary.cycle.ngp").active,"Incomplete prestige disabled"));click(c,"sanctuary.cycle.cycle_progress");
c.runOnClient(m->check(m.gui.screen() instanceof ProgressionScreen,"Prestige link returns to skills"));
c.runOnClient(m->widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.active&&b.visible&&b.getMessage().getString().equals("+")).findFirst().orElseThrow().onPress(enter()));
c.waitFor(m->ProgressionClient.progress.vitality()==1,100);
c.runOnClient(m->check(widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).noneMatch(b->b.visible&&b.getMessage().getString().equals("")),"Refund invisible in normal play"));
toggleOperator(c);c.waitFor(m->ProgressionClient.operator,150);
c.runOnClient(m->m.gui.setScreen(new ProgressionScreen(new PauseScreen(true))));c.waitTicks(4);glyph(c,"");
c.waitFor(m->ProgressionClient.progress.vitality()==0,100);c.runOnClient(m->check(m.player.experienceLevel==26,"Operator refund returns last purchase cost"));
c.takeScreenshot("menus083-refund");glyph(c,"+");c.waitFor(m->ProgressionClient.progress.vitality()==1,100);
toggleOperator(c);c.waitFor(m->!ProgressionClient.operator,150);c.runOnClient(m->m.gui.setScreen(new ProgressionScreen(new PauseScreen(true))));c.waitTicks(4);
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);click(c,"sanctuary.cycle.faction");c.waitTicks(8);
c.runOnClient(m->{check(!button(m.gui.screen(),"sanctuary.cycle.create").active,"Blank faction name disabled");edit(m.gui.screen()).setValue("A");check(!button(m.gui.screen(),"sanctuary.cycle.create").active,"Short name disabled");edit(m.gui.screen()).setValue("Navigateurs");});
click(c,"sanctuary.cycle.create");click(c,"sanctuary.cycle.cancel");c.runOnClient(m->check(CycleClient.view.faction()==null,"Cancel does not found"));
click(c,"sanctuary.cycle.create");click(c,"sanctuary.cycle.confirm");c.waitFor(m->CycleClient.view.faction()!=null,100);
c.runOnClient(m->{check(CycleClient.view.faction().capacity()==2,"Two founding places");check(!button(m.gui.screen(),"sanctuary.cycle.invite").active,"Blank invitation disabled");check(m.player.experienceLevel==15,"Catalogue, skill and faction paid exactly once");});
click(c,"sanctuary.cycle.faction_log");c.runOnClient(m->{check(hasText(m.gui.screen(),"sanctuary.cycle.log_line"),"Faction journal has founding event");check(!hasText(m.gui.screen(),"sanctuary.progression.history_purchase"),"No skill purchases in faction journal");check(find(m.gui.screen(),"sanctuary.cycle.cycle")==null,"No prestige in faction navigation");});
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);click(c,"sanctuary.progression.progress");click(c,"sanctuary.cycle.log");c.waitTicks(5);c.runOnClient(m->{check(hasText(m.gui.screen(),"sanctuary.cycle.log_line"),"Faction event visible");check(hasText(m.gui.screen(),"sanctuary.progression.history_purchase"),"Purchase visible in history");check(hasText(m.gui.screen(),"sanctuary.progression.history_refund"),"Refund visible in history");});c.takeScreenshot("menus083-history-fr");
click(c,"sanctuary.cycle.refresh");c.waitTicks(8);c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
server.runOnServer(s->{var p=player(s);var r=ProgressEffects.get(p).buy("names",4);for(String skill:ProgressRecord.SKILLS)while(r.rank(skill)<ProgressRecord.cap(skill))r=r.buy(skill,1);p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");});c.waitFor(m->ProgressionClient.progress.complete(),100);
click(c,"sanctuary.cycle.cycle");c.waitTicks(8);click(c,"sanctuary.cycle.ngp");click(c,"sanctuary.cycle.cancel");click(c,"sanctuary.cycle.ngp");click(c,"sanctuary.cycle.confirm");
c.waitFor(m->CycleClient.view.prestige()==1&&ProgressionClient.progress.cycle()==1,100);
c.runOnClient(m->{check(ProgressionClient.progress.inventoryRows()==1&&ProgressionClient.progress.names(),"Prestige resets skills and keeps ability");check(CycleClient.view.faction().name().equals("Navigateurs")&&CycleClient.view.charges()==1,"Faction and prestige charge preserved");check(!button(m.gui.screen(),"sanctuary.cycle.ngp").active,"Cannot repeat prestige");});
click(c,"sanctuary.cycle.log");c.waitTicks(5);c.takeScreenshot("menus083-prestige-history");c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(5);
System.out.println("MENUS083_PASS: FR/EN GUI 2/3, six pause destinations, independent categories, native statistics, knowledge gates, advancements, buy/refund, faction journal, history, prestige, late arena close");
}finally{intro.set(false);}
}
private static void glyph(ClientGameTestContext c,String glyph){c.runOnClient(m->widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.active&&b.visible&&b.getMessage().getString().equals(glyph)).findFirst().orElseThrow().onPress(enter()));c.waitTicks(5);}
private static void toggleOperator(ClientGameTestContext c){
c.runOnClient(m->{m.getLanguageManager().setSelected("en_us");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new WorldOptionsScreen(m.gui.screen(),m.level));});c.waitTicks(3);
for(String prefix:List.of("Allow Commands","Apply Changes"))c.runOnClient(m->{var w=widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(b->b.getMessage().getString().startsWith(prefix)).findFirst().orElseThrow();check(w.visible&&w.active,"Operator toggle accessible");if(w instanceof Button b)b.onPress(enter());else if(w instanceof CycleButton<?> b)b.onPress(enter());});
c.waitTicks(5);
}
private static void trackDiet(ClientGameTestContext c){
var id=Identifier.withDefaultNamespace("husbandry/balanced_diet");
c.runOnClient(m->{var adv=m.getConnection().getAdvancements();var root=adv.progress().keySet().stream().filter(a->a.id().toString().equals("minecraft:husbandry/root")).findFirst().orElseThrow();adv.setSelectedTab(root,true);});c.waitTicks(3);
c.runOnClient(m->{var screen=(AdvancementsScreen)m.gui.screen();var tab=(AdvancementTab)field(screen,"selectedTab");var access=(NotificationAdvancementTabAccess)tab;var w=access.sanctuary$widgets().values().stream().filter(a->a.getAdvancement().id().equals(id)).findFirst().orElseThrow();tab.scroll(100-w.getX()-access.sanctuary$scrollX(),45-w.getY()-access.sanctuary$scrollY());});c.waitTicks(2);
c.runOnClient(m->{var screen=(AdvancementsScreen)m.gui.screen();var access=(NotificationAdvancementTabAccess)field(screen,"selectedTab");var w=access.sanctuary$widgets().values().stream().filter(a->a.getAdvancement().id().equals(id)).findFirst().orElseThrow();var event=new MouseButtonEvent((int)field(screen,"leftPos")+9+Math.floor(access.sanctuary$scrollX())+w.getX()+12,(int)field(screen,"topPos")+18+Math.floor(access.sanctuary$scrollY())+w.getY()+12,new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0));boolean before=NotificationClient.isPinned(id);screen.mouseClicked(event,false);screen.mouseReleased(event);check(NotificationClient.isPinned(id)!=before,"Native click toggles advancement tracking through new route");});
}
private static Object field(AdvancementsScreen screen,String name){try{var f=AdvancementsScreen.class.getDeclaredField(name);f.setAccessible(true);return f.get(screen);}catch(Exception e){throw new AssertionError(e);}}
private static java.util.stream.Stream<net.minecraft.client.gui.components.events.GuiEventListener> widgets(net.minecraft.client.gui.components.events.GuiEventListener e){
return java.util.stream.Stream.concat(java.util.stream.Stream.of(e),e instanceof net.minecraft.client.gui.components.events.ContainerEventHandler h?h.children().stream().flatMap(Menus083ClientChecks::widgets):java.util.stream.Stream.empty());
}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static Button find(Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElse(null);}
private static Button button(Screen s,String key){return Objects.requireNonNull(find(s,key),"Missing button "+key+" on "+s.getClass().getSimpleName());}
private static void press(Screen s,String key){var b=button(s,key);check(b.active,"Disabled "+key);b.onPress(enter());}
private static void click(ClientGameTestContext c,String key){c.runOnClient(m->press(m.gui.screen(),key));c.waitTicks(5);}
private static EditBox edit(Screen s){return widgets(s).filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow();}
private static boolean hasText(Screen s,String key){return widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w->contains(w.getMessage(),key));}
private static boolean contains(Component c,String key){if(c.getContents() instanceof TranslatableContents t&&t.getKey().equals(key))return true;return c.getSiblings().stream().anyMatch(s->contains(s,key));}
private static void check(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,206 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import fr.koka.sanctuary.mixin.MultiblockFurnaceAccess;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.*;
import net.minecraft.world.*;
import net.minecraft.world.entity.player.*;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.phys.*;
public final class Multiblocks084ClientChecks implements FabricClientGameTest {
private static final BlockPos BARREL=new BlockPos(15,80,0),FURNACE=new BlockPos(15,80,7);
private static MultiblockData.Machine cask,oven;
private static io.netty.channel.embedded.EmbeddedChannel guestChannel;
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Multiblocks084 "+UUID.randomUUID());s.setSeed("84");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;var l=p.level();
var progress=ProgressRecord.INITIAL;var config=ProgressionService.session(s).config;for(int i=0;i<5;i++)progress=progress.buy("inventory",progress.cost("inventory",config));p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);ProgressionService.send(p,"");
for(int x=10;x<23;x++)for(int z=-4;z<14;z++)l.setBlockAndUpdate(new BlockPos(x,79,z),Blocks.STONE.defaultBlockState());
p.teleportTo(l,13.5,80,1.5,Set.of(),-90,0,true);
cask=structure(l,BARREL,false);oven=structure(l,FURNACE,true);
check(MultiblockData.get(l).at(BARREL)==null,"No automatic assembly");
var stock=new MultiblockContainer(l,cask);stock.setItem(0,new ItemStack(Items.STONE,64));
var diamond=new ItemStack(Items.DIAMOND,17);diamond.set(DataComponents.CUSTOM_NAME,Component.literal("Souvenir 084"));stock.setItem(728,diamond);
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Multiblocks.WRENCH));
wrench(p,BARREL);check(MultiblockData.get(l).at(BARREL)==null,"First click previews only");wrench(p,BARREL);
cask=MultiblockData.get(l).at(BARREL);check(cask!=null,"Second click assembles");
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.HOPPER));p.setShiftKeyDown(true);
check(UseBlockCallback.EVENT.invoker().interact(p,l,InteractionHand.MAIN_HAND,new BlockHitResult(Vec3.atCenterOf(BARREL),Direction.WEST,BARREL,false))==InteractionResult.PASS,"Sneaking permits attaching blocks to machine");
p.setShiftKeyDown(false);p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Multiblocks.WRENCH));
check(!MultiblockData.get(l).add(new MultiblockData.Machine(UUID.randomUUID(),BARREL,false,Direction.NORTH)),"Overlap rejected");
check(stock.getItem(728).getCount()==17,"Existing component data retained");
var data=MultiblockData.get(l);var json=MultiblockData.CODEC.encodeStart(JsonOps.INSTANCE,data).getOrThrow();var reloaded=MultiblockData.CODEC.parse(JsonOps.INSTANCE,json).getOrThrow();
check(reloaded.at(BARREL.offset(2,2,2)).id().equals(cask.id()),"Persistence reconstructs all members across chunks");
try{
var folder=java.nio.file.Files.createTempDirectory("sanctuary-multiblocks084-");
try(var disk=new net.minecraft.world.level.storage.SavedDataStorage(folder,s.getFixerUpper(),l.registryAccess())){var record=MultiblockData.read(disk);record.add(cask);disk.saveAndJoin();}
try(var disk=new net.minecraft.world.level.storage.SavedDataStorage(folder,s.getFixerUpper(),l.registryAccess())){check(MultiblockData.read(disk).at(BARREL.offset(2,2,2)).id().equals(cask.id()),"Actual SavedData file reload retains assembly");}
try(var files=java.nio.file.Files.walk(folder)){for(var file:files.sorted(java.util.Comparator.reverseOrder()).toList())java.nio.file.Files.delete(file);}
}catch(java.io.IOException error){throw new AssertionError(error);}
json.getAsJsonObject().addProperty("schema",99);check(MultiblockData.CODEC.parse(JsonOps.INSTANCE,json).error().isPresent(),"Unknown schema rejected");
var common=HopperBlockEntity.getContainerAt(l,BARREL);check(common.getContainerSize()==729,"Hopper sees whole cask");
check(HopperBlockEntity.addItem(null,common,new ItemStack(Items.COBBLESTONE,64),Direction.UP).isEmpty(),"Native hopper insertion into common cask");
l.setBlockAndUpdate(BARREL.below(),Blocks.HOPPER.defaultBlockState());var hopper=(HopperBlockEntity)l.getBlockEntity(BARREL.below());
check(HopperBlockEntity.suckInItems(l,hopper)&&hopper.getItem(0).is(Items.STONE),"Native hopper extracts from common cask");
l.setBlockAndUpdate(BARREL.below(),Blocks.STONE.defaultBlockState());
var remote=guest(p);remote.setPos(13.5,80,2.5);Multiblocks.open(p,cask,BARREL,13,"",0);Multiblocks.open(remote,cask,BARREL,13,"",0);
var a=(MultiblockMenu)p.containerMenu;var b=(MultiblockMenu)remote.containerMenu;
int overflow=a.slots.size()-1;check(overflow>=90,"Six-row inventory includes overflow slots");a.getSlot(overflow).set(new ItemStack(Items.STICK,23));check(!a.quickMoveStack(p,overflow).isEmpty()&&a.getSlot(overflow).getItem().isEmpty(),"Shift deposit from sixth inventory row reaches whole cask");
check(a.view.indices().length==27&&a.getSlot(26).getItem().getCount()==17,"Last page has 27 real cells");
a.getSlot(26).remove(7);check(b.getSlot(26).getItem().getCount()==10,"Second viewer sees same physical stack");
common.removeItem(728,3);check(a.getSlot(26).getItem().getCount()==7,"Hopper and players share stock");
var result=b.quickMoveStack(remote,26);check(result.getCount()==7&&stock.getItem(728).isEmpty(),"Shift take exactly once");
for(int i=0;i<remote.getInventory().getContainerSize();i++)remote.getInventory().setItem(i,new ItemStack(Items.DIRT,64));
stock.setItem(728,new ItemStack(Items.DIAMOND));check(b.quickMoveStack(remote,26).isEmpty()&&stock.getItem(728).getCount()==1,"Full player inventory leaves source intact");stock.setItem(728,ItemStack.EMPTY);
check(a.quickMoveStack(p,26).isEmpty(),"Other viewer cannot duplicate consumed stack");
var restored=new ItemStack(Items.DIAMOND,17);restored.set(DataComponents.CUSTOM_NAME,Component.literal("Souvenir 084"));stock.setItem(728,restored);
guestChannel.outboundMessages().clear();int oldRemoteId=remote.containerMenu.containerId;
((MultiblockMenu)remote.containerMenu).change(remote,0,"",0);guestChannel.runPendingTasks();
check(remote.containerMenu.containerId!=oldRemoteId,"Navigation still allocates a fresh menu ID");
check(guestChannel.outboundMessages().stream().noneMatch(packet->packet instanceof net.minecraft.network.protocol.game.ClientboundContainerClosePacket),"Navigation sends no intermediate close packet");
remote.closeContainer();guestChannel.runPendingTasks();
check(guestChannel.outboundMessages().stream().anyMatch(packet->packet instanceof net.minecraft.network.protocol.game.ClientboundContainerClosePacket),"Actual close still sends close packet");
remote.openMenu(new SimpleMenuProvider((id,inv,player)->ChestMenu.threeRows(id,inv),Component.literal("Unrelated")));
Multiblocks.closeViewers(l,cask);check(remote.containerMenu!=remote.inventoryMenu,"Machine changes leave unrelated nearby menus open");
remote.closeContainer();remote.discard();
Multiblocks.open(p,cask,BARREL,0,"",0);
// Same native block-entity serialization used when a world unloads/reloads.
var last=l.getBlockEntity(BARREL.offset(2,2,2));var tag=last.saveWithFullMetadata(l.registryAccess());
var restoredBe=(BarrelBlockEntity)BlockEntity.loadStatic(last.getBlockPos(),last.getBlockState(),tag,l.registryAccess());
check(ItemStack.matches(restoredBe.getItem(26),restored),"Native persistence retains custom stack components");
});
c.waitFor(m->m.gui.screen() instanceof MultiblockScreen,200);c.waitTicks(5);
for(String language:List.of("fr_fr","en_us"))for(int scale:List.of(2,3)){
c.runOnClient(m->{m.getWindow().setWindowed(1280,800);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());});c.waitTicks(3);c.takeScreenshot("multiblocks084-cask-"+language+"-"+scale);
c.runOnClient(m->{var screen=m.gui.screen();for(var child:screen.children())if(child instanceof AbstractWidget w)check(w.getX()>=0&&w.getX()+w.getWidth()<=screen.width&&w.getY()>=0&&w.getY()+w.getHeight()<=screen.height,"Controls fit GUI scale "+scale);});
}
// Regression beta.088: click several pages at the same physical cursor position.
double[] cursor=hoverButton(c,">");
for(int page=1;page<=3;page++){int expected=page;clickMouse(c);c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.page()==expected,200);c.waitTicks(4);assertCursor(c,cursor,"Next page "+page);}
cursor=hoverButton(c,"<");
for(int page=2;page>=1;page--){int expected=page;clickMouse(c);c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.page()==expected,200);c.waitTicks(4);assertCursor(c,cursor,"Previous page "+page);}
c.runOnClient(m->{m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.screen().children().stream().filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow().setValue("Roche");});
cursor=hoverButton(c,c.computeOnClient(m->Multiblocks.text("search").getString()));clickMouse(c);
c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.query().equals("Roche"),200);c.waitTicks(4);
assertCursor(c,cursor,"Search");
c.runOnClient(m->{var menu=(MultiblockMenu)m.player.containerMenu;check(menu.view.indices().length>0&&menu.getSlot(0).getItem().is(Items.STONE),"Localized French search maps to server-owned items");});
int previous=c.computeOnClient(m->m.player.containerMenu.containerId);
c.runOnClient(m->ClientPlayNetworking.send(new MultiblockMenu.Change(previous,0,"Souvenir",0)));
c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.containerId!=previous&&menu.view.query().equals("Souvenir"),200);
c.waitTicks(3);c.runOnClient(m->{var menu=(MultiblockMenu)m.player.containerMenu;check(menu.view.indices().length==1&&menu.getSlot(0).getItem().is(Items.DIAMOND),"Native filtered contents synchronized");});c.takeScreenshot("multiblocks084-search");
c.runOnClient(m->ClientPlayNetworking.send(new MultiblockMenu.Change(previous,13,"",0)));c.waitTicks(4);
c.runOnClient(m->check(((MultiblockMenu)m.player.containerMenu).view.query().equals("Souvenir"),"Stale page request rejected"));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var menu=(MultiblockMenu)p.containerMenu;int old=menu.containerId;
menu.setCarried(new ItemStack(Items.STICK));menu.change(p,1,"",0);check(p.containerMenu.containerId==old,"Cursor blocks page change");menu.setCarried(ItemStack.EMPTY);
var l=p.level();var before=new MultiblockContainer(l,cask).getItem(728).copy();Multiblocks.dissolve(l,cask);
check(((BarrelBlockEntity)l.getBlockEntity(BARREL.offset(2,2,2))).getItem(26).equals(before)||ItemStack.matches(((BarrelBlockEntity)l.getBlockEntity(BARREL.offset(2,2,2))).getItem(26),before),"Disassembly preserves named stack");
check(Multiblocks.assemble(p,cask),"Can reassemble current stock");
l.destroyBlock(BARREL,true);check(!Multiblocks.ready(l,cask),"Broken component invalidates menus immediately");
check(new MultiblockContainer(l,cask).getItem(728).getCount()==17,"Breaking other cell preserves remainder");
check(MultiblockData.get(l).at(BARREL.offset(2,2,2))==null,"Break dissolves membership immediately");
explosionCheck(p);
p.teleportTo(l,13.5,80,8.5,Set.of(),-90,0,true);check(Multiblocks.assemble(p,oven),"Assemble furnace");
cookChecks(p);Multiblocks.open(p,oven,FURNACE,0,"",2);
});
c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.furnace(),200);c.waitTicks(5);c.takeScreenshot("multiblocks084-furnace");
for(int section=0;section<3;section++){int expected=section;String key=new String[]{"inputs","fuel","outputs"}[section];cursor=hoverButton(c,c.computeOnClient(m->Multiblocks.text(key).getString()));clickMouse(c);c.waitFor(m->m.player.containerMenu instanceof MultiblockMenu menu&&menu.view.section()==expected,200);c.waitTicks(4);assertCursor(c,cursor,"Furnace section "+section);}
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitForScreen(null);
server.waitFor(s->{var p=s.getPlayerList().getPlayers().getFirst();return p.containerMenu==p.inventoryMenu;},200);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var l=p.level();var stock=new MultiblockContainer(l,oven);int before=items(stock);Multiblocks.dissolve(l,oven);check(items(stock)==before,"Furnace disassembly preserves current fuel/outputs");
// An absent distant chunk suspends membership, without creating or loading that chunk.
var far=new MultiblockData.Machine(UUID.randomUUID(),new BlockPos(300000,80,300000),true,Direction.NORTH);check(!l.hasChunkAt(far.origin()),"Fixture starts unloaded");MultiblockData.get(l).add(far);check(Multiblocks.state(l,far)==0&&!l.hasChunkAt(far.origin()),"Validation never loads remote chunks");MultiblockData.get(l).remove(far);
p.closeContainer();System.out.println("MULTIBLOCKS084_PASS: explicit preview/assembly, cross-chunk membership, schema, 729 slots, 2 server players, native hopper view, pages/search/new IDs/cursor, stationary mouse paging and furnace tabs, Escape closure, component preservation, break, shared native smelting/fuel/remainders/XP, no forced chunk loads, FR/EN GUI2/3");
});
}finally{intro.set(false);}
}
private static double[] hoverButton(ClientGameTestContext c,String text){
return c.computeOnClient(m->{var button=m.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(text)).findFirst().orElseThrow();var w=m.getWindow();
double x=button.getX()+button.getWidth()/2.0,y=button.getY()+button.getHeight()/2.0;
double sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();
org.lwjgl.sdl.SDLMouse.SDL_WarpMouseInWindow(w.handle(),(float)(x*sx),(float)(y*sy));
m.mouseHandler.onMove(w.handle(),x*sx,y*sy,(x-m.mouseHandler.getScaledXPos(w))*sx,(y-m.mouseHandler.getScaledYPos(w))*sy);m.mouseHandler.handleAccumulatedMovement();return new double[]{x,y};});
}
private static void clickMouse(ClientGameTestContext c){
c.runOnClient(m->{var button=new net.minecraft.client.input.MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0);m.mouseHandler.onButton(m.getWindow().handle(),button,1);m.mouseHandler.onButton(m.getWindow().handle(),button,0);});
}
private static void assertCursor(ClientGameTestContext c,double[] cursor,String step){
c.runOnClient(m->{check(Math.abs(m.mouseHandler.getScaledXPos(m.getWindow())-cursor[0])<1&&Math.abs(m.mouseHandler.getScaledYPos(m.getWindow())-cursor[1])<1,step+" keeps mouse over its button: expected "+Arrays.toString(cursor)+", got "+m.mouseHandler.getScaledXPos(m.getWindow())+","+m.mouseHandler.getScaledYPos(m.getWindow()));});
}
private static void cookChecks(ServerPlayer p){
var l=p.level();var stock=new MultiblockContainer(l,oven);
stock.setItem(0,new ItemStack(Items.RAW_IRON,8));stock.setItem(3,new ItemStack(Items.RAW_GOLD,8));stock.setItem(79,new ItemStack(Items.COAL,2));
var nativeBe=(AbstractFurnaceBlockEntity)stock.cell(0);
AbstractFurnaceBlockEntity.serverTick(l,nativeBe.getBlockPos(),nativeBe.getBlockState(),nativeBe);
check(((MultiblockFurnaceAccess)nativeBe).sanctuary$progress()==0,"Native tick cannot double-cook an assembled lane");
for(int tick=0;tick<201;tick++)MultiblockCooking.tick(l,oven);
check(stock.getItem(2).is(Items.IRON_INGOT)&&stock.getItem(5).is(Items.GOLD_INGOT),"Two lanes smelt with distant common fuel");
int coal=0;for(int i=1;i<81;i+=3)if(stock.getItem(i).is(Items.COAL))coal+=stock.getItem(i).getCount();check(coal==1,"Only one shared coal consumed");
int heat=0;for(int i=0;i<81;i+=3)heat+=((MultiblockFurnaceAccess)stock.cell(i)).sanctuary$heat();check(heat>=1180&&heat<=1210,"Heat cost reflects both lanes: "+heat);
var physical=(AbstractFurnaceBlockEntity)stock.cell(0);var saved=physical.saveWithFullMetadata(l.registryAccess());
var restored=BlockEntity.loadStatic(physical.getBlockPos(),physical.getBlockState(),saved,l.registryAccess());
check(((MultiblockFurnaceAccess)restored).sanctuary$heat()==((MultiblockFurnaceAccess)physical).sanctuary$heat(),"Native save preserves remaining shared heat");
check(((MultiblockFurnaceAccess)restored).sanctuary$progress()==((MultiblockFurnaceAccess)physical).sanctuary$progress(),"Native save preserves work in progress");
Multiblocks.open(p,oven,FURNACE,0,"",2);var menu=(MultiblockMenu)p.containerMenu;check(!menu.getSlot(0).mayPlace(new ItemStack(Items.STONE)),"Outputs reject deposits");check(!menu.quickMoveStack(p,0).isEmpty(),"Native result slot awards cooking XP/recipes");
stock.clearContent();for(int i=0;i<81;i+=3){var a=(MultiblockFurnaceAccess)stock.cell(i);a.sanctuary$heat(0);a.sanctuary$progress(0);}
stock.setItem(0,new ItemStack(Items.WET_SPONGE));stock.setItem(79,new ItemStack(Items.LAVA_BUCKET));
for(int tick=0;tick<205;tick++)MultiblockCooking.tick(l,oven);
check(stock.getItem(2).is(Items.SPONGE)&&stock.getItem(1).is(Items.WATER_BUCKET),"Native lava remainder and wet sponge interaction preserved");
stock.clearContent();for(int i=0;i<81;i+=3){var a=(MultiblockFurnaceAccess)stock.cell(i);a.sanctuary$heat(0);a.sanctuary$progress(0);}
stock.setItem(0,new ItemStack(Items.RAW_IRON));stock.setItem(2,new ItemStack(Items.IRON_INGOT,64));stock.setItem(79,new ItemStack(Items.COAL));
for(int tick=0;tick<30;tick++)MultiblockCooking.tick(l,oven);check(stock.getItem(79).getCount()==1&&stock.getItem(0).getCount()==1,"Full output never consumes new fuel or ingredient");
// One coal cannot finish 27 simultaneous recipes, but adding it in stages must not erase paid work.
stock.clearContent();for(int i=0;i<81;i+=3){var a=(MultiblockFurnaceAccess)stock.cell(i);a.sanctuary$heat(0);a.sanctuary$progress(0);stock.setItem(i,new ItemStack(Items.RAW_IRON));}
stock.setItem(79,new ItemStack(Items.COAL));for(int tick=0;tick<90;tick++)MultiblockCooking.tick(l,oven);
int partial=((MultiblockFurnaceAccess)stock.cell(0)).sanctuary$progress();check(partial>0&&partial<200,"Batch pauses midway when fuel runs out");
for(int tick=0;tick<30;tick++)MultiblockCooking.tick(l,oven);
check(((MultiblockFurnaceAccess)stock.cell(0)).sanctuary$progress()==partial,"Starved batch retains paid work");
stock.setItem(79,new ItemStack(Items.COAL,4));for(int tick=0;tick<205;tick++)MultiblockCooking.tick(l,oven);
int produced=0;for(int i=2;i<81;i+=3)produced+=stock.getItem(i).getCount();check(produced==27,"Refuelling completes all 27 lanes once: "+produced);
var hopper=(WorldlyContainer)HopperBlockEntity.getContainerAt(l,FURNACE);check(hopper.getSlotsForFace(Direction.UP).length==27&&hopper.getSlotsForFace(Direction.DOWN).length==54,"Native sided hopper slots aggregated");
}
private static void explosionCheck(ServerPlayer p){
var l=p.level();var origin=new BlockPos(48,80,0);var m=structure(l,origin,false);
check(Multiblocks.assemble(p,m),"Explosion fixture assembled");var stock=new MultiblockContainer(l,m);stock.setItem(0,new ItemStack(Items.DIAMOND,23));
l.explode(null,48.5,80.5,.5,4,false,net.minecraft.world.level.Level.ExplosionInteraction.TNT);
check(MultiblockData.get(l).at(origin)==null,"Explosion dissolves machine");
int loose=l.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,new AABB(origin).inflate(12)).stream().filter(e->e.getItem().is(Items.DIAMOND)).mapToInt(e->e.getItem().getCount()).sum();
int stored=0;for(var pos:m.members())if(l.getBlockEntity(pos) instanceof BarrelBlockEntity be)for(int i=0;i<27;i++)if(be.getItem(i).is(Items.DIAMOND))stored+=be.getItem(i).getCount();
check(loose+stored==23,"Explosion conserves container contents exactly once: "+loose+"/"+stored);
}
private static MultiblockData.Machine structure(ServerLevel l,BlockPos origin,boolean furnace){var m=new MultiblockData.Machine(UUID.randomUUID(),origin,furnace,Direction.NORTH);for(var pos:m.members())l.setBlockAndUpdate(pos,(furnace?Blocks.FURNACE:Blocks.BARREL).defaultBlockState());return m;}
private static int items(Container c){int n=0;for(int i=0;i<c.getContainerSize();i++)n+=c.getItem(i).getCount();return n;}
private static void wrench(ServerPlayer p,BlockPos pos){UseBlockCallback.EVENT.invoker().interact(p,p.level(),InteractionHand.MAIN_HAND,new BlockHitResult(Vec3.atCenterOf(pos),Direction.WEST,pos,false));}
private static ServerPlayer guest(ServerPlayer host){var s=host.level().getServer();var profile=new com.mojang.authlib.GameProfile(UUID.randomUUID(),"Visitor084");var p=new ServerPlayer(s,host.level(),profile,ClientInformation.createDefault());var connection=new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);guestChannel=new io.netty.channel.embedded.EmbeddedChannel(connection);p.connection=new net.minecraft.server.network.ServerGamePacketListenerImpl(s,connection,p,net.minecraft.server.network.CommonListenerCookie.createInitial(profile,false));p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);p.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());host.level().addNewPlayer(p);return p;}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,93 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.cycle.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
/** Real native routes and server-confirmed actions, including while the integrated world is paused. */
public final class Navigation076ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Navigation076 "+UUID.randomUUID());s.setSeed("76");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.getAbilities().invulnerable=true;p.giveExperienceLevels(30);ProgressionService.send(p,"");});c.waitTicks(5);
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3)){
c.runOnClient(m->{m.getWindow().setWindowed(1280,800);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new PauseScreen(true));});c.waitTicks(3);
c.runOnClient(m->{var b=button(m.gui.screen(),"sanctuary.arena.title");check(b.getY()>0&&b.getY()+b.getHeight()<m.gui.screen().height,"Combat button in viewport");});
c.takeScreenshot("navigation076-pause-"+locale+"-"+scale);
click(c,"sanctuary.arena.title");c.waitFor(m->m.gui.screen() instanceof ArenaScreen&&ArenaClient.view!=null,100);
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen() instanceof PauseScreen,"Arena returns to pause"));
c.runOnClient(m->{ArenaClient.open(m.gui.screen());m.gui.screen().onClose();});c.waitTicks(8);c.runOnClient(m->check(m.gui.screen() instanceof PauseScreen,"Late arena response does not reopen it"));
click(c,"sanctuary.progression.progress");c.runOnClient(m->{check(find(m.gui.screen(),"sanctuary.arena.title")==null,"No combat in progression");for(String tab:List.of("cycle","faction","log"))button(m.gui.screen(),"sanctuary.cycle."+tab);});
c.takeScreenshot("navigation076-progress-"+locale+"-"+scale);c.getInput().pressKey(InputConstants.KEY_ESCAPE);
}
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());});
click(c,"sanctuary.progression.progress");click(c,"sanctuary.cycle.cycle");c.waitFor(m->CycleClient.view!=null,100);c.waitTicks(5);
c.runOnClient(m->check(!button(m.gui.screen(),"sanctuary.cycle.ngp").active,"Incomplete prestige disabled"));click(c,"sanctuary.cycle.cycle_progress");
c.runOnClient(m->check(m.gui.screen() instanceof ProgressionScreen,"Prestige link returns to skills"));
c.runOnClient(m->widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.active&&b.visible&&b.getMessage().getString().equals("+")).findFirst().orElseThrow().onPress(enter()));
c.waitFor(m->ProgressionClient.progress.vitality()==1,100);
click(c,"sanctuary.cycle.faction");c.waitTicks(8);
c.runOnClient(m->{check(!button(m.gui.screen(),"sanctuary.cycle.create").active,"Blank faction name disabled");edit(m.gui.screen()).setValue("A");check(!button(m.gui.screen(),"sanctuary.cycle.create").active,"Short name disabled");edit(m.gui.screen()).setValue("Navigateurs");});
click(c,"sanctuary.cycle.create");click(c,"sanctuary.cycle.cancel");c.runOnClient(m->check(CycleClient.view.faction()==null,"Cancel does not found"));
click(c,"sanctuary.cycle.create");click(c,"sanctuary.cycle.confirm");c.waitFor(m->CycleClient.view.faction()!=null,100);
c.runOnClient(m->{check(CycleClient.view.faction().capacity()==2,"Two founding places");check(!button(m.gui.screen(),"sanctuary.cycle.invite").active,"Blank invitation disabled");check(m.player.experienceLevel==19,"Skill plus faction paid exactly once");});
click(c,"sanctuary.cycle.log");c.waitTicks(5);c.runOnClient(m->{check(hasText(m.gui.screen(),"sanctuary.cycle.log_line"),"Faction event visible");check(hasText(m.gui.screen(),"sanctuary.progression.history_purchase"),"Purchase visible in history");});c.takeScreenshot("navigation076-history-fr");
click(c,"sanctuary.cycle.refresh");c.waitTicks(8);c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
server.runOnServer(s->{var p=player(s);var r=ProgressEffects.get(p).buy("names",4);for(String skill:ProgressRecord.SKILLS)while(r.rank(skill)<ProgressRecord.cap(skill))r=r.buy(skill,1);p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");});c.waitFor(m->ProgressionClient.progress.complete(),100);
click(c,"sanctuary.cycle.cycle");c.waitTicks(8);click(c,"sanctuary.cycle.ngp");click(c,"sanctuary.cycle.cancel");click(c,"sanctuary.cycle.ngp");click(c,"sanctuary.cycle.confirm");
c.waitFor(m->CycleClient.view.prestige()==1&&ProgressionClient.progress.cycle()==1,100);
c.runOnClient(m->{check(ProgressionClient.progress.inventoryRows()==1&&ProgressionClient.progress.names(),"Prestige resets skills and keeps ability");check(CycleClient.view.faction().name().equals("Navigateurs")&&CycleClient.view.charges()==1,"Faction and prestige charge preserved");check(!button(m.gui.screen(),"sanctuary.cycle.ngp").active,"Cannot repeat prestige");});
click(c,"sanctuary.cycle.log");c.waitTicks(5);c.takeScreenshot("navigation076-prestige-history");c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(5);
c.runOnClient(m->FamiliarClient.open(null));c.waitFor(m->FamiliarClient.profile!=null&&FamiliarClient.bond!=null,100);c.waitTicks(5);
c.runOnClient(m->check(!button(m.gui.screen(),"sanctuary.familiar.work").active,"Utility requires ability for normal player"));
server.runOnServer(s->{var p=player(s);FamiliarBattle.state(p).lastRequest=-100;FamiliarBattle.command(p,"work");check(!FamiliarBattle.state(p).workMode,"Server also refuses locked utility");CompanionService.save(p,CompanionService.record(p).purchase(4,System.currentTimeMillis()));CompanionService.send(p,"");FamiliarBattle.send(p,"");});c.waitTicks(10);
c.runOnClient(m->widgets(m.gui.screen()).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow().setScrollAmount(100));
click(c,"sanctuary.familiar.work");c.waitFor(m->FamiliarClient.view.work(),100);
c.runOnClient(m->check(widgets(m.gui.screen()).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow().scrollAmount()>=90,"Changing utility preserves scroll position"));
c.runOnClient(m->{check(!button(m.gui.screen(),"sanctuary.familiar.work_selected").active,"Selected utility visibly selected");check(hasText(m.gui.screen(),"sanctuary.familiar.selected_power"),"Selected power named");});
c.takeScreenshot("navigation076-utility");
c.runOnClient(m->{String name=FamiliarClient.name(FamiliarClient.profile.signature()).getString();widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(name)).findFirst().orElseThrow().onPress(enter());});c.waitFor(m->!FamiliarClient.view.work(),100);c.waitTicks(6);
click(c,"sanctuary.familiar.work");c.waitFor(m->FamiliarClient.view.work(),100);c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(6);
server.runOnServer(s->{var p=player(s);CompanionService.save(p,CompanionService.record(p).cooldown(System.currentTimeMillis()+60000));});c.getInput().pressKey(InputConstants.KEY_G);
c.waitFor(m->FamiliarClient.view.message().equals("work.cooldown"),100);
c.runOnClient(m->check(FamiliarClient.feedback(FamiliarClient.view.message()).getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.companion.cooldown"),"Utility feedback uses correct translations"));
System.out.println("NAVIGATION076_PASS: FR/EN scale 2/3, pause/arena/back, late close, purchase history, faction, prestige, locked/selected utility, G feedback");
}finally{intro.set(false);}
}
private static java.util.stream.Stream<net.minecraft.client.gui.components.events.GuiEventListener> widgets(net.minecraft.client.gui.components.events.GuiEventListener e){
return java.util.stream.Stream.concat(java.util.stream.Stream.of(e),e instanceof net.minecraft.client.gui.components.events.ContainerEventHandler h?h.children().stream().flatMap(Navigation076ClientChecks::widgets):java.util.stream.Stream.empty());
}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static Button find(Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElse(null);}
private static Button button(Screen s,String key){return Objects.requireNonNull(find(s,key),"Missing button "+key+" on "+s.getClass().getSimpleName());}
private static void press(Screen s,String key){var b=button(s,key);check(b.active,"Disabled "+key+" view="+FamiliarClient.view+" record="+CompanionClient.record);b.onPress(enter());}
private static void click(ClientGameTestContext c,String key){c.runOnClient(m->press(m.gui.screen(),key));c.waitTicks(5);}
private static EditBox edit(Screen s){return widgets(s).filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow();}
private static boolean hasText(Screen s,String key){return widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w->contains(w.getMessage(),key));}
private static boolean contains(Component c,String key){if(c.getContents() instanceof TranslatableContents t&&t.getKey().equals(key))return true;return c.getSiblings().stream().anyMatch(s->contains(s,key));}
private static void check(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,164 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.multiblock.*;
import fr.koka.sanctuary.mixin.MultiblockFurnaceAccess;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.*;
import net.minecraft.world.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.entity.player.*;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.phys.*;
public final class Piston087ClientChecks implements FabricClientGameTest {
private static final BlockPos C=new BlockPos(15,85,15);
private static final AtomicBoolean movingSeen=new AtomicBoolean(),returnSeen=new AtomicBoolean();
private static net.minecraft.world.entity.animal.pig.Pig pig;
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Piston087 "+UUID.randomUUID());s.setSeed("87");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
c.runOnClient(m->{if(!m.gui.hud.isHidden())m.gui.hud.toggle();m.options.bobView().set(false);m.options.fov().set(70);m.getWindow().setWindowed(1280,800);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(m.level==null)return;for(var f:Direction.values())for(int d=0;d<4;d++)if(m.level.getBlockEntity(C.relative(f,d)) instanceof net.minecraft.world.level.block.piston.PistonMovingBlockEntity be){
if(be.getMovedState().is(SuperPistons.HEAD))movingSeen.set(true);if(be.getMovedState().getBlock() instanceof SuperPistonBlocks.Base&&be.isSourcePiston())returnSeen.set(true);
}});
server.runOnServer(s->{var p=player(s);p.setGameMode(GameType.CREATIVE);p.getAbilities().flying=true;p.onUpdateAbilities();p.teleportTo(24,91,5);
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");});
// beta.089 regression: inherited piston predicates must never read absent EXTENDED on helper pieces.
c.runOnClient(m->verifyPredicates(m.level));
server.runOnServer(s->verifyPredicates(player(s).level()));
// Real wrench confirmation on vanilla components; then six orientations for both variants.
for(boolean sticky:new boolean[]{false,true})for(var facing:Direction.values()){
server.runOnServer(s->{var p=player(s);setup(p,facing,sticky,1);var l=p.level();
check(!(l.getBlockEntity(C) instanceof SuperPistonController),"Placing nine pistons does not assemble them");
wrench(p,C);check(!(l.getBlockEntity(C) instanceof SuperPistonController),"First click only previews");wrench(p,C);
check(l.getBlockEntity(C) instanceof SuperPistonController,"Second click creates controller");
camera(p,facing);
});
c.waitTicks(8);c.getInput().lookAt(C.relative(facing));c.waitTicks(5);
if(facing==Direction.NORTH)c.takeScreenshot("piston087-"+(sticky?"sticky":"normal")+"-closed");
server.runOnServer(s->power(player(s).level(),facing,true));
server.waitFor(s->idle(player(s).level(),3),200);
server.runOnServer(s->{var l=player(s).level();for(var pos:SuperPistons.tiles(C,facing,4))check(l.getBlockState(pos).is(Blocks.STONE),"Entire nine-block load moved exactly three blocks: "+facing);
for(var pos:SuperPistons.tiles(C,facing,3))check(l.getBlockState(pos).is(SuperPistons.HEAD),"Common head at depth three");});
c.waitTicks(7);c.takeScreenshot("piston087-"+(sticky?"sticky":"normal")+"-"+facing.getName());
c.runOnClient(m->{
for(var pos:SuperPistons.tiles(C,facing,3))check(!m.level.collidesWithSuffocatingBlock(m.player,new AABB(pos)),"Player contact with every head tile is safe: "+facing);
for(int depth=1;depth<3;depth++)check(!m.level.collidesWithSuffocatingBlock(m.player,new AABB(C.relative(facing,depth))),"Player contact with rod is safe: "+facing);
});
server.runOnServer(s->power(player(s).level(),facing,false));server.waitFor(s->idle(player(s).level(),0),200);
server.runOnServer(s->{var l=player(s).level();for(var pos:SuperPistons.tiles(C,facing,sticky?1:4))check(l.getBlockState(pos).is(Blocks.STONE),"Correct normal/sticky return: "+facing);
for(int depth=1;depth<=3;depth++)for(var pos:SuperPistons.tiles(C,facing,depth))check(!(l.getBlockState(pos).getBlock() instanceof SuperPistonBlocks.Part),"No leftover helper pieces");});
}
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,false,12);check(SuperPistons.assemble(p.level(),C,Direction.NORTH,false),"108-load fixture");power(p.level(),Direction.NORTH,true);});
server.waitFor(s->idle(player(s).level(),3),200);
server.runOnServer(s->{var l=player(s).level();int n=0;for(int d=1;d<=15;d++)for(var pos:SuperPistons.tiles(C,Direction.NORTH,d))if(l.getBlockState(pos).is(Blocks.STONE))n++;check(n==108,"108 blocks conserved after three steps");for(var pos:SuperPistons.tiles(C,Direction.NORTH,15))check(l.getBlockState(pos).is(Blocks.STONE),"Full 12-block deep load moves");});
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,false,12);p.level().setBlockAndUpdate(SuperPistons.tile(C,Direction.NORTH,0).relative(Direction.NORTH,13),Blocks.STONE.defaultBlockState());SuperPistons.assemble(p.level(),C,Direction.NORTH,false);power(p.level(),Direction.NORTH,true);});
c.waitTicks(25);server.runOnServer(s->{var l=player(s).level();check(idle(l,0),"109 blocks reject the whole stroke");});
// Obstacle at the second step: all nine stop, then resume after removal.
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,false,0);SuperPistons.assemble(p.level(),C,Direction.NORTH,false);p.level().setBlockAndUpdate(SuperPistons.tile(C,Direction.NORTH,0).relative(Direction.NORTH,2),Blocks.OBSIDIAN.defaultBlockState());power(p.level(),Direction.NORTH,true);});
server.waitFor(s->idle(player(s).level(),1),200);c.waitTicks(15);server.runOnServer(s->{var l=player(s).level();check(idle(l,1),"Obstacle stops every tile at step one");for(var pos:SuperPistons.tiles(C,Direction.NORTH,1))check(l.getBlockState(pos).is(SuperPistons.HEAD),"Head remains aligned");l.removeBlock(SuperPistons.tile(C,Direction.NORTH,0).relative(Direction.NORTH,2),false);});
server.waitFor(s->idle(player(s).level(),3),200);
// A chest remains untouched and keeps its inventory.
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,true,0);var l=p.level();l.setBlockAndUpdate(C.north(),Blocks.CHEST.defaultBlockState());((ChestBlockEntity)l.getBlockEntity(C.north())).setItem(0,new ItemStack(Items.DIAMOND,17));SuperPistons.assemble(l,C,Direction.NORTH,true);power(l,Direction.NORTH,true);});
c.waitTicks(20);server.runOnServer(s->{var l=player(s).level();check(idle(l,0)&&((ChestBlockEntity)l.getBlockEntity(C.north())).getItem(0).getCount()==17,"Container remains immovable with its exact contents");});
// Adhesion beyond the face, with slime/honey incompatibility.
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,true,0);var l=p.level();var corner=SuperPistons.tile(C,Direction.NORTH,8).north();l.setBlockAndUpdate(corner,Blocks.SLIME_BLOCK.defaultBlockState());l.setBlockAndUpdate(corner.east(),Blocks.STONE.defaultBlockState());l.setBlockAndUpdate(corner.above(),Blocks.HONEY_BLOCK.defaultBlockState());SuperPistons.assemble(l,C,Direction.NORTH,true);power(l,Direction.NORTH,true);});
server.waitFor(s->idle(player(s).level(),3),200);
server.runOnServer(s->{var l=player(s).level();var corner=SuperPistons.tile(C,Direction.NORTH,8).north();check(l.getBlockState(corner.north(3)).is(Blocks.SLIME_BLOCK)&&l.getBlockState(corner.east().north(3)).is(Blocks.STONE),"Slime pulls an adjacent load outside the 3x3 face");check(l.getBlockState(corner.above()).is(Blocks.HONEY_BLOCK),"Honey does not adhere to slime");power(l,Direction.NORTH,false);});
server.waitFor(s->idle(player(s).level(),0),200);
server.runOnServer(s->{var l=player(s).level();var corner=SuperPistons.tile(C,Direction.NORTH,8).north();check(l.getBlockState(corner).is(Blocks.SLIME_BLOCK)&&l.getBlockState(corner.east()).is(Blocks.STONE),"Sticky return restores attached load");});
// Moving platform transports a real entity using vanilla collision.
server.runOnServer(s->{var p=player(s);setup(p,Direction.UP,false,1);var l=p.level();pig=new net.minecraft.world.entity.animal.pig.Pig(net.minecraft.world.entity.EntityTypes.PIG,l);pig.setNoAi(true);pig.setPos(C.getX()+.5,C.getY()+2,C.getZ()+.5);l.addFreshEntity(pig);SuperPistons.assemble(l,C,Direction.UP,false);power(l,Direction.UP,true);});
server.waitFor(s->idle(player(s).level(),3),200);c.waitTicks(5);
server.runOnServer(s->{check(pig.getY()>=C.getY()+4.8,"Real entity carried upward three blocks: "+pig.getY());pig.discard();});
// Cut power during a step, preserving native in-flight serialization.
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,true,1);SuperPistons.assemble(p.level(),C,Direction.NORTH,true);power(p.level(),Direction.NORTH,true);});
server.waitFor(s->player(s).level().getBlockEntity(C) instanceof SuperPistonController be&&be.stroke==1&&!be.pending.isEmpty(),200);
server.runOnServer(s->{var l=player(s).level();var be=(SuperPistonController)l.getBlockEntity(C);var copy=BlockEntity.loadStatic(C,be.getBlockState(),be.saveWithFullMetadata(l.registryAccess()),l.registryAccess());check(copy instanceof SuperPistonController saved&&saved.stroke==1&&saved.pending.equals(be.pending),"Controller serializes in-flight progress and positions");
for(var pos:be.pending)if(l.getBlockEntity(pos) instanceof net.minecraft.world.level.block.piston.PistonMovingBlockEntity motion){var saved=BlockEntity.loadStatic(pos,motion.getBlockState(),motion.saveWithFullMetadata(l.registryAccess()),l.registryAccess());check(saved instanceof net.minecraft.world.level.block.piston.PistonMovingBlockEntity m&&m.getMovedState().equals(motion.getMovedState()),"Native moving block state serialized");}
power(l,Direction.NORTH,false);});
server.waitFor(s->idle(player(s).level(),0),200);
server.runOnServer(s->{for(var pos:SuperPistons.tiles(C,Direction.NORTH,1))check(player(s).level().getBlockState(pos).is(Blocks.STONE),"Interrupted sticky cycle keeps cargo");});
// Unloaded loads suspend instead of detaching a sticky piston; no forced chunk request.
server.runOnServer(s->{var l=player(s).level();var far=new BlockPos(300000,85,300000);boolean suspended=false;
try{SuperPistonPlan.resolve(l,List.of(far),Direction.NORTH,false,Set.of());}catch(SuperPistonPlan.Suspended expected){suspended=true;}
check(suspended&&!l.hasChunkAt(far),"Unloaded pull suspends without loading chunks");
check(SuperPistonPlan.resolve(l,List.of(C.atY(l.getMaxY()+1)),Direction.UP,true,Set.of())==null,"World height blocks movement");});
// Native breakable container drops one box with its inventory, never loose duplicate cargo.
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,false,0);var l=p.level();l.setBlockAndUpdate(C.north(),Blocks.SHULKER_BOX.defaultBlockState());((ShulkerBoxBlockEntity)l.getBlockEntity(C.north())).setItem(0,new ItemStack(Items.DIAMOND,17));SuperPistons.assemble(l,C,Direction.NORTH,false);power(l,Direction.NORTH,true);});
server.waitFor(s->idle(player(s).level(),3),200);
server.runOnServer(s->{var l=player(s).level();var boxes=l.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,new AABB(C).inflate(20),e->e.getItem().is(Items.SHULKER_BOX));check(boxes.size()==1,"One broken shulker box dropped");var contents=boxes.getFirst().getItem().get(DataComponents.CONTAINER);check(contents!=null&&contents.nonEmptyItemCopyStream().filter(i->i.is(Items.DIAMOND)).mapToInt(ItemStack::getCount).sum()==17,"Shulker contents preserved");boxes.forEach(net.minecraft.world.entity.Entity::discard);});
// The four-pixel front belongs to the moving head, including all six collision orientations.
server.runOnServer(s->{var l=player(s).level();for(var f:Direction.values()){
var state=SuperPistons.base(f,0,false,true);var bounds=state.getCollisionShape(l,C).bounds();double thickness=bounds.max(f.getAxis())-bounds.min(f.getAxis());check(Math.abs(thickness-.75)<.00001,"Extended base keeps twelve pixels: "+f);
var head=SuperPistons.headForBase(state,1).getCollisionShape(l,C).bounds();check(Math.abs(head.max(f.getAxis())-head.min(f.getAxis())-.25)<.00001,"Moving plate has four pixels: "+f);
}});
for(boolean sticky:new boolean[]{false,true}){
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,sticky,0);SuperPistons.assemble(p.level(),C,Direction.NORTH,sticky);p.teleportTo(C.getX()+6.5,C.getY()+3,C.getZ()-6.5);});
c.waitTicks(8);c.getInput().lookAt(C);c.waitTicks(4);c.takeScreenshot("piston087-"+(sticky?"sticky":"normal")+"-face-closed");
server.runOnServer(s->power(player(s).level(),Direction.NORTH,true));server.waitFor(s->idle(player(s).level(),3),200);c.waitTicks(6);c.takeScreenshot("piston087-"+(sticky?"sticky":"normal")+"-face-open");
}
// Loaded structure after real chunk unload and return; break/dismantle cleanup.
server.runOnServer(s->{var p=player(s);setup(p,Direction.NORTH,false,0);SuperPistons.assemble(p.level(),C,Direction.NORTH,false);power(p.level(),Direction.NORTH,true);camera(p,Direction.NORTH);});
server.waitFor(s->idle(player(s).level(),3),200);
server.runOnServer(s->player(s).teleportTo(400.5,90,400.5));c.waitFor(m->m.level.getChunkSource().getChunkNow(C.getX()>>4,C.getZ()>>4)==null,600);
server.runOnServer(s->camera(player(s),Direction.NORTH));c.waitFor(m->m.level.getChunkSource().getChunkNow(C.getX()>>4,C.getZ()>>4)!=null&&m.level.getBlockState(C).is(SuperPistons.NORMAL),600);server.waitFor(s->idle(player(s).level(),3),200);
server.runOnServer(s->{var p=player(s);p.level().destroyBlock(SuperPistons.tile(C,Direction.NORTH,0),true);});c.waitTicks(15);
server.runOnServer(s->{var l=player(s).level();for(int i=1;i<9;i++)check(l.getBlockState(SuperPistons.tile(C,Direction.NORTH,i)).is(Blocks.PISTON),"Broken structure restores remaining eight components");for(int d=1;d<=3;d++)for(var pos:SuperPistons.tiles(C,Direction.NORTH,d))check(l.getBlockState(pos).isAir(),"Head and rod removed after break");});
check(movingSeen.get(),"Client receives moving head data");check(returnSeen.get(),"Client receives final retraction data");
System.out.println("PISTON087_PASS: all-state suffocation/view/spawn/conductor predicates, client player collision on every head tile and rod, six directions x two variants, 108/109 load, obstacles, inventories, adhesion, entity collision, interruption, serialization, chunk reload and dismantle");
}
}
private static void verifyPredicates(net.minecraft.world.level.BlockGetter level){
for(var block:List.of(SuperPistons.NORMAL,SuperPistons.GLUANT,SuperPistons.HEAD,SuperPistons.ROD))for(var state:block.getStateDefinition().getPossibleStates()){
boolean closed=state.getBlock() instanceof SuperPistonBlocks.Base&&!state.getValue(net.minecraft.world.level.block.piston.PistonBaseBlock.EXTENDED);
check(state.isSuffocating(level,C)==closed,"Only retracted bases suffocate: "+state);
check(!state.isRedstoneConductor(level,C),"Piston pieces do not conduct through their volume");
state.isViewBlocking(level,C,new AABB(C));state.isValidSpawn(level,C,net.minecraft.world.entity.EntityTypes.PIG);
}
}
private static ServerPlayer player(net.minecraft.server.MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static void camera(ServerPlayer p,Direction f){var u=SuperPistons.u(f);var v=SuperPistons.v(f);p.teleportTo(C.getX()+.5+f.getStepX()*9+u.getStepX()*7+v.getStepX()*5,C.getY()+.5+f.getStepY()*9+u.getStepY()*7+v.getStepY()*5,C.getZ()+.5+f.getStepZ()*9+u.getStepZ()*7+v.getStepZ()*5);}
private static void power(ServerLevel l,Direction f,boolean on){l.setBlockAndUpdate(SuperPistons.tile(C,f,0).relative(f,-1),(on?Blocks.REDSTONE_BLOCK:Blocks.AIR).defaultBlockState());}
private static boolean idle(ServerLevel l,int n){return l.getBlockEntity(C) instanceof SuperPistonController be&&be.stroke==n&&be.pending.isEmpty();}
private static void setup(ServerPlayer p,Direction f,boolean sticky,int layers){
var l=p.level();p.closeContainer();p.setShiftKeyDown(false);p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Multiblocks.WRENCH));
for(var d:Direction.values())for(boolean glue:new boolean[]{false,true})SuperPistons.dismantle(l,C,d,glue);
for(var d:Direction.values())for(int depth=-16;depth<=16;depth++)for(var pos:SuperPistons.tiles(C,d,depth))if(!l.getBlockState(pos).isAir())l.setBlockAndUpdate(pos,Blocks.AIR.defaultBlockState());
for(int i=0;i<9;i++){var pos=SuperPistons.tile(C,f,i);l.setBlockAndUpdate(pos,(sticky?Blocks.STICKY_PISTON:Blocks.PISTON).defaultBlockState().setValue(net.minecraft.world.level.block.piston.PistonBaseBlock.FACING,f));}
for(int depth=1;depth<=layers;depth++)for(var pos:SuperPistons.tiles(C,f,depth))l.setBlockAndUpdate(pos,Blocks.STONE.defaultBlockState());
}
private static void wrench(ServerPlayer p,BlockPos pos){UseBlockCallback.EVENT.invoker().interact(p,p.level(),InteractionHand.MAIN_HAND,new BlockHitResult(Vec3.atCenterOf(pos),Direction.UP,pos,false));}
private static net.minecraft.client.input.KeyEvent enter(){return new net.minecraft.client.input.KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,119 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import java.util.stream.Stream;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.phys.Vec3;
/** Real client resource/model capture, widgets, previews and creative upload in a new flat world. */
public final class Plans072ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
button(screen,"sanctuary.progression.enter").onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Plans072 "+UUID.randomUUID());s.setSeed("72");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1800);var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setGameMode(GameType.CREATIVE);p.teleportTo(0.5,10,0.5);p.getAbilities().flying=true;p.onUpdateAbilities();
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set day");
});
c.waitFor(m->m.player.isCreative(),100);c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();});
long started=System.nanoTime();var supported=new ArrayList<String>();var refused=new ArrayList<String>();
c.runOnClient(m->{
var palette=new StatuePaletteUnchecked();
require(palette.value.nearest(0xFFFFFFFF)!=null,"Active resource textures produce a block palette");
for(var item:BuiltInRegistries.ITEM)if(item instanceof SpawnEggItem){
var type=SpawnEggItem.getType(item.getDefaultInstance());String id=BuiltInRegistries.ENTITY_TYPE.getKey(type).toString();
if(supported.contains(id)||refused.stream().anyMatch(v->v.startsWith(id+" ")))continue;
try{
var entity=type.create(m.level,EntitySpawnReason.MOB_SUMMONED);entity.setPos(m.player.position());entity.setId(Integer.MIN_VALUE+73);
var faces=StatueModels.capture(entity);require(faces.size()>=6,"Native geometry exists: "+id);supported.add(id);
}catch(Exception e){refused.add(id+" "+e.getMessage());}
}
});
require(supported.containsAll(List.of("minecraft:creeper","minecraft:cow","minecraft:pig","minecraft:chicken","minecraft:zombie","minecraft:sheep")),"Common native models are supported");
System.out.println("PLANS072 models="+supported.size()+" refused="+refused+" capture_ms="+(System.nanoTime()-started)/1_000_000);
c.getInput().pressKey(InputConstants.KEY_K);c.waitForScreen(PlansScreen.class);
c.takeScreenshot("plans072-create-en");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.generate").onPress(enter()));
c.waitFor(m->PlanClient.plan!=null&&!PlanClient.busy,400);
c.runOnClient(m->{require(PlanClient.plan.height()==32,"Requested model height");require(PlanClient.plan.cells().size()>100,"Detailed geometry becomes real material cells");
PlanClient.origin=new BlockPos(10,2,10);PlanClient.refresh();
});
c.takeScreenshot("plans072-plan-en");
var expected=new AtomicReference<BuildPlan>();c.runOnClient(m->expected.set(PlanClient.plan));
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.rotate").onPress(enter()));
c.runOnClient(m->{for(int i=0;i<3;i++)PlanClient.rotate();require(PlanClient.plan.equals(expected.get()),"UI and native rotations return to exact original plan");});
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.materials").onPress(enter()));c.takeScreenshot("plans072-materials-en");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.back_plan").onPress(enter()));
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.export").onPress(enter()));c.waitFor(m->!PlanClient.busy,200);
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(15.5,15,55.5));
c.runOnClient(m->m.gui.setScreen(null));c.waitFor(m->m.player.distanceToSqr(15.5,15,55.5)<2,100);
c.getInput().lookAt(new BlockPos(15,18,17));c.waitTicks(15);
c.getInput().pressKey(InputConstants.KEY_F1);c.takeScreenshot("plans072-ghost-creeper");c.getInput().pressKey(InputConstants.KEY_F1);
c.getInput().pressKey(InputConstants.KEY_K);c.waitForScreen(PlansScreen.class);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.plans.paste").onPress(enter()));c.waitForScreen(ConfirmScreen.class);
c.clickScreenButton("gui.yes");
c.waitFor(m->PlanClient.completed==PlanClient.plan.cells().size(),400);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();for(var cell:expected.get().cells())require(p.level().getBlockState(new BlockPos(10,2,10).offset(cell.pos()))==cell.state(),"Server pasted every exact material");});
c.runOnClient(m->m.gui.setScreen(null));c.getInput().pressKey(InputConstants.KEY_F1);c.waitTicks(10);c.takeScreenshot("plans072-built-creeper");c.getInput().pressKey(InputConstants.KEY_F1);
// A real survival connection must reject an upload, even when sent without the GUI.
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setGameMode(GameType.SURVIVAL);p.getAbilities().invulnerable=true;});
c.waitFor(m->!m.player.isCreative(),100);
c.runOnClient(m->{var tiny=new BuildPlan("Survival",1,1,1,List.of(new BuildPlan.Cell(BlockPos.ZERO,Blocks.STONE.defaultBlockState())));PlanClient.select(tiny,Map.of());PlanClient.origin=new BlockPos(4,2,4);PlanClient.refresh();
ClientPlayNetworking.send(new PlanPayload(UUID.randomUUID(),PlanClient.origin,1,1,1,1,0,tiny.cells()));});
c.waitTicks(30);server.runOnServer(s->require(s.getPlayerList().getPlayers().getFirst().level().getBlockState(new BlockPos(4,2,4)).isAir(),"Survival spoofed paste leaves the world untouched"));
c.getInput().pressKey(InputConstants.KEY_K);c.waitForScreen(PlansScreen.class);
c.runOnClient(m->require(widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).noneMatch(b->key(b).equals("sanctuary.plans.paste")),"Survival UI only offers construction guidance"));
language(c,"fr_fr");c.takeScreenshot("plans072-survie-fr");
c.runOnClient(m->{PlanClient.layer=0;PlanClient.refresh();require(PlanClient.completed==0,"Free preview does not complete a block");});
// Ordinary native placement updates the plan and consumes the held material.
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().setItem(p.getInventory().getSelectedSlot(),new ItemStack(Items.STONE,2));
for(int x=3;x<=5;x++)for(int z=2;z<=5;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
p.level().setBlockAndUpdate(new BlockPos(4,1,4),Blocks.STONE.defaultBlockState());p.teleportTo(4.5,1,3.5);});
c.runOnClient(m->m.gui.setScreen(null));
c.waitFor(m->m.player.distanceToSqr(4.5,1,3.5)<1&&m.player.getMainHandItem().is(Items.STONE),150);
c.getInput().lookAt(new BlockPos(4,1,4));c.waitTicks(3);
c.runOnClient(m->System.out.println("PLANS072 survival hit="+m.hitResult+" player="+m.player.position()+" origin="+PlanClient.origin+" held="+m.player.getMainHandItem()));
c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);
c.waitFor(m->PlanClient.completed==1,150);
server.runOnServer(s->require(s.getPlayerList().getPlayers().getFirst().getMainHandItem().getCount()==1,"Survival native placement consumes exactly one block"));
c.runOnClient(m->{PlanClient.clear();m.gui.setScreen(new PlansScreen(null));});c.takeScreenshot("plans072-create-fr");
language(c,"en_us");
System.out.println("PLANS072 passed: native models + UV textures + active palette, creation widgets, 32-block statue, rotations, materials, gzip export, colored preview, real creative network paste, survival spoof rejection and paid native placement, FR/EN.");
}
}
private static void language(ClientGameTestContext c,String id){
var reload=c.computeOnClient(m->{m.getLanguageManager().setSelected(id);return m.reloadResourcePacks();});
c.waitFor(m->reload.isDone(),600);reload.join();c.waitFor(m->m.gui.overlay()==null,400);c.waitTicks(8);
}
private static final class StatuePaletteUnchecked {final StatuePalette value;StatuePaletteUnchecked(){try{value=new StatuePalette("mixed");}catch(Exception e){throw new IllegalStateException(e);}}}
private static Stream<GuiEventListener> widgets(ContainerEventHandler c){return c.children().stream().flatMap(w->w instanceof ContainerEventHandler child?Stream.concat(Stream.of(w),widgets(child)):Stream.of(w));}
private static String key(Button b){return b.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
private static Button button(Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals(key)).findFirst().orElseThrow(()->new AssertionError("Button missing: "+key));}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,68 @@
package fr.koka.sanctuary.client;
import java.util.Arrays;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.packs.PackSelectionScreen;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.repository.PackCompatibility;
/** Native default selection, actual resource priority, GUI atlas rendering and player opt-out. */
public final class ResourcePack070ClientChecks implements FabricClientGameTest {
private static final String ID="sanctuary:textures";
public void runTest(ClientGameTestContext c) {
var previous=c.computeOnClient(m->m.gui.screen());
c.runOnClient(m->{
var repo=m.getResourcePackRepository();
check(repo.getAvailableIds().contains(ID),"Built-in pack available: "+repo.getAvailableIds());
check(repo.getSelectedIds().contains(ID),"Pack enabled at startup without changing options");
check(repo.getPack(ID).getCompatibility()==PackCompatibility.COMPATIBLE,"Format accepted by exact Minecraft version");
for(String state:new String[]{"empty","half","full","empty_hunger","half_hunger","full_hunger"})
resource(m,"minecraft:textures/gui/sprites/hud/food_"+state+".png");
resource(m,"sanctuary:textures/gui/food_details.png");
check(!m.getResourceManager().getResource(Identifier.withDefaultNamespace("textures/gui/sprites/hud/air.png")).orElseThrow().sourcePackId().equals(ID),"Breathing keeps native resources");
});
c.takeScreenshot("resourcepack070-title-default");
c.runOnClient(m->m.gui.setScreen(new PackSelectionScreen(m.getResourcePackRepository(),repo->{},m.getResourcePackDirectory(),Component.translatable("resourcePack.title"))));
c.waitTicks(4);c.takeScreenshot("resourcepack070-selected");
c.runOnClient(m->m.gui.setScreen(new Screen(Component.literal("Resource pack preview")) {
@Override public void extractRenderState(GuiGraphicsExtractor g,int x,int y,float delta) {
g.fill(0,0,width,height,0xff171717);
g.centeredText(font,Component.translatable("sanctuary.resource_pack.description","beta.070"),width/2,35,0xffffffff);
String[] states={"empty","half","full","empty_hunger","half_hunger","full_hunger"};
for(int i=0;i<states.length;i++)g.blitSprite(RenderPipelines.GUI_TEXTURED,Identifier.withDefaultNamespace("hud/food_"+states[i]),width/2-63+i*22,75,18,18);
for(int i=0;i<4;i++)g.blit(RenderPipelines.GUI_TEXTURED,Identifier.parse("sanctuary:textures/gui/food_details.png"),width/2-36+i*22,115,i*9,0,18,18,9,9,256,256);
}
}));
c.waitTicks(4);c.takeScreenshot("resourcepack070-food-native-atlas");
c.runOnClient(m->{
var repo=m.getResourcePackRepository();repo.removePack(ID);m.options.updateResourcePacks(repo);
});
c.waitFor(m->!source(m).equals(ID),1000);
c.runOnClient(m->{
var repo=m.getResourcePackRepository();repo.reload();m.options.loadSelectedResourcePacks(repo);
check(!repo.getSelectedIds().contains(ID),"Explicit disable survives pack rediscovery");
repo.addPack(ID);m.options.updateResourcePacks(repo);
});
c.waitFor(m->source(m).equals(ID),1000);
c.runOnClient(m->m.gui.setScreen(previous));
System.out.println("RESOURCEPACK070_PASS: default activation, exact imported textures override native assets, format compatible, breathing vanilla, disable retained and re-enable works");
}
private static String source(Minecraft m) {
return m.getResourceManager().getResource(Identifier.withDefaultNamespace("textures/gui/sprites/hud/food_full.png")).orElseThrow().sourcePackId();
}
private static void resource(Minecraft m,String name) {
var id=Identifier.parse(name);var resource=m.getResourceManager().getResource(id).orElseThrow();
check(resource.sourcePackId().equals(ID),"Active resource priority: "+name+" from "+resource.sourcePackId());
var path=FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow().findPath("resourcepacks/textures/assets/"+id.getNamespace()+"/"+id.getPath()).orElseThrow();
try(var in=resource.open()) {check(Arrays.equals(in.readAllBytes(),java.nio.file.Files.readAllBytes(path)),"Resolved texture bytes: "+name);}
catch(java.io.IOException error){throw new AssertionError(error);}
}
private static void check(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,98 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.companion.CompanionService;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.ProgressionService;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.CustomData;
import net.minecraft.world.level.block.Blocks;
/** Real single-player orders without operator permissions or purchased familiar abilities. */
public final class Solo079ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
var intro=new AtomicBoolean(true);
var messages=ConcurrentHashMap.<String>newKeySet();
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{
if(FamiliarClient.view!=null&&!FamiliarClient.view.message().isEmpty())messages.add(FamiliarClient.view.message());
if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
button(screen,"sanctuary.progression.enter").onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Solo079 "+UUID.randomUUID());s.setSeed("79");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();var target=new AtomicReference<Mob>();
server.runOnServer(s->{var p=player(s);check(!ProgressionService.canOperate(p),"Normal solo has no operator permissions");check(!CompanionService.record(p).unlocked(),"No familiar active ability bought");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set night");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"difficulty normal");
for(int x=-18;x<=18;x++)for(int z=-18;z<=18;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
p.getInventory().clearContent();p.teleportTo(.5,1,.5);equip(p,Items.WOLF_SPAWN_EGG,FamiliarPersonality.PASSIVE);
});c.waitTicks(15);
// The actual held-H menu sends recall and follow through the play connection.
order(c,"recall");c.waitTicks(12);
server.runOnServer(s->check(FamiliarBattle.bond(player(s)).recalled()&&FamiliarService.active(player(s))==null,"Held H -> Recall actually removes the familiar"));
order(c,"follow");c.waitTicks(20);
server.runOnServer(s->check(!FamiliarBattle.bond(player(s)).recalled()&&FamiliarService.active(player(s))!=null,"Follow summons the recalled familiar"));
order(c,"protect");c.waitTicks(12);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.state(p).order==FamiliarBattle.Order.PROTECT&&p.getUUID().equals(FamiliarBattle.state(p).protectedEntity),"Held H -> Protect establishes a personal guard without operator rights");});
for(var item:new Item[]{Items.WOLF_SPAWN_EGG,Items.SLIME_SPAWN_EGG,Items.CHICKEN_SPAWN_EGG}) {
server.runOnServer(s->{var p=player(s);equip(p,item,FamiliarPersonality.PASSIVE);p.teleportTo(.5,1,.5);FamiliarService.active(p).setPos(1.5,1,.5);target.set(enemy(p,.5,1,6.5));});c.waitTicks(15);
c.getInput().lookAt(new BlockPos(0,2,6));c.waitTicks(5);order(c,"engage");c.waitTicks(12);
server.runOnServer(s->{var p=player(s);check(FamiliarBattle.target(p)==target.get()&&FamiliarBattle.state(p).explicitTarget,"Held H -> Attack gives a target: "+item);});
c.waitTicks(200);
server.runOnServer(s->{var p=player(s);var st=FamiliarBattle.state(p);var pet=FamiliarService.active(p);check(target.get().getHealth()<100&&target.get().getLastHurtByMob()==pet,"Commanded familiar actually damages enemy: "+item+" pet="+pet.position()+" target="+target.get().position()+" current="+st.target+" next="+st.nextInitiative+" tick="+p.tickCount+" cast="+(st.cast==null?"none":st.cast.age)+" sight="+pet.hasLineOfSight(target.get()));target.get().discard();FamiliarBattle.cancel(p);});
}
// Follow pauses spontaneous pursuit, never an immediate response to real damage.
for(var item:new Item[]{Items.WOLF_SPAWN_EGG,Items.SLIME_SPAWN_EGG,Items.CHICKEN_SPAWN_EGG}) {
server.runOnServer(s->{var p=player(s);equip(p,item,FamiliarPersonality.PASSIVE);p.setHealth(p.getMaxHealth());p.teleportTo(.5,1,.5);FamiliarService.active(p).setPos(1.5,1,.5);target.set(enemy(p,5.5,1,.5));
FamiliarBattle.command(p,"follow");
p.hurtServer(p.level(),p.damageSources().mobAttack(target.get()),1);
check(FamiliarBattle.target(p)==target.get(),"Passive familiar defends non-operator solo player immediately after Follow: "+item);
});c.waitTicks(200);
server.runOnServer(s->{check(target.get().getHealth()<100&&target.get().getLastHurtByMob()==FamiliarService.active(player(s)),"Autonomous defence really lands a hit: "+item);target.get().discard();});
}
check(messages.containsAll(java.util.Set.of("recalled","order.following","order.attacking","order.protecting")),"Accepted menu orders have client feedback: "+messages);
c.takeScreenshot("solo079-defence");System.out.println("SOLO079_PASS: no operator, no purchased ability, held-H menu, wolf/slime/chicken damage, real defence");
} finally {intro.set(false);c.getInput().releaseKey(InputConstants.KEY_H);}
}
private static void order(ClientGameTestContext c,String action) {
c.getInput().holdKey(InputConstants.KEY_H);c.waitFor(m->m.gui.screen() instanceof FamiliarOrdersScreen,100);c.getInput().releaseKey(InputConstants.KEY_H);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.familiar."+action).onPress(enter()));
}
private static Button button(net.minecraft.client.gui.screens.Screen screen,String key) {
return screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow();
}
private static void equip(ServerPlayer p,Item item,FamiliarPersonality personality) {
p.stopRiding();CarryService.detach(p);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);FamiliarBattle.release(p);p.removeAttached(FamiliarBattle.COMBAT_LOCK);p.removeAttached(FamiliarBattle.LAST_FAMILIAR);
var egg=new ItemStack(item);var scale=new CompoundTag();scale.putInt("schema",1);scale.putInt("per_mille",1000);CustomData.update(DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,scale));
var profile=FamiliarBattle.world(p.level().getServer()).catalog.forEgg(egg);UUID id;do{id=UUID.randomUUID();}while(FamiliarPersonality.of(id,profile.id())!=personality);
FamiliarBattle.world(p.level().getServer()).store.put(FamiliarBond.create(id,profile,0,"test",p.getUUID()));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,FamiliarIdentity.assign(egg,id));
CompanionService.tick(p);p.tickCount+=10-p.tickCount%10;FamiliarService.tick(p);check(FamiliarService.active(p)!=null,"Familiar spawned: "+item);p.inventoryMenu.broadcastChanges();
}
private static Mob enemy(ServerPlayer p,double x,double y,double z){var e=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);e.setNoAi(true);e.getAttribute(Attributes.MAX_HEALTH).setBaseValue(100);e.setHealth(100);e.setPos(x,y,z);p.level().addFreshEntity(e);return e;}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean condition,String message){if(!condition)throw new AssertionError(message);}
}
@@ -0,0 +1,90 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.realtime.RealtimeService;
import fr.koka.sanctuary.weather.*;
import java.nio.file.*;
import java.time.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.attribute.EnvironmentAttributes;
import net.minecraft.world.level.storage.LevelResource;
/** Operator command tree, actual chat packets, daily persistence and native atmosphere. */
public final class Weather082ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
var intro=new AtomicBoolean(true);byte[] prior=readConfig();
checked(()->{Files.createDirectories(WeatherService.CONFIG.getParent());Files.writeString(WeatherService.CONFIG,WeatherConfig.DEFAULT.json());});
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Weather082 "+UUID.randomUUID());s.setSeed("82");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->checked(()->{
at(s,Instant.parse("2026-09-16T10:00:00Z"));WeatherService.reload(s);
var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;
var commands=s.getCommands().getDispatcher();var root=commands.getRoot();var sanctuary=root.getChild("sanctuary").getChild("weather");var nativeWeather=root.getChild("weather");
s.getWorldData().setAllowCommands(false);var source=p.createCommandSourceStack();var before=WeatherService.session(s).state();
for(var kind:DailyWeather.Kind.values()) {check(!sanctuary.getChild(kind.key()).canUse(source),"Operator choice hidden without Allow Commands: "+kind);refused(()->commands.execute("sanctuary weather "+kind.key(),source));}
for(var kind:List.of(DailyWeather.Kind.OVERCAST,DailyWeather.Kind.FOG,DailyWeather.Kind.SHOWERS)){check(!nativeWeather.getChild(kind.key()).canUse(source),"Native alias hidden for non-operator");refused(()->commands.execute("weather "+kind.key(),source));}
check(WeatherService.session(s).state().equals(before),"Rejected commands cannot alter weather");
s.getCommands().sendCommands(p);
}));
c.waitFor(m->m.player.connection.getCommands().getRoot().getChild("sanctuary").getChild("weather").getChild("fog")==null,200);
server.runOnServer(s->{s.getWorldData().setAllowCommands(true);s.getCommands().sendCommands(s.getPlayerList().getPlayers().getFirst());});
c.waitFor(m->{var weather=m.player.connection.getCommands().getRoot().getChild("weather");return weather!=null&&weather.getChild("fog")!=null;},200);
c.runOnClient(m->{var root=m.player.connection.getCommands().getRoot();for(String key:List.of("clear","rain","thunder","overcast","fog","showers"))check(root.getChild("weather").getChild(key)!=null&&root.getChild("sanctuary").getChild("weather").getChild(key)!=null,"All six profiles available to operator completion: "+key);});
server.runOnServer(s->checked(()->{s.getCommands().getDispatcher().execute("sanctuary time vanilla",s.createCommandSourceStack());WeatherService.setMode(s,false);}));
c.waitFor(m->!WeatherClient.state().active(),200);
// All six choices go over the player's real chat-command connection.
for(var kind:List.of(DailyWeather.Kind.OVERCAST,DailyWeather.Kind.FOG,DailyWeather.Kind.SHOWERS,DailyWeather.Kind.RAIN,DailyWeather.Kind.THUNDER,DailyWeather.Kind.CLEAR)) {
c.runOnClient(m->m.player.connection.sendCommand("sanctuary weather "+kind.key()));
c.waitFor(m->WeatherClient.state().active()&&WeatherClient.state().kind()==kind,200);
server.runOnServer(s->{var now=RealtimeService.civilTime(s);var state=WeatherService.session(s).state();var weather=s.overworld().getWeatherData();check(state.weather()==kind&&state.realtime()&&state.date()==now.toLocalDate().toEpochDay(),"Chosen civil-day profile committed: "+kind);check(weather.isRaining()==DailyWeather.raining(kind,s.overworld().getSeed(),now)&&weather.isThundering()==kind.thundering(),"Native precipitation matches "+kind);check(!RealtimeService.active(s.overworld()),"Weather choice never changes solar mode");});
}
for(var kind:List.of(DailyWeather.Kind.OVERCAST,DailyWeather.Kind.FOG,DailyWeather.Kind.SHOWERS)){
c.runOnClient(m->m.player.connection.sendCommand("weather "+kind.key()));c.waitFor(m->WeatherClient.state().kind()==kind,200);
if(kind==DailyWeather.Kind.FOG){c.waitTicks(205);c.runOnClient(m->check(m.level.environmentAttributes().getDimensionValue(EnvironmentAttributes.FOG_END_DISTANCE)<=80.01,"Commanded fog reaches native rendering"));}
}
server.runOnServer(s->checked(()->{
var path=s.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-weather.json");var before=WeatherService.session(s).state();byte[] bytes=Files.readAllBytes(path);
WeatherService.reload(s);check(WeatherService.session(s).state().equals(before)&&Arrays.equals(bytes,Files.readAllBytes(path)),"Reload retains chosen profile without rewriting it");
check(s.getCommands().getDispatcher().execute("weather showers",s.createCommandSourceStack())==1&&Arrays.equals(bytes,Files.readAllBytes(path)),"Console alias is idempotent");
var current=RealtimeService.civilTime(s);var midnight=current.toLocalDate().plusDays(1).atStartOfDay(current.getZone());at(s,midnight.plusSeconds(1).toInstant());
}));server.waitFor(s->WeatherService.session(s).state().date()==RealtimeService.civilTime(s).toLocalDate().toEpochDay(),200);
server.runOnServer(s->checked(()->{
var now=RealtimeService.civilTime(s);check(WeatherService.session(s).state().date()==now.toLocalDate().toEpochDay()&&WeatherService.session(s).state().weather()==DailyWeather.at(s.overworld().getSeed(),now.toLocalDate(),WeatherConfig.DEFAULT),"Daily forecast resumes on the next civil date");
var commands=s.getCommands().getDispatcher();var source=s.createCommandSourceStack();commands.execute("sanctuary weather vanilla",source);commands.execute("weather rain 600",source);check(s.overworld().getWeatherData().isRaining(),"Native weather commands remain available in vanilla mode");
commands.execute("weather fog",source);check(WeatherService.active(s.overworld())&&WeatherService.session(s).state().weather()==DailyWeather.Kind.FOG,"Alias activates weather from vanilla mode");
Files.writeString(WeatherService.CONFIG,new WeatherConfig(false,15,5,10,3,2).json());WeatherService.reload(s);var before=WeatherService.session(s).state();
check(commands.execute("weather overcast",source)==0&&WeatherService.session(s).state().equals(before)&&!WeatherService.active(s.overworld()),"Disabled weather configuration refuses choices");
Files.writeString(WeatherService.CONFIG,WeatherConfig.DEFAULT.json());WeatherService.reload(s);
var path=s.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-weather.json");byte[] bytes=Files.readAllBytes(path);Files.writeString(path,"{external-conflict");var retained=WeatherService.session(s).state();
check(commands.execute("weather overcast",source)==0&&WeatherService.session(s).state().equals(retained)&&Files.readString(path).equals("{external-conflict"),"Save conflict refuses weather change before native side effects");Files.write(path,bytes);
}));
for(String language:List.of("fr_fr","en_us"))c.runOnClient(m->{m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());check(!WeatherService.label("selected",WeatherService.label("fog"),"2026-09-16","Europe/Paris").getString().contains("sanctuary.weather."),"Localized confirmation "+language);});
System.out.println("WEATHER082_PASS: player commands, operator completion and denials, six choices, native aliases, client fog, current-day persistence, midnight rollover, vanilla return, config disable, atomic save refusal, FR/EN");
}finally{intro.set(false);checked(()->{if(prior==null)Files.deleteIfExists(WeatherService.CONFIG);else Files.write(WeatherService.CONFIG,prior);});}
}
private static byte[] readConfig(){try{return Files.exists(WeatherService.CONFIG)?Files.readAllBytes(WeatherService.CONFIG):null;}catch(Exception e){throw new RuntimeException(e);}}
private static void at(MinecraftServer s,Instant instant)throws Exception{var field=RealtimeService.Session.class.getDeclaredField("clock");field.setAccessible(true);field.set(RealtimeService.session(s),Clock.fixed(instant,ZoneOffset.UTC));RealtimeService.tick(s);}
private interface Checked{void run()throws Exception;}
private static void checked(Checked task){try{task.run();}catch(Exception e){throw new RuntimeException(e);}}
private static void refused(Checked task)throws Exception{try{task.run();throw new AssertionError("Non-operator command accepted");}catch(CommandSyntaxException expected){}}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean value,String reason){if(!value)throw new AssertionError(reason);}
}
@@ -0,0 +1,36 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.progression.ProgressionPayloads;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
/** Native background captures, with the complete original score after a separate welcome fade. */
public final class Welcome083ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var previous=c.computeOnClient(m->m.gui.screen());
var clock=new AtomicLong();var completed=new AtomicInteger();
var uuid=UUID.fromString("01234567-89ab-cdef-fedc-ba9876543210");
c.runOnClient(m->{m.getWindow().setWindowed(1280,800);m.options.guiScale().set(2);m.resizeGui();
m.gui.setScreen(new HelloWorldScreen(new ProgressionPayloads.Hello("Sanctuary",List.of(0x55AAFF,0xFFAA55),""),()->{}));});
c.waitTicks(3);c.takeScreenshot("welcome083-gray");
c.runOnClient(m->m.gui.setScreen(new IntroScreen(uuid,skipped->completed.incrementAndGet(),clock::get,true)));
for(int millis:new int[]{0,250,500,750,1000,4000,24500,30000}) {
clock.set(millis*1_000_000L);c.waitTicks(2);c.takeScreenshot("welcome083-intro-"+millis);
if(millis<24500)c.runOnClient(m->check(((IntroScreen)m.gui.screen()).waitingForMusic(),"Music still waits for portal reveal"));
if(millis>=24500)c.runOnClient(m->check(!((IntroScreen)m.gui.screen()).waitingForMusic(),"Music begins with portal"));
check(completed.get()==0,"Original 29.5-second intro is not shortened by welcome fade");
}
clock.set(30_600_000_000L);c.waitTicks(3);
check(completed.get()==1,"Completion exactly once after fade plus full sequence");
c.runOnClient(m->m.gui.screen().onClose());check(completed.get()==1,"Closing cannot duplicate completion");
clock.set(0);c.runOnClient(m->m.gui.setScreen(new IntroScreen(uuid,skipped->completed.incrementAndGet(),clock::get)));
c.waitTicks(2);c.takeScreenshot("welcome083-replay-black");
c.runOnClient(m->m.gui.setScreen(previous));check(completed.get()==1,"Replacing replay never acknowledges admission");
System.out.println("WELCOME083_PASS: plain welcome gray, monotonic one-second gray-to-black fade, complete intro, portal music, black replay, no duplicate completion");
}
private static void check(boolean value,String reason) {if(!value)throw new AssertionError(reason);}
}
@@ -0,0 +1,140 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.boats.*;
import fr.koka.sanctuary.client.TiledBoatModel;
import fr.koka.sanctuary.progression.SanctuaryGameplay;
import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.core.BlockPos;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.player.Input;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
/** Actual native block shapes, vehicle movement and networked client controls in a development world. */
public final class Boat065ClientChecks implements FabricClientGameTest {
private static final double EPS=1.0E-5;
public void runTest(ClientGameTestContext c) {
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.simulationDistance().set(8);m.options.pauseOnLostFocus=false;});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Boat065 "+UUID.randomUUID());s.setSeed("65");s.setAllowCommands(true);s.getGameRules().set(SanctuaryGameplay.ENABLED,false,null);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("minecraft:flat")).findFirst().orElseThrow());}).create()) {
var server=world.getServer();
server.runOnServer(s->{
var level=s.overworld();walls(level,Blocks.STONE.defaultBlockState());
var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;
int moves=0,turns=0;
for(var variant:CrewBoats.variants()) {
var boat=variant.type().create(level,EntitySpawnReason.COMMAND);
for(int angle=0;angle<360;angle+=45)for(int axis=0;axis<2;axis++)for(int sign:new int[]{-1,1}) {
boat.setYRot(angle);boat.setPos(0,20,0);boat.setDeltaMovement(Vec3.ZERO);
var motion=axis==0?new Vec3(sign*20,0,0):new Vec3(0,0,sign*20);
boat.move(MoverType.SELF,motion);inside(boat);check(boat.horizontalCollision,"Native collision reports contact");
check(boat.position().horizontalDistance()>2,"Craft moved before the wall");
Vec3 before=boat.position();boat.move(MoverType.SELF,motion.scale(-.075));
check(Math.abs(boat.position().distanceTo(before)-1.5)<EPS,"Can back away from contact");inside(boat);moves++;
}
for(int sx:new int[]{-1,1})for(int sz:new int[]{-1,1}) {
boat.setYRot(33);boat.setPos(0,20,0);boat.move(MoverType.SELF,new Vec3(20*sx,0,20*sz));inside(boat);moves++;
}
check(p.startRiding(boat,true,true),"Pilot mounted");
p.setLastClientInput(new Input(false,false,false,true,false,false,false));
for(int angle:new int[]{0,90,180,270}) {
boat.setYRot(angle);boat.setPos(8-boat.getBoundingBox().getXsize()/2-.01,20,0);
check(level.noCollision(boat,boat.getBoundingBox().deflate(EPS)),"Rotation fixture starts clear");
for(int tick=0;tick<20;tick++){boat.control(Vec3.ZERO);boat.move(MoverType.SELF,new Vec3(.35,0,0));inside(boat);}
turns++;
}
// Both endpoint envelopes fit; only the middle of this five-degree turn hits the wall.
boat.setPos(0,20,0);boat.setYRot(0);
for(int tick=0;tick<160;tick++)boat.control(Vec3.ZERO); // Native angular inertia reaches its 5° target in free space.
check(Math.abs(boat.getYRot())>90,"Free turning retained");
double halfWidth=(variant.raft()?variant.columns()*1.25:variant.columns()+.25)/2;
double halfLength=(variant.rows()*1.75+(variant.raft()?0:.25))/2;
float from=(float)Math.toDegrees(Math.atan2(halfLength,halfWidth))-2.5f;
boat.setYRot(from);boat.setPos(8-Math.hypot(halfWidth,halfLength)+.001,20,0);
check(level.noCollision(boat,boat.getBoundingBox().deflate(EPS)),"Arc starts clear");
boat.setYRot(from+5);check(level.noCollision(boat,boat.getBoundingBox().deflate(EPS)),"Arc ends clear");boat.setYRot(from);
boat.control(Vec3.ZERO);check(boat.getYRot()==from,"Swept corner arc rejected before touching wall");inside(boat);turns++;
p.stopRiding();p.setLastClientInput(Input.EMPTY);boat.discard();
}
// Fences and panes must use their native voxel collision shapes, even at high displacement.
int thin=0;
for(var block:List.of(Blocks.OAK_FENCE,Blocks.GLASS_PANE)) {
walls(level,block.defaultBlockState());
for(String wood:List.of("oak","bamboo"))for(var shape:CrewBoats.SHAPES)for(int axis=0;axis<2;axis++)for(int sign:new int[]{-1,1}) {
var boat=CrewBoats.variant(wood,shape.columns(),shape.rows()).type().create(level,EntitySpawnReason.COMMAND);
boat.setYRot(27);boat.setPos(0,20,0);boat.move(MoverType.SELF,axis==0?new Vec3(sign*20,0,0):new Vec3(0,0,sign*20));
check(level.noCollision(boat,boat.getBoundingBox().deflate(EPS)),"Thin obstacle not intersected");
check(Math.abs(boat.getX())<8&&Math.abs(boat.getZ())<8&&boat.horizontalCollision,"No tunneling through thin obstacle");boat.discard();thin++;
}
}
walls(level,Blocks.STONE.defaultBlockState());
for(int x=-8;x<8;x++)for(int z=-8;z<8;z++) {
level.setBlockAndUpdate(new BlockPos(x,18,z),Blocks.STONE.defaultBlockState());
level.setBlockAndUpdate(new BlockPos(x,19,z),Blocks.WATER.defaultBlockState());
}
p.teleportTo(0,25,0);
System.out.println("BOAT065_MATRIX_PASS variants="+CrewBoats.variants().size()+" moves="+moves+" turns="+turns+" thin="+thin);
});
// Compare real mesh vertices (including rails, excluding decorative moving paddles) against each collision box.
c.runOnClient(m->{
for(var v:CrewBoats.variants())for(int yaw=0;yaw<360;yaw+=45) {
var boat=v.type().create(m.level,EntitySpawnReason.COMMAND);boat.setYRot(yaw);boat.setPos(0,20,0);var bounds=boat.getBoundingBox();
var pose=new com.mojang.blaze3d.vertex.PoseStack();pose.rotateDegrees(com.mojang.math.Axis.YP,180-yaw);pose.scale(-1,-1,1);pose.rotateDegrees(com.mojang.math.Axis.YP,90);
TiledBoatModel.hull(v.columns(),v.rows(),v.raft()).bakeRoot().visit(pose,(at,path,index,cube)->{
if(path.contains("paddle"))return;
for(float x:new float[]{cube.minX,cube.maxX})for(float y:new float[]{cube.minY,cube.maxY})for(float z:new float[]{cube.minZ,cube.maxZ}) {
var vertex=at.pose().transformPosition(new org.joml.Vector3f(x/16,y/16,z/16));
check(vertex.x>=bounds.minX-EPS&&vertex.x<=bounds.maxX+EPS&&vertex.z>=bounds.minZ-EPS&&vertex.z<=bounds.maxZ+EPS,"Rendered hull inside collision: "+v.wood()+" "+v.columns()+"x"+v.rows()+" "+path);
}
});boat.discard();
}
});
c.runOnClient(m->m.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK));
int live=0;
for(String wood:List.of("oak","bamboo"))for(var shape:CrewBoats.SHAPES) {
final float yaw=(live%4)*90;final boolean flying=live%2==0;live++;
var id=new java.util.concurrent.atomic.AtomicInteger();
server.runOnServer(s->{
cleanup(s);var p=s.getPlayerList().getPlayers().getFirst();
var boat=CrewBoats.variant(wood,shape.columns(),shape.rows()).type().create(s.overworld(),EntitySpawnReason.COMMAND);
boat.setPos(0,flying?25:20,0);boat.setYRot(yaw);s.overworld().addFreshEntity(boat);id.set(boat.getId());
if(flying){var motor=EntityTypes.ALLAY.create(s.overworld(),EntitySpawnReason.COMMAND);motor.setPos(boat.position());motor.setNoAi(true);s.overworld().addFreshEntity(motor);motor.startRiding(boat);}
p.teleportTo(boat.getX(),boat.getY(),boat.getZ());p.startRiding(boat,true,true);
});
c.waitFor(m->m.player.getVehicle()!=null&&m.player.getVehicle().getId()==id.get(),200);
c.getInput().lookAt(yaw,25);c.getInput().holdKey(o->o.keyUp);if(flying)c.getInput().holdKey(o->o.keyJump);c.waitTicks(65);
c.getInput().holdKey(o->o.keyRight);c.waitTicks(15);c.getInput().releaseKey(o->o.keyRight);c.getInput().releaseKey(o->o.keyUp);
server.runOnServer(s->{var b=(CrewBoat)s.overworld().getEntity(id.get());check(b!=null&&b.getPassengers().stream().anyMatch(net.minecraft.server.level.ServerPlayer.class::isInstance),"Live pilot stays seated");inside(b);check(b.position().horizontalDistance()>2,"Native keys moved vessel");});
c.waitTicks(5);c.runOnClient(m->inside((CrewBoat)m.player.getVehicle()));
if(shape.columns()==3&&shape.rows()==3)c.takeScreenshot("boat065-"+wood+"-wall-contact");
var before=new AtomicReference<Vec3>();server.runOnServer(s->before.set(s.overworld().getEntity(id.get()).position()));
c.getInput().holdKey(o->o.keyDown);c.waitTicks(25);c.getInput().releaseKey(o->o.keyDown);if(flying)c.getInput().releaseKey(o->o.keyJump);
server.runOnServer(s->{var b=(CrewBoat)s.overworld().getEntity(id.get());inside(b);check(b.position().subtract(before.get()).horizontalDistance()>.15,"Native reverse input frees craft");});
System.out.println("BOAT065_LIVE_PASS "+wood+" "+shape.columns()+"x"+shape.rows()+" yaw="+yaw+" flying="+flying);
}
server.runOnServer(Boat065ClientChecks::cleanup);
System.out.println("BOAT065_PASS native collision, meshes and "+live+" piloted water/flight cases");
} finally {
c.getInput().releaseKey(o->o.keyUp);c.getInput().releaseKey(o->o.keyDown);c.getInput().releaseKey(o->o.keyRight);c.getInput().releaseKey(o->o.keyJump);
}
}
private static void cleanup(MinecraftServer server) {
var p=server.getPlayerList().getPlayers().getFirst();var old=p.getVehicle();p.stopRiding();
if(old!=null)old.getSelfAndPassengers().toList().reversed().forEach(Entity::discard);
}
private static void walls(ServerLevel level,net.minecraft.world.level.block.state.BlockState state) {
for(int y=18;y<=70;y++)for(int i=-9;i<=8;i++) {
level.setBlockAndUpdate(new BlockPos(-9,y,i),state);level.setBlockAndUpdate(new BlockPos(8,y,i),state);
level.setBlockAndUpdate(new BlockPos(i,y,-9),state);level.setBlockAndUpdate(new BlockPos(i,y,8),state);
}
}
private static void inside(CrewBoat b) {
var box=b.getBoundingBox();check(box.minX>=-8-EPS&&box.maxX<=8+EPS&&box.minZ>=-8-EPS&&box.maxZ<=8+EPS,"Craft remains inside walls: "+b.position()+" "+box);
check(b.level().noBlockCollision(b,box.deflate(EPS)),"Hull does not overlap blocks");
}
private static void check(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,92 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.boats.*;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import java.util.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.inventory.ContainerScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.Container;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.vehicle.boat.AbstractBoat;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
/** Native cursor and mouse packets, including actual mounted mob damage and head dispenser activation. */
public final class BoatPassengers091ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
ClientTickEvents.END_CLIENT_TICK.register(m->{
if(m.gui.screen() instanceof IntroScreen s)s.onClose();
if(m.gui.screen() instanceof HelloWorldScreen s){
Starter039TestSupport.selectFirst(s);
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());
}
});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("BoatPassengers091 "+UUID.randomUUID());s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
world.getConnection().waitForChunksDownload(2400);
c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.getInventory().clearContent();AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);
for(int x=-8;x<=8;x++)for(int z=-8;z<=8;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());
});
var types=new ArrayList<EntityType<? extends AbstractBoat>>();types.add(EntityTypes.OAK_BOAT);types.add(EntityTypes.BAMBOO_RAFT);
for(var shape:CrewBoats.SHAPES)types.add(CrewBoats.variant("oak",shape.columns(),shape.rows()).type());
types.add(CrewBoats.variant("bamboo",3,3).type());types.add(EntityTypes.OAK_BOAT);types.add(EntityTypes.OAK_BOAT);
for(int index=0;index<types.size();index++){
var type=types.get(index);boolean familiar=index==types.size()-1,baby=index==types.size()-2;
int[] ids=server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.stopRiding();p.setPos(0,2,0);
var boat=type.create(p.level(),EntitySpawnReason.COMMAND);boat.setPos(0,1,0);p.level().addFreshEntity(boat);
Mob mob;
if(familiar){var pet=FamiliarService.TYPE.create(p.level(),EntitySpawnReason.COMMAND);pet.bind(p,new ItemStack(Items.CHICKEN_SPAWN_EGG));mob=pet;}
else{var chicken=EntityTypes.CHICKEN.create(p.level(),EntitySpawnReason.COMMAND);chicken.setBaby(baby);mob=chicken;}
mob.setPos(boat.position());mob.setNoAi(true);mob.getAttribute(Attributes.MAX_HEALTH).setBaseValue(40);mob.setHealth(40);p.level().addFreshEntity(mob);
boolean boarded=boat instanceof CrewBoat ? p.startRiding(boat,true,true)&&mob.startRiding(boat,true,true) : mob.startRiding(boat,true,true)&&p.startRiding(boat,true,true);
check(boarded,"Both passengers board "+type);
HeadEquipment.set(mob,new ItemStack(Items.CHEST));((Container)HeadBlockService.session(mob).entity).setItem(0,new ItemStack(Items.DIAMOND,7));
p.inventoryMenu.broadcastChanges();return new int[]{boat.getId(),mob.getId()};
});
c.waitFor(m->m.player.getVehicle()!=null&&m.player.getVehicle().getId()==ids[0]&&m.level.getEntity(ids[1])!=null&&m.level.getEntity(ids[1]).getVehicle()==m.player.getVehicle(),200);c.waitTicks(15);
aim(c,ids[1]);assertPick(c,ids[1]);
c.getInput().pressMouse(InputConstants.MOUSE_BUTTON_RIGHT);
c.waitFor(m->m.gui.screen() instanceof ContainerScreen,100);
server.runOnServer(s->check(s.getPlayerList().getPlayers().getFirst().containerMenu.getSlot(0).getItem().is(Items.DIAMOND),"Right click opens the seated mob's actual chest"));
c.runOnClient(m->m.player.closeContainer());c.waitTicks(8);
server.runOnServer(s->{var mob=mob(s,ids[1]);HeadEquipment.set(mob,new ItemStack(Items.DISPENSER));((Container)HeadBlockService.session(mob).entity).setItem(0,new ItemStack(Items.ARROW,4));});
c.waitTicks(8);aim(c,ids[1]);assertPick(c,ids[1]);c.getInput().pressMouse(InputConstants.MOUSE_BUTTON_LEFT);
server.waitFor(s->(familiar?mob(s,ids[1]).getHealth()==40:mob(s,ids[1]).getHealth()<40)&&((Container)HeadBlockService.session(mob(s,ids[1])).entity).getItem(0).getCount()==3,100);
server.runOnServer(s->{check(mob(s,ids[1]).getVehicle()!=null,"Hit does not unseat the animal");check(!p(s).level().getEntitiesOfClass(net.minecraft.world.entity.projectile.arrow.Arrow.class,new AABB(-12,-4,-12,12,10,12)).isEmpty(),"A real projectile is fired");});
c.runOnClient(m->{var other=EntityTypes.CHICKEN.create(m.level,EntitySpawnReason.COMMAND);check(!BoatPassengerInteractions.aboardTogether(m.player,other),"Unrelated mob excluded from exception");check(!BoatPassengerInteractions.aboardTogether(m.player,m.player),"Player seats excluded from exception");});
if(familiar)server.runOnServer(s->{
var pet=(FamiliarEntity)mob(s,ids[1]);check(!CarryService.pair(p(s),pet),"Seated familiar accepts taps");
var ammo=(Container)HeadBlockService.session(pet).entity;
pet.stopRiding();pet.setAttached(CarryService.CARRIED,true);check(pet.startRiding(p(s),true,true),"Familiar can be carried for protection check");
check(CarryService.pair(p(s),pet)&&CarryService.pair(pet,p(s)),"Head carry protection is symmetric and preserved");
pet.tap(p(s));check(pet.getHealth()==40&&ammo.getItem(0).getCount()==3,"Carried familiar remains protected from equipment pulses");
pet.setAttached(CarryService.CARRIED,false);
});
System.out.println("BOAT091_SEAT_PASS "+type+" familiar="+familiar+" baby="+baby);
server.runOnServer(s->{p(s).stopRiding();HeadEquipment.set(mob(s,ids[1]),ItemStack.EMPTY);mob(s,ids[1]).discard();p(s).level().getEntity(ids[0]).discard();});c.waitTicks(8);
}
System.out.println("BOAT091_PASS: vanilla boat/raft, every tiled shape, native targeting, right-click storage, left-click damage and real dispenser shots, baby/familiar passengers and preserved carry protection");
}
}
private static net.minecraft.server.level.ServerPlayer p(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static Mob mob(MinecraftServer s,int id){return (Mob)p(s).level().getEntity(id);}
private static void aim(ClientGameTestContext c,int id){var d=c.computeOnClient(m->m.level.getEntity(id).getBoundingBox().getCenter().subtract(m.player.getEyePosition()));c.getInput().lookAt((float)Math.toDegrees(Math.atan2(-d.x,d.z)),(float)-Math.toDegrees(Math.atan2(d.y,Math.sqrt(d.x*d.x+d.z*d.z))));c.waitTicks(4);}
private static void assertPick(ClientGameTestContext c,int id){c.runOnClient(m->{var h=m.player.raycastHitResult(1,m.player);check(h instanceof EntityHitResult e&&e.getEntity().getId()==id,"Native crosshair must pick seated animal: "+h+" eye="+m.player.getEyePosition()+" view="+m.player.getViewVector(1)+" mob="+m.level.getEntity(id).getBoundingBox()+" yaw="+m.player.getYRot()+" eligible="+BoatPassengerInteractions.aboardTogether(m.player,m.level.getEntity(id)));});}
private static void check(boolean value,String message){if(!value)throw new AssertionError(message);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
}
@@ -51,11 +51,11 @@ public final class Collections035ClientChecks implements FabricClientGameTest {
m.gui.setScreen(new InventoryScreen(m.player));});c.waitTicks(5);c.takeScreenshot("sanctuary-beta035-personal-catalogue");
hover(c,36);c.getInput().pressKey(InputConstants.KEY_U);c.waitFor(m->m.gui.screen() instanceof mezz.jei.gui.recipes.RecipesGui,60);
c.runOnClient(m->{m.gui.setScreen(new RecipeScreen(null));button(m.gui.screen(),"sanctuary.recipes.open").onPress(enter());});c.waitFor(m->m.gui.screen() instanceof mezz.jei.gui.recipes.RecipesGui,60);c.waitTicks(5);c.takeScreenshot("sanctuary-beta035-wood-recipes");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();s.getPlayerList().op(p.nameAndId());RecipeService.refresh(p);});c.waitFor(m->RecipeClient.operatorAllowed,120);
c.runOnClient(m->{require(!RecipeClient.operator,"Operator status alone does not enable full view");m.gui.setScreen(new RecipeScreen(null));RecipeClient.request("operator");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();s.getWorldData().setAllowCommands(true);s.getPlayerList().op(p.nameAndId());RecipeService.refresh(p);});c.waitFor(m->RecipeClient.operatorAllowed,120);
c.runOnClient(m->{require(RecipeClient.operator,"Allow Commands enables the operator catalogue");m.gui.setScreen(new RecipeScreen(null));});
c.waitFor(m->RecipeClient.operator,120);c.waitTicks(3);c.takeScreenshot("sanctuary-beta035-operator-view");
c.runOnClient(m->require(SanctuaryJeiPlugin.runtime().getIngredientFilter().getFilteredItemStacks().stream().anyMatch(i->i.is(Items.DIAMOND_BLOCK)),"Explicit operator sees unknown items"));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();s.getPlayerList().deop(p.nameAndId());RecipeService.refresh(p);});
c.runOnClient(m->require(SanctuaryJeiPlugin.runtime().getIngredientFilter().getFilteredItemStacks().stream().anyMatch(i->i.is(Items.DIAMOND_BLOCK)),"Operator sees unknown items"));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();s.getWorldData().setAllowCommands(false);s.getPlayerList().deop(p.nameAndId());RecipeService.refresh(p);});
c.waitFor(m->!RecipeClient.operatorAllowed&&!RecipeClient.operator,120);c.runOnClient(m->require(SanctuaryJeiPlugin.runtime().getIngredientFilter().getFilteredItemStacks().stream().noneMatch(i->i.is(Items.DIAMOND_BLOCK)),"Permission revocation removes unknown items"));
// Use JEI's real transfer packet with ingredients available only in the final added row.
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var progress=ProgressEffects.get(p);for(int i=progress.inventory();i<5;i++)progress=progress.buy("inventory",1);
@@ -0,0 +1,93 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.vertex.PoseStack;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.progression.SanctuaryGameplay;
import java.lang.reflect.Proxy;
import java.util.*;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.Minecraft;
import net.minecraft.client.CameraType;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.state.*;
import net.minecraft.core.Direction;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.entity.CampfireBlockEntity;
/** Native renderer submissions and synchronized animation, in a disposable integrated world. */
public final class Cosmetic066ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(6);m.options.pauseOnLostFocus=false;});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Cosmetic066 "+UUID.randomUUID());s.setSeed("66");s.setAllowCommands(true);s.getGameRules().set(SanctuaryGameplay.ENABLED,false,null);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("minecraft:flat")).findFirst().orElseThrow());}).create()) {
world.getConnection().waitForChunksDownload();var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.getInventory().clearContent();s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");});
c.runOnClient(m->{m.options.setCameraType(CameraType.THIRD_PERSON_FRONT);m.options.fov().set(40);m.getWindow().setWindowed(1280,800);m.resizeGui();});c.getInput().lookAt(0,-12);
for(var item:List.of(Items.CHEST,Items.TRAPPED_CHEST,Items.ENDER_CHEST,Items.SHULKER_BOX)) {
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();HeadEquipment.set(p,new ItemStack(item));HeadBlockService.session(p).event(1,1);});
c.waitTicks(18);
c.takeScreenshot("cosmetic066-open-"+item);
c.runOnClient(m->{var h=head(m,m.player);var calls=draw(h);System.out.println("COSMETIC066_OPEN "+item+" "+calls);
check(calls.models()==1,"Exactly one animated container, no closed duplicate: "+item+" "+calls);
if(h.blockEntity instanceof ChestRenderState chest)check(chest.open>.7,"Synced chest lid opens");
else check(h.blockEntity instanceof ShulkerBoxRenderState box&&box.progress>.9,"Synced Shulker lid opens");
});
server.runOnServer(s->HeadBlockService.session(s.getPlayerList().getPlayers().getFirst()).event(1,0));c.waitTicks(18);
c.runOnClient(m->{var h=head(m,m.player);check(draw(h).models()==1,"One closed model");if(h.blockEntity instanceof ChestRenderState chest)check(chest.open<.05,"Chest closes");else check(((ShulkerBoxRenderState)h.blockEntity).progress<.05,"Shulker closes");});
}
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();HeadEquipment.set(p,new ItemStack(Items.BELL));HeadBlockService.session(p).event(1,Direction.NORTH.get3DDataValue());});
c.waitTicks(7);c.takeScreenshot("cosmetic066-bell-ringing");
c.runOnClient(m->{var h=head(m,m.player);var calls=draw(h);check(calls.models()==1&&calls.blocks==1,"One moving bell and its static support: "+calls);check(h.blockEntity instanceof BellRenderState bell&&bell.shakeDirection!=null&&bell.ticks>1,"Bell shakes from the server event");});
c.waitTicks(50);c.runOnClient(m->check(((BellRenderState)head(m,m.player).blockEntity).shakeDirection==null,"Bell settles normally"));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();HeadEquipment.set(p,new ItemStack(Items.CAMPFIRE));var session=HeadBlockService.session(p);HeadBlockService.scoped(session,()->{((CampfireBlockEntity)session.entity).placeFood(p.level(),p,new ItemStack(Items.BEEF));return null;});});
c.waitTicks(15);c.runOnClient(m->{var h=head(m,m.player);check(draw(h).blocks==1,"Campfire body remains");check(h.blockEntity instanceof CampfireRenderState f&&f.items.stream().anyMatch(i->!i.isEmpty()),"Campfire still renders synchronized food");});c.takeScreenshot("cosmetic066-campfire-food");
// The same renderer must retain partial static bodies and complete models that have no live renderer.
c.runOnClient(m->{
var original=HeadCosmeticService.appearance(m.player).copy();
for(var item:List.of(Items.ENCHANTING_TABLE,Items.LECTERN,Items.OAK_SIGN,Items.BANNER.white(),Items.PLAYER_HEAD,Items.BED.red(),Items.OAK_DOOR,Items.FURNACE,Items.OAK_SHELF)) {
m.player.setAttached(AccessoryService.APPEARANCE,new AccessoryService.Appearance(ItemStack.EMPTY,new ItemStack(item)));
var h=head(m,m.player);var calls=draw(h);System.out.println("COSMETIC066_RETAINED "+item+" "+calls);
check(calls.models()+calls.blocks>0,"Head visual retained: "+item);
if(item==Items.ENCHANTING_TABLE)check(calls.models()==1&&calls.blocks==1,"One book on enchanting table body");
if(item==Items.OAK_SIGN)check(calls.blocks==1&&calls.models()==0,"One sign board mesh");
if(item==Items.BANNER.white())check(Collections.frequency(calls.modelNames,"BannerModel")==1,"One banner support, with native fabric texture passes");
if(item==Items.PLAYER_HEAD)check(calls.models()==1,"One skull model");
if(item==Items.BED.red()||item==Items.OAK_DOOR)check(!h.second.isEmpty(),"Both bed/door halves retained");
}
int nextId=200000;
for(var type:List.of(EntityTypes.CHICKEN,EntityTypes.COW,EntityTypes.ZOMBIE))for(boolean baby:List.of(false,true)) {
var mob=(Mob)type.create(m.level,EntitySpawnReason.COMMAND);mob.setId(nextId++);mob.setPos(m.player.position());mob.setBaby(baby);
for(var item:List.of(Items.CHEST,Items.BELL)) {
mob.setAttached(AccessoryService.APPEARANCE,new AccessoryService.Appearance(ItemStack.EMPTY,new ItemStack(item)));
var h=head(m,mob);var calls=draw(h);check(calls.models()==1,"Mob hat has one model: "+type+" baby="+baby+" "+item+" "+calls);
if(item==Items.BELL)check(calls.blocks==1,"Mob bell keeps its support");
}
}
m.player.setAttached(AccessoryService.APPEARANCE,new AccessoryService.Appearance(ItemStack.EMPTY,original));
});
System.out.println("COSMETIC066_PASS: native submissions, four synced container animations, bell ring/settle, static supports, food and twelve mob hat cases");
}
}
private static HeadCosmeticState head(Minecraft m,LivingEntity entity) {
var state=m.getEntityRenderDispatcher().getRenderer(entity).createRenderState(entity,1);
return entity==m.player?((HeadCosmeticState.Access)state).sanctuary$headCosmetic():((MobHeadState.Access)state).sanctuary$mobHat().head;
}
private static final class Calls {
final List<String> modelNames=new ArrayList<>();int blocks;
int models(){return modelNames.size();}
public String toString(){return "models="+modelNames+", block meshes="+blocks;}
}
private static Calls draw(HeadCosmeticState head) {
var calls=new Calls();
SubmitNodeCollector collector=(SubmitNodeCollector)Proxy.newProxyInstance(SubmitNodeCollector.class.getClassLoader(),new Class<?>[]{SubmitNodeCollector.class},(proxy,method,args)->{
if(method.getName().equals("order"))return proxy;
if(method.getName().equals("submitModel"))calls.modelNames.add(args[0].getClass().getSimpleName());
if(method.getName().equals("submitBlockModel"))calls.blocks++;
return null;
});
HeadCosmeticLayer.submitAtHead(new PoseStack(),collector,15728880,0,head);return calls;
}
private static void check(boolean yes,String message){if(!yes)throw new AssertionError(message);}
}
@@ -0,0 +1,70 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.level.gamerules.GameRules;
/** Creation toggles and saved-world reopen, using ordinary vanilla presets and actual admission. */
public final class Gameplay063ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var observing=new AtomicBoolean(true);var hello=new AtomicInteger();var lastHello=new AtomicReference<HelloWorldScreen>();
ClientTickEvents.END_CLIENT_TICK.register(m->{if(observing.get()&&m.gui.screen() instanceof HelloWorldScreen s){
if(lastHello.getAndSet(s)==s)return;
hello.incrementAndGet();IntroClient.setQuickTestIntro(true);Starter039TestSupport.selectFirst(s);
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());
}});
c.runOnClient(m->{m.options.renderDistance().set(4);m.options.simulationDistance().set(4);m.options.pauseOnLostFocus=false;});
try{
for(String preset:List.of("minecraft:flat","minecraft:normal"))for(boolean enabled:List.of(true,false)){
int before=hello.get();net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
var identity=new AtomicReference<UUID>();
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Gameplay063 "+UUID.randomUUID());s.setSeed("63");s.setAllowCommands(true);s.setGenerateStructures(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals(preset)).findFirst().orElseThrow());s.getGameRules().set(SanctuaryGameplay.ENABLED,enabled,null);}).create()){
saved=world.getWorldSave();c.waitFor(m->m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),2400);c.waitTicks(25);
check((hello.get()>before)==enabled,"Hello World only when enabled: "+preset+" "+enabled);
check(c.computeOnClient(m->ProgressionClient.active())==enabled,"Client knows selected mode");
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;
check(SanctuaryGameplay.supported(s)==enabled&&(ProgressionService.session(s)!=null)==enabled,"Server uses the creation choice");
check((s.overworld().getChunkSource().getGenerator() instanceof net.minecraft.world.level.levelgen.FlatLevelSource)==preset.endsWith("flat"),"Selected native generator preserved");
try{fr.koka.sanctuary.expansion.ExpansionRuntime.session(s.overworld());throw new AssertionError("Native terrain must not open an expansion session");}
catch(IllegalArgumentException expected){check(expected.getMessage().equals("laboratory_required"),"No island planning or expansion system on native terrain");}
check(SanctuaryInventoryCapacity.unlockedRows(p)==(enabled?1:4),"Correct inventory capacity");
check(p.getAttributeValue(Attributes.MAX_HEALTH)==(enabled?6:20),"Correct initial health");
check(fr.koka.sanctuary.realtime.RealtimeService.active(s.overworld())==enabled&&fr.koka.sanctuary.weather.WeatherService.active(s.overworld())==enabled,"Clock and weather follow gameplay choice");
if(enabled){check(FamiliarService.active(p)!=null,"Starter familiar enters native terrain");identity.set(fr.koka.sanctuary.familiar.FamiliarBattle.bond(p).id());}
check(s.getGlobalGameRules().get(SanctuaryGameplay.ENABLED)==enabled,"Rule retained");
s.overworld().getGameRules().set(SanctuaryGameplay.ENABLED,!enabled,s);check(s.overworld().getGameRules().get(SanctuaryGameplay.ENABLED)==enabled,"Live dimension rule flip rejected before mutation");
s.getGlobalGameRules().set(SanctuaryGameplay.ENABLED,!enabled,s);check(s.getGlobalGameRules().get(SanctuaryGameplay.ENABLED)==enabled,"Live global rule flip rejected");
try{s.getCommands().getDispatcher().execute("gamerule sanctuary:use_sanctuary "+!enabled,s.createCommandSourceStack());throw new AssertionError("Command must be rejected");}catch(com.mojang.brigadier.exceptions.CommandSyntaxException expected){check(expected.getRawMessage().getString().contains("Sanctuary"),"Command explains creation-only choice");}
s.overworld().getGameRules().set(GameRules.KEEP_INVENTORY,true,s);check(s.overworld().getGameRules().get(GameRules.KEEP_INVENTORY),"Other game rules remain editable");
});
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),2400);c.waitTicks(20);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(s.getWorldData().overworldData().isInitialized(),"Reopened saved world");check(SanctuaryGameplay.supported(s)==enabled&&(ProgressionService.session(s)!=null)==enabled,"Mode survives reopening");
if(enabled)check(identity.get().equals(fr.koka.sanctuary.familiar.FamiliarBattle.bond(p).id()),"Starter identity preserved across reopen");
});
check(hello.get()==before+(enabled?1:0),"Reopen does not repeat character creation");
}
System.out.println("GAMEPLAY063_CASE_PASS: "+preset+" enabled="+enabled+" creation and reopen");
}
for(String locale:List.of("fr_fr","en_us"))c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());check(!Component.translatable(SanctuaryGameplay.ENABLED.getDescriptionId()).getString().contains("gamerule."),"Native gamerule name localized: "+locale);check(!Component.translatable(SanctuaryGameplay.ENABLED.getDescriptionId()+".description").getString().contains("gamerule."),"Native tooltip localized: "+locale);});
System.out.println("GAMEPLAY063_PASS");
}finally{observing.set(false);}
}
private static KeyEvent enter(){return new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,94 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.multiblock.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.network.protocol.game.ServerboundPickItemFromBlockPacket;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.piston.*;
import net.minecraft.world.phys.*;
public final class Pick092ClientChecks implements FabricClientGameTest {
private static final BlockPos C=new BlockPos(0,50,0);
public void runTest(ClientGameTestContext c){
var intro=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{
if(m.gui.screen() instanceof IntroScreen s)s.onClose();
if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen s){Starter039TestSupport.selectFirst(s);
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false);
}
});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Pick092 "+UUID.randomUUID());s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
var server=world.getServer();server.runOnServer(s->{creative(p(s));verifyStates(p(s).level());});c.runOnClient(m->verifyStates(m.level));
for(boolean sticky:new boolean[]{false,true})for(var f:Direction.values()){
Item item=sticky?Items.STICKY_PISTON:Items.PISTON;
server.runOnServer(s->{var l=p(s).level();for(var facing:Direction.values())for(boolean glue:new boolean[]{false,true})SuperPistons.dismantle(l,C,facing,glue);
for(int x=-6;x<=6;x++)for(int y=-6;y<=6;y++)for(int z=-6;z<=6;z++)l.setBlockAndUpdate(C.offset(x,y,z),Blocks.AIR.defaultBlockState());
for(int i=0;i<9;i++)l.setBlockAndUpdate(SuperPistons.tile(C,f,i),(sticky?Blocks.STICKY_PISTON:Blocks.PISTON).defaultBlockState().setValue(PistonBaseBlock.FACING,f));
check(SuperPistons.assemble(l,C,f,sticky),"Assemble real piston");
});c.waitTicks(5);
pick(c,server,C,f,item,false);
// Ctrl-pick uses the actual server handler; its result must still be an ordinary piston.
server.runOnServer(s->{p(s).getInventory().clearContent();p(s).connection.handlePickItemFromBlock(new ServerboundPickItemFromBlockPacket(C,true));check(ItemStack.matches(p(s).getMainHandItem(),new ItemStack(item)),"Ctrl-pick excludes controller data: "+p(s).getMainHandItem()+" components="+p(s).getMainHandItem().getComponents()+" eye="+p(s).getEyePosition());});
server.runOnServer(s->p(s).level().setBlockAndUpdate(SuperPistons.tile(C,f,0).relative(f,-1),Blocks.REDSTONE_BLOCK.defaultBlockState()));
server.waitFor(s->p(s).level().getBlockEntity(C) instanceof SuperPistonController be&&be.stroke==3&&be.pending.isEmpty(),200);c.waitTicks(5);
pick(c,server,C.relative(f,3),f,item,false);
pick(c,server,C.relative(f),SuperPistons.u(f),item,false);
pick(c,server,C.relative(f,2),SuperPistons.u(f),item,false);
if(f==Direction.NORTH){
server.runOnServer(s->{var p=p(s);p.setGameMode(GameType.SURVIVAL);p.getAbilities().mayfly=true;p.getAbilities().flying=true;p.onUpdateAbilities();});
pick(c,server,C.relative(f,3),f,item,true);
server.runOnServer(s->{p(s).getInventory().clearContent();p(s).inventoryMenu.broadcastChanges();});
c.waitFor(m->m.player.getMainHandItem().isEmpty(),100);c.getInput().pressMouse(InputConstants.MOUSE_BUTTON_MIDDLE);c.waitTicks(8);
server.runOnServer(s->{check(p(s).getInventory().isEmpty(),"Survival pick creates no item");creative(p(s));});
}
System.out.println("PICK092_DIRECTION_PASS sticky="+sticky+" facing="+f);
}
server.runOnServer(s->moving(p(s)));
System.out.println("PICK092_PASS: 864 states on client/server; real middle clicks for bases, heads, both rods, six directions and variants; survival and moving/Ctrl-pick");
}finally{intro.set(false);}
}
private static void pick(ClientGameTestContext c,TestServerContext server,BlockPos pos,Direction from,Item item,boolean survival){
server.runOnServer(s->{var p=p(s);p.getInventory().clearContent();p.getInventory().setSelectedSlot(0);if(survival)p.getInventory().setItem(4,new ItemStack(item,5));p.inventoryMenu.broadcastChanges();
p.teleportTo(pos.getX()+.5+from.getStepX()*2.5,pos.getY()+.5+from.getStepY()*2.5-p.getEyeHeight(),pos.getZ()+.5+from.getStepZ()*2.5);p.setDeltaMovement(Vec3.ZERO);p.getAbilities().flying=true;p.onUpdateAbilities();
});c.waitFor(m->m.player.getMainHandItem().isEmpty(),100);c.waitTicks(5);c.runOnClient(m->{m.player.getAbilities().flying=true;m.player.setDeltaMovement(Vec3.ZERO);});c.getInput().lookAt(pos);c.waitTicks(4);
c.runOnClient(m->{var hit=m.player.raycastHitResult(1,m.player);check(hit instanceof BlockHitResult b&&b.getType()==HitResult.Type.BLOCK&&b.getBlockPos().equals(pos),"Native cursor targets the requested part: "+pos+" got "+hit.getLocation());});
c.getInput().pressMouse(InputConstants.MOUSE_BUTTON_MIDDLE);
c.waitFor(m->m.player.getMainHandItem().is(item),100);
server.runOnServer(s->check(ItemStack.matches(p(s).getMainHandItem(),new ItemStack(item,survival?5:1)),"Exact source item and count, no technical data"));
}
private static void verifyStates(LevelReader l){int count=0;for(var b:List.of(SuperPistons.NORMAL,SuperPistons.GLUANT,SuperPistons.HEAD,SuperPistons.ROD))for(var s:b.getStateDefinition().getPossibleStates()){
boolean sticky=s.getBlock() instanceof SuperPistonBlocks.Base base?base.sticky:s.getValue(SuperPistonBlocks.STICKY);
for(boolean data:new boolean[]{false,true})check(ItemStack.matches(s.getCloneItemStack(l,C,data),new ItemStack(sticky?Items.STICKY_PISTON:Items.PISTON)),"Source for "+s);count++;
}check(count==864,"Every technical state checked");}
private static void moving(ServerPlayer p){var l=p.level();var pos=C.offset(5,0,0);p.teleportTo(pos.getX()+.5,pos.getY(),pos.getZ()+2);creative(p);
for(boolean sticky:new boolean[]{false,true})for(boolean extending:new boolean[]{false,true}){
var carried=extending?SuperPistons.headForBase(SuperPistons.base(Direction.NORTH,4,sticky,true),2):SuperPistons.base(Direction.NORTH,4,sticky,true);
var state=Blocks.MOVING_PISTON.defaultBlockState().setValue(MovingPistonBlock.FACING,Direction.NORTH);l.setBlock(pos,state,324);l.setBlockEntity(MovingPistonBlock.newMovingBlockEntity(pos,state,carried,Direction.NORTH,extending,true));
p.getInventory().clearContent();p.connection.handlePickItemFromBlock(new ServerboundPickItemFromBlockPacket(pos,true));check(ItemStack.matches(p.getMainHandItem(),new ItemStack(sticky?Items.STICKY_PISTON:Items.PISTON)),"Moving source picks ordinary piston without motion NBT");
l.removeBlockEntity(pos);
}
var state=Blocks.MOVING_PISTON.defaultBlockState();l.setBlockEntity(MovingPistonBlock.newMovingBlockEntity(pos,state,Blocks.STONE.defaultBlockState(),Direction.NORTH,true,false));check(state.getCloneItemStack(l,pos,false).isEmpty(),"Vanilla moving cargo behaviour unchanged");l.removeBlockEntity(pos);l.setBlock(pos,Blocks.AIR.defaultBlockState(),324);
for(var block:List.of(Blocks.FURNACE,Blocks.BARREL))check(block.defaultBlockState().getCloneItemStack(l,pos,false).is(block.asItem()),"Other multiblock source blocks still pick normally");
}
private static void creative(ServerPlayer p){p.setGameMode(GameType.CREATIVE);p.setNoGravity(true);p.getAbilities().flying=true;p.onUpdateAbilities();}
private static ServerPlayer p(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static void check(boolean v,String message){if(!v)throw new AssertionError(message);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
}
@@ -0,0 +1,129 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.inventory.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.CustomData;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
/** Native solar engine, live equipment packets and familiar combat health on a fresh flat world. */
public final class Sunlight062ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var onboarding=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen s){
Starter039TestSupport.selectFirst(s);
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());onboarding.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sunlight062 "+UUID.randomUUID());s.setSeed("62");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&m.gui.screen()==null&&ProgressionClient.active()&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=player(s);p.getAbilities().invulnerable=true;p.getInventory().clearContent();
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);
command(s,"sanctuary time vanilla");command(s,"sanctuary weather vanilla");command(s,"weather clear 100000");command(s,"time set noon");
p.teleportTo(.5,1,.5);for(int x=-9;x<=9;x++)for(int z=-9;z<=9;z++)for(int y=0;y<=7;y++)p.level().setBlockAndUpdate(new BlockPos(x,y,z),(y==0?Blocks.STONE:Blocks.AIR).defaultBlockState());
});c.waitTicks(30);
server.runOnServer(s->{species(player(s));nativeHats(player(s));command(s,"time set midnight");});c.waitTicks(5);
server.runOnServer(s->{var p=player(s);var pet=probe(p,Items.ZOMBIE_SPAWN_EGG);expose(pet);check(!pet.isOnFire(),"Night prevents ignition");pet.discard();
fresh(p,Items.ZOMBIE_SPAWN_EGG);p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.DIAMOND));p.inventoryMenu.broadcastChanges();});c.waitTicks(25);
int id=server.computeOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet!=null,"Equipped zombie egg spawns");pet.setNoAi(true);pet.setPos(p.position().add(0,0,2));pet.setDeltaMovement(Vec3.ZERO);return pet.getId();});
c.waitFor(m->m.level.getEntity(id)!=null&&m.player.getMainHandItem().is(Items.DIAMOND),100);
c.getInput().holdKey(o->o.keyShift);c.waitTicks(5);click(c,id);c.waitTicks(8);c.getInput().releaseKey(o->o.keyShift);
float healthy=server.computeOnServer(s->{var pet=FamiliarService.active(player(s));check(HeadEquipment.get(pet).is(Items.DIAMOND),"Real sneak-click equips a non-block sun hat");command(s,"time set noon");return pet.getHealth();});
c.waitTicks(100);server.runOnServer(s->{var pet=FamiliarService.active(player(s));check(!pet.isOnFire()&&pet.getHealth()==healthy,"Hat protects familiar during real server ticks");expose(pet);check(!pet.isOnFire(),"Hat also withstands a long solar exposure");});
c.runOnClient(m->{var pet=(FamiliarEntity)m.level.getEntity(id);var state=(FamiliarRenderer.State)m.getEntityRenderDispatcher().getRenderer(pet).createRenderState(pet,1);check(((MobHeadState.Access)state.appearance).sanctuary$mobHat().active,"Synced hat renders on the familiar model");});
c.getInput().holdKey(o->o.keyShift);c.waitTicks(5);click(c,id);c.waitTicks(8);c.getInput().releaseKey(o->o.keyShift);
server.runOnServer(s->{var pet=FamiliarService.active(player(s));check(HeadEquipment.get(pet).isEmpty(),"Empty-hand sneak-click removes hat");expose(pet);check(pet.isOnFire(),"Removing hat exposes familiar to the sun again");});
c.waitTicks(45);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet.getHealth()<healthy&&FamiliarBattle.bond(p).health()==pet.getHealth(),"Native on-fire ticks lower persisted familiar health");});
c.waitFor(m->m.level.getEntity(id)!=null&&m.level.getEntity(id).isOnFire(),100);
c.runOnClient(m->{var pet=(FamiliarEntity)m.level.getEntity(id);var state=(FamiliarRenderer.State)m.getEntityRenderDispatcher().getRenderer(pet).createRenderState(pet,1);check(state.displayFireAnimation&&state.boundingBoxHeight==pet.visualHeight(),"Synced native fire follows visible baby size");});
c.getInput().lookAt(0,12);c.takeScreenshot("sanctuary-beta062-sunburn");
// A hat prevents new ignition; it must never grant blanket fire immunity.
server.runOnServer(s->{var pet=FamiliarService.active(player(s));HeadEquipment.set(pet,new ItemStack(Items.STONE));pet.clearFire();pet.igniteForSeconds(2);check(pet.isOnFire(),"A hat does not extinguish an external flame");});c.waitTicks(45);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(!pet.isOnFire(),"With hat, previous fire expires without solar reignition");
HeadEquipment.set(pet,ItemStack.EMPTY);roof(p,true);pet.clearFire();});c.waitTicks(8);
server.runOnServer(s->{var pet=FamiliarService.active(player(s));expose(pet);check(!pet.isOnFire(),"Solid roof prevents ignition");roof(player(s),false);
var p=player(s);for(int x=-1;x<=1;x++)for(int z=1;z<=3;z++)p.level().setBlockAndUpdate(new BlockPos(x,1,z),Blocks.WATER.defaultBlockState());pet.igniteForSeconds(8);});c.waitTicks(15);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet.isInWater()&&!pet.isOnFire(),"Real water extinguishes existing familiar fire");expose(pet);check(!pet.isOnFire(),"Immersion prevents solar ignition");
for(int x=-9;x<=9;x++)for(int z=-9;z<=9;z++)for(int y=1;y<=2;y++)p.level().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());
pet.setPos(.5,1,2.5);pet.setDeltaMovement(Vec3.ZERO);HeadEquipment.set(pet,new ItemStack(Items.STONE));command(s,"weather rain 100000");});c.waitTicks(130);
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);HeadEquipment.set(pet,ItemStack.EMPTY);pet.clearFire();check(pet.isInWaterOrRain(),"Rain fixture actually wets the familiar");expose(pet);check(!pet.isOnFire(),"Rain protects familiar under open sky");HeadEquipment.set(pet,new ItemStack(Items.STONE));command(s,"weather clear 100000");});c.waitTicks(130);
for(String locale:List.of("fr_fr","en_us"))c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());check(!Component.translatable("sanctuary.familiar.sunlight.hint").getString().contains("sanctuary."),"Sunlight advice localized in "+locale);});
server.runOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);var egg=AccessoryAccess.of(p).getItem(AccessoryContainer.FAMILIAR).copy();
HeadEquipment.set(pet,ItemStack.EMPTY);pet.clearFire();pet.setHealth(.5F);expose(pet);check(pet.isOnFire(),"Bare familiar ignites after clear weather resumes");
// A final native baseTick delivers on-fire damage, not a direct test-only damage call.
pet.setRemainingFireTicks(160);pet.baseTick();check(pet.isRemoved()&&FamiliarBattle.bond(p).knockedOutUntil()>FamiliarBattle.now(),"Sunfire causes ordinary familiar KO");
check(ItemStack.isSameItemSameComponents(egg,AccessoryAccess.of(p).getItem(AccessoryContainer.FAMILIAR)),"KO preserves the equipped egg and identity");
command(s,"time set midnight");duelSunfire(p);
});
System.out.println("SUNLIGHT062_PASS: species, real hat packets, native sunlight/fire/health/KO, roofs, night, water, rain, flame size, duels and FR/EN");
}finally{onboarding.set(false);c.getInput().releaseKey(o->o.keyShift);}
}
private static void species(ServerPlayer p){
var expected=Set.of("zombie","zombie_villager","drowned","zombie_horse","zombie_nautilus","skeleton","stray","bogged","phantom");var actual=new HashSet<String>();int count=0;
for(var item:BuiltInRegistries.ITEM)if(item instanceof SpawnEggItem){
var stack=new ItemStack(item);String name=BuiltInRegistries.ENTITY_TYPE.getKey(SpawnEggItem.getType(stack)).getPath();
var pet=probe(p,item);expose(pet);if(pet.isOnFire())actual.add(name);
check(pet.isOnFire()==expected.contains(name),"Native daylight species: "+name);pet.discard();count++;
}
check(actual.equals(expected),"All nine sun-vulnerable egg species tested; fire-immune wither skeleton exempt");
System.out.println("SUNLIGHT062_SPECIES_PASS: "+count+" spawn eggs, burning="+actual);
}
private static void nativeHats(ServerPlayer p){
var mob=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);mob.setPos(.5,1,2.5);mob.setNoAi(true);expose(mob);check(mob.isOnFire(),"Native bare zombie burns");
for(var item:List.of(Items.STONE,Items.DIAMOND,Items.STICK,Items.TORCH)){
mob.clearFire();HeadEquipment.set(mob,new ItemStack(item));expose(mob);check(!mob.isOnFire(),"Any Sanctuary head item shades a normal mob: "+item);
}
HeadEquipment.set(mob,ItemStack.EMPTY);var helmet=new ItemStack(Items.DIAMOND_HELMET);mob.setItemSlot(EquipmentSlot.HEAD,helmet);mob.clearFire();expose(mob);
check(!mob.isOnFire()&&helmet.getDamageValue()>0,"Native head armour still protects and wears");
mob.setItemSlot(EquipmentSlot.HEAD,ItemStack.EMPTY);expose(mob);check(mob.isOnFire(),"Uncovered native mob burns again");mob.discard();
}
private static FamiliarEntity probe(ServerPlayer p,Item item){var pet=FamiliarService.TYPE.create(p.level(),EntitySpawnReason.COMMAND);pet.bind(p,egg(item));pet.setPos(.5,1,2.5);pet.setNoAi(true);return pet;}
private static void expose(Mob mob){for(int i=0;i<400;i++)mob.aiStep();}
private static void roof(ServerPlayer p,boolean present){for(int x=-3;x<=3;x++)for(int z=-1;z<=5;z++)p.level().setBlockAndUpdate(new BlockPos(x,4,z),(present?Blocks.STONE:Blocks.AIR).defaultBlockState());}
private static void fresh(ServerPlayer p,Item item){FamiliarBattle.cancel(p);p.setAttached(FamiliarBattle.COMBAT_LOCK,0L);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(p);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg(item));p.tickCount+=10-p.tickCount%10;FamiliarService.tick(p);}
private static void duelSunfire(ServerPlayer p){
var s=p.level().getServer();fresh(p,Items.ZOMBIE_SPAWN_EGG);
var profile=new com.mojang.authlib.GameProfile(UUID.randomUUID(),"Shade062");
var q=new ServerPlayer(s,p.level(),profile,net.minecraft.server.level.ClientInformation.createDefault());
var connection=new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);new io.netty.channel.embedded.EmbeddedChannel(connection);
q.connection=new net.minecraft.server.network.ServerGamePacketListenerImpl(s,connection,q,net.minecraft.server.network.CommonListenerCookie.createInitial(profile,false));q.setPos(p.position().add(0,0,4));
q.setAttached(fr.koka.sanctuary.progression.ProgressionService.RECORD,fr.koka.sanctuary.progression.ProgressRecord.INITIAL.json());fr.koka.sanctuary.progression.ProgressEffects.apply(q,fr.koka.sanctuary.progression.ProgressRecord.INITIAL);fresh(q,Items.COW_SPAWN_EGG);
check(FamiliarDuels.invite(p,q).equals("duel.sent"),"Sunlight duel accepted");request(q,"accept");request(p,"ready");request(q,"ready");
var match=FamiliarDuels.match(p);check(match.running(),"Sunlight duel started");match.phase=FamiliarDuels.Phase.FIGHT;
var pet=FamiliarService.active(p);float health=pet.getHealth();
pet.hurtServer(p.level(),p.damageSources().playerAttack(q),2);check(pet.getHealth()==health,"Player interference is still blocked in a duel");
pet.hurtServer(p.level(),p.damageSources().generic(),2);check(pet.getHealth()==health,"Unrelated environmental damage is still blocked in a duel");
pet.setHealth(.5F);pet.setRemainingFireTicks(160);pet.baseTick();check(pet.isRemoved()&&FamiliarDuels.match(p)==null&&FamiliarBattle.bond(p).knockedOutUntil()>FamiliarBattle.now(),"On-fire KO finishes a duel normally");
AccessoryAccess.of(q).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);FamiliarService.tick(q);q.discard();
}
private static void request(ServerPlayer p,String action){p.tickCount+=4;var m=FamiliarDuels.match(p);FamiliarDuels.request(p,m.id.toString(),m.revision,action,-1,0);}
private static ItemStack egg(Item item){var egg=new ItemStack(item);var data=new CompoundTag();data.putInt("schema",1);data.putInt("per_mille",1000);CustomData.update(DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,data));return egg;}
private static void command(MinecraftServer s,String cmd){s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),cmd);}
private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static KeyEvent enter(){return new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);}
private static void click(ClientGameTestContext c,int id){c.runOnClient(m->{var e=m.level.getEntity(id);m.gameMode.interact(m.player,e,new EntityHitResult(e,e.getEyePosition()),InteractionHand.MAIN_HAND);});}
private static void check(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -88,7 +88,9 @@ public final class BoatsHead049GameTests {
h.assertTrue(primary.size()==v.tiles(),"Primary places occupy every module before secondaries");
if(v.columns()!=v.rows()){
b.setYRot(0);double x=b.getBoundingBox().getXsize(),z=b.getBoundingBox().getZsize();
h.assertTrue((x>z)==(v.columns()>v.rows()),"Dimensions distinguish transposed recipes");
double expectedX=v.raft()?v.columns()*1.25:v.columns()+.25;
double expectedZ=v.rows()*1.75+(v.raft()?0:.25);
h.assertTrue(Math.abs(x-expectedX)<.0001&&Math.abs(z-expectedZ)<.0001,"Bounds cover tiled hull and exterior rails");
b.setYRot(90);h.assertTrue(Math.abs(x-b.getBoundingBox().getZsize())<.0001&&Math.abs(z-b.getBoundingBox().getXsize())<.0001,"Collision bounds follow orientation");
var e=FlightBalance.Engines.NONE.add(FlightBalance.Kind.ALLAY,false);
h.assertTrue(FlightBalance.profile(e,2,v.columns(),v.rows()).equals(FlightBalance.profile(e,2,v.rows(),v.columns())),"Transpose has identical engine balance");
@@ -36,7 +36,7 @@ public final class Building009GameTests {
h.assertTrue(ProgressEffects.get(p).building()==rank && BuildingLimits.diameter(rank)==1+2*rank,"Each rank grows the diameter");
}
h.assertTrue(p.experienceLevel==0 && !ProgressionService.purchase(p,"building",7),"111 levels pay seven ranks, no eighth purchase");
h.assertTrue(p.blockInteractionRange()==reach && p.entityInteractionRange()==attack,"Neither block nor attack reach is changed");
h.assertTrue(p.blockInteractionRange()==reach && p.entityInteractionRange()==attack,"Native block interaction and attack attributes are unchanged; placement has its own skill range");
for (Direction face:Direction.values()) for (Direction look:List.of(Direction.NORTH,Direction.EAST,Direction.SOUTH,Direction.WEST)) {
var plane=BuildPlane.positions(BlockPos.ZERO,face,look,7);
h.assertTrue(plane.size()==225 && new HashSet<>(plane).size()==225 && plane.getFirst().equals(BlockPos.ZERO),"Every orientation makes 225 unique positions, centre first");
@@ -33,7 +33,7 @@ public final class Mining008GameTests {
@GameTest
public void purchaseRanksAndNativeSpeed(GameTestHelper h) {
var p=player(h,0);p.giveExperienceLevels(111);
h.assertTrue(p.getAttributeValue(Attributes.BLOCK_BREAK_SPEED)==1,"Rank zero keeps vanilla mining speed");
h.assertTrue(p.getAttributeValue(Attributes.BLOCK_BREAK_SPEED)==.75,"Rank zero starts at 75 percent of vanilla mining speed");
var other=net.minecraft.resources.Identifier.fromNamespaceAndPath("test","other_mining_modifier");
p.getAttribute(Attributes.BLOCK_BREAK_SPEED).addTransientModifier(new AttributeModifier(other,.5,AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL));
for (int i=1;i<=7;i++) {
@@ -0,0 +1,123 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.plans.*;
import java.io.IOException;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.minecraft.core.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.*;
import net.minecraft.world.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
public final class Plans072GameTests {
private static final Set<UUID> DENIED=new HashSet<>();
static { UseBlockCallback.EVENT.register((p,l,h,hit)->DENIED.contains(p.getUUID())?InteractionResult.FAIL:InteractionResult.PASS); }
@GameTest public void creativePasteAndSurvivalAuthority(GameTestHelper h){
var player=Progression003GameTests.player(h,GameType.SURVIVAL);player.setPos(h.absoluteVec(new net.minecraft.world.phys.Vec3(-4,4,-4)));
var origin=h.absolutePos(new BlockPos(1,4,1));var plan=fixture();
for(var c:plan.cells())h.getLevel().setBlockAndUpdate(origin.offset(c.pos()),Blocks.AIR.defaultBlockState());
h.assertTrue(PlanService.paste(player,plan,origin).equals("creative_only"),"Survival operator cannot paste");
h.assertTrue(h.getLevel().getBlockState(origin).isAir(),"Survival never modifies the world");
// GameTest's mock isCreative() is fixed to its creation mode.
var creative=Progression003GameTests.player(h,GameType.CREATIVE);creative.setPos(h.absoluteVec(new net.minecraft.world.phys.Vec3(-4,4,-4)));
String result=PlanService.paste(creative,plan,origin);
h.assertTrue(result.equals("placed"),"Creative paste succeeds in a loaded clear area: "+result);
for(var c:plan.cells())h.assertTrue(h.getLevel().getBlockState(origin.offset(c.pos())).getBlock()==c.state().getBlock(),"Native world contains expected blocks");
h.assertTrue(PlanService.paste(creative,plan,origin).equals("placed"),"Already matching blocks are not replaced");
h.succeed();
}
@GameTest public void preflightPreservesOccupiedBlocksAndPermissions(GameTestHelper h){
var player=Progression003GameTests.player(h,GameType.CREATIVE);player.setPos(h.absoluteVec(new net.minecraft.world.phys.Vec3(-4,4,-4)));
var origin=h.absolutePos(new BlockPos(1,4,1));var plan=fixture();
for(var c:plan.cells())h.getLevel().setBlockAndUpdate(origin.offset(c.pos()),Blocks.AIR.defaultBlockState());
var conflict=origin.offset(plan.cells().getLast().pos());h.getLevel().setBlockAndUpdate(conflict,Blocks.CHEST.defaultBlockState());
var chest=h.getLevel().getBlockEntity(conflict);
h.assertTrue(PlanService.paste(player,plan,origin).equals("occupied"),"One conflict rejects the complete paste");
h.assertTrue(h.getLevel().getBlockState(origin).isAir()&&h.getLevel().getBlockEntity(conflict)==chest,"No partial placement and original chest retained");
h.getLevel().setBlockAndUpdate(conflict,Blocks.AIR.defaultBlockState());DENIED.add(player.getUUID());
try{h.assertTrue(PlanService.paste(player,plan,origin).equals("denied"),"Fabric permission callback vetoes paste");}
finally{DENIED.remove(player.getUUID());}
for(var c:plan.cells())h.assertTrue(h.getLevel().getBlockState(origin.offset(c.pos())).isAir(),"Veto leaves all destinations empty");
var restricted=new BuildPlan("Restricted",1,1,1,List.of(new BuildPlan.Cell(BlockPos.ZERO,Blocks.COMMAND_BLOCK.defaultBlockState())));
h.assertTrue(PlanService.paste(player,restricted,origin).equals("restricted_block"),"No command block through plan import");
h.assertTrue(PlanService.paste(player,plan,origin.offset(1000,0,0)).equals("distance"),"Far imports cannot generate chunks");
h.succeed();
}
@GameTest public void litematicCrossWordPaletteAndNegativeRegions(GameTestHelper h) throws Exception {
var cells=new ArrayList<BuildPlan.Cell>();
var materials=new ArrayList<Block>();
for(var dye:net.minecraft.world.item.DyeColor.values())materials.add(BuiltInRegistries.BLOCK.getValue(net.minecraft.resources.Identifier.withDefaultNamespace(dye.getName()+"_wool")));
materials.add(Blocks.STONE);materials.add(Blocks.BRICKS);
for(int x=0;x<31;x++)cells.add(new BuildPlan.Cell(new BlockPos(x,0,0),materials.get(x%materials.size()).defaultBlockState()));
var input=new BuildPlan("Palette",31,1,1,cells);var encoded=SchematicIO.encode(input);var output=SchematicIO.decode(encoded,"Palette");
h.assertTrue(output.equals(input),"Blockstates survive 5-bit packed palette boundaries");
// Independent 5-bit witness: index 12 straddles bit 60 of the first long.
long[] words={17L<<60,1};h.assertTrue(SchematicIO.unpack(words,5,12)==17,"Bitstream crosses a 64-bit word without padding");
var region=encoded.getCompoundOrEmpty("Regions").getCompoundOrEmpty("Main");
var size=region.getCompoundOrEmpty("Size");size.putInt("x",-31);region.put("Size",size);
var origin=region.getCompoundOrEmpty("Position");origin.putInt("x",50);region.put("Position",origin);
h.assertTrue(SchematicIO.decode(encoded,"Palette").equals(input),"Negative region size moves the minimum, does not reverse indices");
var dir=java.nio.file.Files.createTempDirectory("sanctuary-plan072-");
try{
var file=SchematicIO.write(input,dir);h.assertTrue(SchematicIO.read(file).equals(input),"Compressed disk export opens as a plan");
var again=SchematicIO.write(input,dir);h.assertTrue(!file.equals(again),"Repeated export preserves earlier file");
java.nio.file.Files.delete(file);java.nio.file.Files.delete(again);
}finally{java.nio.file.Files.delete(dir);}
h.succeed();
}
@GameTest public void sharedFormatsAndStrictInvalidInput(GameTestHelper h) throws Exception {
for(int version:List.of(2,3)){
var root=new CompoundTag();root.putInt("Version",version);root.putInt("DataVersion",0);root.putShort("Width",(short)2);root.putShort("Height",(short)1);root.putShort("Length",(short)1);
var palette=new CompoundTag();palette.putInt("minecraft:stone",0);palette.putInt("minecraft:oak_stairs[facing=east,half=top,shape=straight,waterlogged=false]",1);
var data=new CompoundTag();data.put("Palette",palette);data.putByteArray(version==3?"Data":"BlockData",new byte[]{0,1});
if(version==3){root.put("Blocks",data);var outer=new CompoundTag();outer.put("Schematic",root);root=outer;}else root.merge(data);
var plan=SchematicIO.decode(root,"Sponge");
h.assertTrue(plan.cells().size()==2&&plan.cells().get(1).state().getValue(BlockStateProperties.HORIZONTAL_FACING)==Direction.EAST,"Sponge v"+version+" reads native state properties");
}
var vanilla=new CompoundTag();vanilla.put("size",ints(1,1,1));
var palette=new ListTag();palette.add(NbtUtils.writeBlockState(Blocks.STONE.defaultBlockState()));vanilla.put("palette",palette);
var block=new CompoundTag();block.put("pos",ints(0,0,0));block.putInt("state",0);var entries=new ListTag();entries.add(block);vanilla.put("blocks",entries);
h.assertTrue(SchematicIO.decode(vanilla,"Vanilla").cells().size()==1,"Vanilla structure imports");
var bad=SchematicIO.encode(fixture());bad.getCompoundOrEmpty("Regions").getCompoundOrEmpty("Main").putLongArray("BlockStates",new long[0]);reject(h,()->SchematicIO.decode(bad,"Bad"));
reject(h,()->SchematicIO.parseState("minecraft:no_such_block"));
reject(h,()->SchematicIO.parseState("minecraft:oak_stairs[facing=garbage]"));
reject(h,()->SchematicIO.parseState("minecraft:stone{foo:1}"));
var newer=SchematicIO.encode(fixture());newer.putInt("MinecraftDataVersion",Integer.MAX_VALUE);reject(h,()->SchematicIO.decode(newer,"Future"));
var huge=SchematicIO.encode(fixture());huge.getCompoundOrEmpty("Regions").getCompoundOrEmpty("Main").getCompoundOrEmpty("Size").putInt("x",Integer.MIN_VALUE);reject(h,()->SchematicIO.decode(huge,"Huge"));
h.succeed();
}
@GameTest public void independentlyWrittenCommunityFormats(GameTestHelper h)throws Exception{
for(String extension:List.of("litematic","schem","nbt")){
try(var stream=getClass().getResourceAsStream("/fixtures/plans072/independent."+extension+".base64.txt")){
h.assertTrue(stream!=null,"Independent fixture packaged: "+extension);
var compressed=new java.io.ByteArrayInputStream(Base64.getMimeDecoder().decode(stream.readAllBytes()));
var plan=SchematicIO.decode(NbtIo.readCompressed(compressed,NbtAccounter.create(SchematicIO.MAX_NBT)),"Independent");
h.assertTrue(plan.width()==5&&plan.height()==2&&plan.depth()==3&&plan.cells().size()==3,"Independent dimensions and sparse block count: "+extension);
var blocks=new HashMap<BlockPos,BlockState>();plan.cells().forEach(c->blocks.put(c.pos(),c.state()));
h.assertTrue(blocks.get(BlockPos.ZERO).is(Blocks.WOOL.red()),"Independent red wool at origin: "+extension);
h.assertTrue(blocks.get(new BlockPos(4,1,2)).getValue(BlockStateProperties.HORIZONTAL_FACING)==Direction.WEST,"Independent UV-free oriented stairs: "+extension);
}
}
h.succeed();
}
@GameTest public void modelGeometryAlphaColorAndRotation(GameTestHelper h){
var texture=new StatueVoxelizer.Texture(2,1,new int[]{0xFFFF0000,0x00000000});
var face=new StatueVoxelizer.Face(new StatueVoxelizer.Vertex(0,0,0,0,0),new StatueVoxelizer.Vertex(1,0,0,1,0),new StatueVoxelizer.Vertex(1,1,0,1,1),new StatueVoxelizer.Vertex(0,1,0,0,1),texture,-1);
var voxels=StatueVoxelizer.convert(List.of(face),16);
h.assertTrue(voxels.keySet().stream().mapToInt(BlockPos::getY).max().orElseThrow()==15,"Requested height preserved");
h.assertTrue(voxels.keySet().stream().noneMatch(p->p.getX()>8),"Transparent texture half never becomes blocks");
h.assertTrue(voxels.values().stream().allMatch(c->c==0xFFFF0000),"UV color sampling is exact without background colors");
var plan=fixture();h.assertTrue(plan.rotate().rotate().rotate().rotate().equals(plan),"Four rotations preserve coordinates and native blockstate direction");
h.succeed();
}
private static ListTag ints(int...values){var list=new ListTag();for(int n:values)list.add(IntTag.valueOf(n));return list;}
@FunctionalInterface private interface Checked {void run()throws Exception;}
private static void reject(GameTestHelper h,Checked work)throws Exception{boolean refused=false;try{work.run();}catch(IOException|IllegalArgumentException e){refused=true;}h.assertTrue(refused,"Invalid input rejected explicitly");}
private static BuildPlan fixture(){return new BuildPlan("Fixture",3,1,1,List.of(new BuildPlan.Cell(BlockPos.ZERO,Blocks.STONE.defaultBlockState()),new BuildPlan.Cell(new BlockPos(1,0,0),Blocks.WOOL.red().defaultBlockState()),new BuildPlan.Cell(new BlockPos(2,0,0),Blocks.OAK_STAIRS.defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING,Direction.EAST))));}
}
@@ -97,7 +97,7 @@ public final class TerrainAtlas305GameTests {
var report = new LinkedHashMap<String, Object>();
report.put("schema", 1); report.put("version", version);
report.put("revision", System.getProperty("sanctuary.test.atlasRevision", "working-tree"));
report.put("minecraft", "26.3-pre-2"); report.put("seed", Long.toString(seed));
report.put("minecraft", net.minecraft.SharedConstants.getCurrentVersion().id()); report.put("seed", Long.toString(seed));
report.put("diameter", radius * 2); report.put("process_id", ProcessHandle.current().pid());
report.put("domain_y", List.of(0, 419)); report.put("build_ceiling_exclusive", 640);
report.put("map_step", 12); report.put("vertical_step", 2); report.put("density_scale", 10000);
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"sanctuary_gametest:vortex"
]
}
@@ -0,0 +1,43 @@
{
"dimensions": {
"minecraft:overworld": {
"type": "sanctuary:sanctuary_640",
"generator": {
"type": "minecraft:flat",
"settings": {
"biome": "minecraft:plains",
"features": false,
"lakes": false,
"layers": [
{
"block": "minecraft:bedrock",
"height": 1
}
],
"structure_overrides": []
}
}
},
"minecraft:the_end": {
"type": "minecraft:the_end",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:the_end"
},
"settings": "minecraft:end"
}
},
"minecraft:the_nether": {
"type": "minecraft:the_nether",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:multi_noise",
"preset": "minecraft:nether"
},
"settings": "minecraft:nether"
}
}
}
}
@@ -8,7 +8,7 @@
"entrypoints": {
"client": ["fr.koka.sanctuary.gametest.Starter039TestSupport"],
"preLaunch": ["fr.koka.sanctuary.gametest.Inventory010MixinAudit"],
"fabric-gametest": ["fr.koka.sanctuary.gametest.Starter052GameTests", "fr.koka.sanctuary.gametest.Dispenser051GameTests", "fr.koka.sanctuary.gametest.Head050GameTests", "fr.koka.sanctuary.gametest.Poultry048GameTests", "fr.koka.sanctuary.gametest.StarInspection047GameTests", "fr.koka.sanctuary.gametest.SkyFlight046GameTests", "fr.koka.sanctuary.gametest.GravesFood038GameTests","fr.koka.sanctuary.gametest.Realtime037GameTests","fr.koka.sanctuary.gametest.Corrections036GameTests","fr.koka.sanctuary.gametest.Collections035GameTests","fr.koka.sanctuary.gametest.Notifications034GameTests","fr.koka.sanctuary.gametest.Menus033GameTests","${gametest_entrypoint}", "fr.koka.sanctuary.gametest.Swamp27WorldGameTests", "fr.koka.sanctuary.gametest.Caves28WorldGameTests", "fr.koka.sanctuary.gametest.Terraces29WorldGameTests", "fr.koka.sanctuary.gametest.River30WorldGameTests", "fr.koka.sanctuary.gametest.Transit29WorldGameTests", "fr.koka.sanctuary.gametest.Alpha28NativeStructuresGameTests", "fr.koka.sanctuary.gametest.Village306GameTests", "fr.koka.sanctuary.gametest.Drained307GameTests", "fr.koka.sanctuary.gametest.BlockKnowledgeGameTests", "fr.koka.sanctuary.gametest.CensusGameTestsTerrain", "fr.koka.sanctuary.gametest.CensusStockGameTests", "fr.koka.sanctuary.gametest.MaterialActivityGameTests", "fr.koka.sanctuary.gametest.AncientExpeditions001GameTests", "fr.koka.sanctuary.gametest.Progression003GameTests", "fr.koka.sanctuary.gametest.Atlas005GameTests", "fr.koka.sanctuary.gametest.Demeure006GameTests", "fr.koka.sanctuary.gametest.Mining008GameTests", "fr.koka.sanctuary.gametest.Building009GameTests", "fr.koka.sanctuary.gametest.Inventory010GameTests", "fr.koka.sanctuary.gametest.Cycle011GameTests", "fr.koka.sanctuary.gametest.Inventory012GameTests", "fr.koka.sanctuary.gametest.Inventory014GameTests", "fr.koka.sanctuary.gametest.Tutorial016GameTests", "fr.koka.sanctuary.gametest.Movement017GameTests", "fr.koka.sanctuary.gametest.Accessory018GameTests", "fr.koka.sanctuary.gametest.Companion019GameTests", "fr.koka.sanctuary.gametest.Companion032GameTests", "fr.koka.sanctuary.gametest.Recipes024GameTests", "fr.koka.sanctuary.gametest.Carry027GameTests", "fr.koka.sanctuary.gametest.Familiar029GameTests", "fr.koka.sanctuary.gametest.Dynamic030GameTests", "fr.koka.sanctuary.gametest.GraveInventory039GameTests", "fr.koka.sanctuary.gametest.FoodKnowledge039GameTests", "fr.koka.sanctuary.gametest.OperatorStars040GameTests", "fr.koka.sanctuary.gametest.Starter039GameTests"],
"fabric-gametest": ["fr.koka.sanctuary.gametest.Plans072GameTests", "fr.koka.sanctuary.gametest.Starter052GameTests", "fr.koka.sanctuary.gametest.Dispenser051GameTests", "fr.koka.sanctuary.gametest.Head050GameTests", "fr.koka.sanctuary.gametest.Poultry048GameTests", "fr.koka.sanctuary.gametest.StarInspection047GameTests", "fr.koka.sanctuary.gametest.SkyFlight046GameTests", "fr.koka.sanctuary.gametest.GravesFood038GameTests","fr.koka.sanctuary.gametest.Realtime037GameTests","fr.koka.sanctuary.gametest.Corrections036GameTests","fr.koka.sanctuary.gametest.Collections035GameTests","fr.koka.sanctuary.gametest.Notifications034GameTests","fr.koka.sanctuary.gametest.Menus033GameTests","${gametest_entrypoint}", "fr.koka.sanctuary.gametest.Swamp27WorldGameTests", "fr.koka.sanctuary.gametest.Caves28WorldGameTests", "fr.koka.sanctuary.gametest.Terraces29WorldGameTests", "fr.koka.sanctuary.gametest.River30WorldGameTests", "fr.koka.sanctuary.gametest.Transit29WorldGameTests", "fr.koka.sanctuary.gametest.Alpha28NativeStructuresGameTests", "fr.koka.sanctuary.gametest.Village306GameTests", "fr.koka.sanctuary.gametest.Drained307GameTests", "fr.koka.sanctuary.gametest.BlockKnowledgeGameTests", "fr.koka.sanctuary.gametest.CensusGameTestsTerrain", "fr.koka.sanctuary.gametest.CensusStockGameTests", "fr.koka.sanctuary.gametest.MaterialActivityGameTests", "fr.koka.sanctuary.gametest.AncientExpeditions001GameTests", "fr.koka.sanctuary.gametest.Progression003GameTests", "fr.koka.sanctuary.gametest.Atlas005GameTests", "fr.koka.sanctuary.gametest.Demeure006GameTests", "fr.koka.sanctuary.gametest.Mining008GameTests", "fr.koka.sanctuary.gametest.Building009GameTests", "fr.koka.sanctuary.gametest.Inventory010GameTests", "fr.koka.sanctuary.gametest.Cycle011GameTests", "fr.koka.sanctuary.gametest.Inventory012GameTests", "fr.koka.sanctuary.gametest.Inventory014GameTests", "fr.koka.sanctuary.gametest.Tutorial016GameTests", "fr.koka.sanctuary.gametest.Movement017GameTests", "fr.koka.sanctuary.gametest.Accessory018GameTests", "fr.koka.sanctuary.gametest.Companion019GameTests", "fr.koka.sanctuary.gametest.Companion032GameTests", "fr.koka.sanctuary.gametest.Recipes024GameTests", "fr.koka.sanctuary.gametest.Carry027GameTests", "fr.koka.sanctuary.gametest.Familiar029GameTests", "fr.koka.sanctuary.gametest.Dynamic030GameTests", "fr.koka.sanctuary.gametest.GraveInventory039GameTests", "fr.koka.sanctuary.gametest.FoodKnowledge039GameTests", "fr.koka.sanctuary.gametest.OperatorStars040GameTests", "fr.koka.sanctuary.gametest.Starter039GameTests"],
"fabric-client-gametest": ["fr.koka.sanctuary.gametest.SanctuaryClientRenderTests"]
},
"mixins": ["sanctuary-gametest.mixins.json"],
@@ -0,0 +1,19 @@
# Independent schematic fixtures
Synthetic three-block region, authored for Sanctuary on 2026-09-15.
Generated with Litemapy **0.11.0b0**, using its own Litematica, Sponge and vanilla
writers. No downloaded build or Minecraft world is included.
The gzip files are stored as base64 text because this repository's test resource
task applies line filters. The test decodes their exact bytes before parsing.
- Dimensions: 5 × 2 × 3.
- `(0,0,0)` = `minecraft:red_wool`.
- `(1,0,0)` = `minecraft:stone`.
- `(4,1,2)` = `minecraft:oak_stairs[facing=west,half=top,shape=straight,waterlogged=false]`.
- All other cells are air; no entities or block entity contents.
These exercise an independently implemented writer, in addition to Sanctuary's
round-trip and malformed-input tests. Litemapy is a test-fixture generation tool;
it is not a build dependency and is not shipped in Sanctuary.
Source: https://github.com/SmylerMC/litemapy
@@ -0,0 +1 @@
H4sICBRyqWoC/2luZGVwZW5kZW50LmxpdGVtYXRpYwB9kc9O20AQxie1A44dVFKhHiueAIVrby1QCampLBJxjSb22Fmx3o12xw3hZeilj9b36KybxBKH+rLy92n+/b4UIILTR3JeWQMAJxGk83bVC4MILmbKUOGw4ltkPFrZawrJjBhLUVM4uzOFtl6Zeq5eKILBs1QP5d3J+07eFwizEjj50vLaOvg4R1Nwi253yeT5slLP3DpKILslXzi14W5MAvEPbAjO7w2TsxtyuFJa8S6BZG4r3qIjmHxXTA1udsvp1fX11XQ1jSB7oFo63NjWcDgkhmyhGrpxhEylCL9+/3ldxDAO6syWqlK9LOULy6i/als8+bD5QXm0upV1AD5lcJ47+qloe99gTQEOhC+F03+jfQqjB6rIkSlIYOWCJxy1hwN7OLCHEyrj/8IbwaRbaM5yQo6amKUvQHyAdNYcovqMyh3ZfehlR+Vya60+eu97z7M1dDQuesPi09KzdJSL0rwLgRV5ybLCQhKHeCsJSuEadQUR200CQ7/GjXTz7FDVa3GzrWzttK1r4TysUHvBOILkzrAK7cIl8i95aHqjTXIypQzqrl8oieSN8U23quyNMWQ9qBDfYNwBlgqAv/7b+CT0AgAA
@@ -0,0 +1 @@
H4sICBRyqWoC/2luZGVwZW5kZW50Lm5idACNkM9qhDAQhycmbv1DKZQ990F67rn01OsydUcN6xpJBoS+TF+1sbBaitYRAnHmm/n5WQDkYIL9JA0A00njSaa7hvIFGd/JB+t6gPIrh4x6tmwpFJGIk3cDdsRM06vJwLzileD+anuqPNb8jNbDrfy4lD2dT6Nz3dx7WHqBXU9z47g0HF5OgePGGF68eTeQn74kg0ONle0bMCMFjoMtdjVodkMGaWhxiNsCe7RNG7vliEy+c01DZ0hr7AJNIoePzlWXH62nHPTgwu1/zI+O2zgOx6uCdUb9YmCDSXYY9Sdri1EC5r8steKVbDBKsGcva89LCbyUwCtZ8dpilIDZy1KCLCXI2vPSAi8t8NICLy3w0gIvLfAyAi8j8DICLyPwMgIvs+IV698vYo2hTQUAAA==
@@ -0,0 +1 @@
H4sICBRyqWoC/2luZGVwZW5kZW50LnNjaGVtAHWQ0UrDQBBFb7M2aROK4Af4BfkCIS9FQVDRJ30QKUMyu1m67pbdgfg3/qpJK0YfnLd7Zrgzd0pAobomoWeOyQYPVJ8KxY9ClmH5YjvpscyQ37I1vUwwv2dvRqoq5I9aJxZMXr9qjdWNFyuWU3nSm60L7f4PVCifyLEIP9DHqM9KFN9AYfNuPbeRtFyRjafxi5lF7nZDCG7kC4XzuZEkeJ5uV7ibaaD9LslolF41tdabZuAkdU9ONxIOderpwE2SSFPGeiDh6IIx3DWaXOK34/4C62OK6WcjuFxk+L8UvgDKJuEMYQEAAA==
@@ -43,6 +43,9 @@ public final class SanctuaryMod implements ModInitializer {
@Override
public void onInitialize() {
fr.koka.sanctuary.sky.VortexClouds.register();
fr.koka.sanctuary.loading.PreloadProgress.register();
fr.koka.sanctuary.progression.SanctuaryGameplay.register();
fr.koka.sanctuary.cosmetics.AccessoryService.register();
fr.koka.sanctuary.progression.ProgressionService.register();
fr.koka.sanctuary.operator.OperatorService.register();
@@ -66,6 +69,7 @@ public final class SanctuaryMod implements ModInitializer {
fr.koka.sanctuary.notifications.DiscoveryNotifications.register();
fr.koka.sanctuary.realtime.RealtimeService.register();
fr.koka.sanctuary.weather.WeatherService.register();
fr.koka.sanctuary.plans.PlanService.register();
fr.koka.sanctuary.food.FoodSync.register();
fr.koka.sanctuary.food.FoodKnowledgeService.register();
fr.koka.sanctuary.grave.GraveService.register();
@@ -130,6 +134,7 @@ public final class SanctuaryMod implements ModInitializer {
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("cavern_groves"), CavernGrovesFeature.CODEC);
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("cavern_decorations"), CavernDecorationsFeature.CODEC);
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("cavern_shore_sugar_cane"), CavernShoreSugarCaneFeature.CODEC);
fr.koka.sanctuary.multiblock.Multiblocks.register();
LOGGER.info("Sanctuary initialized: the Sanctuary world preset is available.");
}
}
@@ -0,0 +1,136 @@
package fr.koka.sanctuary.arena;
import com.google.gson.*;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.companion.CompanionInventory;
import fr.koka.sanctuary.familiar.FamiliarCatalog;
import fr.koka.sanctuary.progression.AtomicJson;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.*;
import net.fabricmc.fabric.api.attachment.v1.*;
import net.minecraft.nbt.*;
import net.minecraft.resources.RegistryOps;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.storage.LevelResource;
/** Separate additive escrow journal. Each inventory change has a durable native player receipt. */
public final class ArenaLedger {
public static final AttachmentType<String> RECEIPT=AttachmentRegistry.create(SanctuaryMod.id("arena_receipt"),b->b.persistent(com.mojang.serialization.Codec.STRING).copyOnDeath());
private static final int LIMIT=32*1024*1024;
private final MinecraftServer server;
private final Path path;
private byte[] previous;
private JsonObject deposits=new JsonObject(),payouts=new JsonObject();
private boolean healthy=true;
public ArenaLedger(MinecraftServer server)throws Exception{
this.server=server;path=server.getWorldPath(LevelResource.ROOT).resolve("sanctuary/arena-stakes-v1.json");previous=AtomicJson.read(path,LIMIT);
if(previous==null)return;
var root=AtomicJson.parse(new String(previous,StandardCharsets.UTF_8)).getAsJsonObject();AtomicJson.fields(root,"schema","deposits","payouts");
if(root.get("schema").getAsBigDecimal().intValueExact()!=1)throw new IllegalArgumentException("Unknown arena journal");
deposits=root.getAsJsonObject("deposits");payouts=root.getAsJsonObject("payouts");
for(var e:deposits.entrySet()){
UUID.fromString(e.getKey());var d=e.getValue().getAsJsonObject();AtomicJson.fields(d,"player","item","count","match","target","entry","state");validate(d);if(d.get("count").getAsLong()<1||d.get("count").getAsLong()>3456||!d.get("entry").isJsonPrimitive()||!d.get("entry").getAsJsonPrimitive().isBoolean())throw new IllegalArgumentException("Invalid deposit");UUID.fromString(d.get("match").getAsString());UUID.fromString(d.get("target").getAsString());
if(!Set.of("pending","held","refund").contains(d.get("state").getAsString()))throw new IllegalArgumentException("Invalid deposit state");
// Live combats do not survive restart. Decided payouts are already separate and remain authoritative.
d.addProperty("state",d.get("state").getAsString().equals("pending")?"pending":"refund");
}
for(var e:payouts.entrySet()){UUID.fromString(e.getKey());var d=e.getValue().getAsJsonObject();validate(d);
if(d.has("transfer")){AtomicJson.fields(d,"player","item","count","transfer","moving");long moving=d.get("moving").getAsBigDecimal().longValueExact();if(moving<1||moving>3456||moving>d.get("count").getAsLong()||d.get("transfer").getAsString().length()>64)throw new IllegalArgumentException("Invalid transfer");}
else AtomicJson.fields(d,"player","item","count");
}
write();
}
private void validate(JsonObject d){UUID.fromString(d.get("player").getAsString());if(decode(d.get("item")).getCount()!=1)throw new IllegalArgumentException("Invalid unit item");if(d.get("count").getAsBigDecimal().longValueExact()<0)throw new IllegalArgumentException("Negative stake");}
public boolean healthy(){return healthy;}
public boolean pending(ServerPlayer p){return payouts.asMap().values().stream().anyMatch(v->v.getAsJsonObject().get("player").getAsString().equals(p.getStringUUID()))||deposits.asMap().values().stream().anyMatch(v->{var d=v.getAsJsonObject();return d.get("player").getAsString().equals(p.getStringUUID())&&!d.get("state").getAsString().equals("held");});}
public boolean deposit(UUID match,ServerPlayer p,UUID target,boolean entry,ItemStack item,int count){
if(!healthy||!p.isAlive()||p.isRemoved()||item.isEmpty()||count<1||count>3456)return false;
claim(p);if(!healthy||pending(p))return false;
var plan=new CompanionInventory.Plan(p);if(!plan.take(s->ItemStack.isSameItemSameComponents(s,item),count))return false;
try{
var id=UUID.randomUUID().toString();var d=amount(p.getUUID(),item,count);d.addProperty("match",match.toString());d.addProperty("target",target.toString());d.addProperty("entry",entry);d.addProperty("state","pending");deposits.add(id,d);write();
plan.commit();checkpoint(p,id+"/paid");d.addProperty("state","held");write();return true;
}catch(Exception e){fail(e);return false;}
}
public long pot(UUID match,boolean entry){long n=0;for(var v:deposits.asMap().values()){var d=v.getAsJsonObject();if(d.get("match").getAsString().equals(match.toString())&&d.get("entry").getAsBoolean()==entry&&d.get("state").getAsString().equals("held"))n=Math.addExact(n,d.get("count").getAsLong());}return n;}
public boolean hasBet(UUID match,UUID player){return deposits.asMap().values().stream().anyMatch(v->{var d=v.getAsJsonObject();return d.get("match").getAsString().equals(match.toString())&&d.get("player").getAsString().equals(player.toString())&&!d.get("entry").getAsBoolean();});}
public void leave(UUID match,UUID player){
if(!healthy)return;
try{for(var id:new ArrayList<>(deposits.keySet())){var d=deposits.getAsJsonObject(id);
if(!d.get("match").getAsString().equals(match.toString())||!d.get("target").getAsString().equals(player.toString()))continue;
if(!d.get("state").getAsString().equals("held"))throw new IllegalStateException("Unresolved withdrawn stake");
credit(UUID.fromString(d.get("player").getAsString()),decode(d.get("item")),d.get("count").getAsLong());deposits.remove(id);
}write();}catch(Exception e){fail(e);}
}
/** The complete allocation is committed in one write before any player can collect. */
public void finish(UUID match,UUID winner){
if(!healthy)return;
try{
var chosen=new LinkedHashMap<String,JsonObject>();for(var e:deposits.entrySet())if(e.getValue().getAsJsonObject().get("match").getAsString().equals(match.toString()))chosen.put(e.getKey(),e.getValue().getAsJsonObject());
if(chosen.values().stream().anyMatch(d->!d.get("state").getAsString().equals("held"))){for(var d:chosen.values())if(d.get("state").getAsString().equals("held"))d.addProperty("state","refund");write();return;}
if(winner==null){for(var d:chosen.values())credit(UUID.fromString(d.get("player").getAsString()),decode(d.get("item")),d.get("count").getAsLong());}
else{
for(boolean entry:List.of(true,false)){
var pool=chosen.values().stream().filter(d->d.get("entry").getAsBoolean()==entry).toList();if(pool.isEmpty())continue;
long total=0;var weights=new TreeMap<UUID,Long>();
for(var d:pool){long n=d.get("count").getAsLong();total=Math.addExact(total,n);if(d.get("target").getAsString().equals(winner.toString()))weights.merge(UUID.fromString(d.get("player").getAsString()),n,Math::addExact);}
var item=decode(pool.getFirst().get("item"));
if(entry)credit(winner,item,total);
else if(weights.isEmpty())for(var d:pool)credit(UUID.fromString(d.get("player").getAsString()),item,d.get("count").getAsLong());
else for(var allocation:split(total,weights).entrySet())credit(allocation.getKey(),item,allocation.getValue());
}
}
for(var id:chosen.keySet())deposits.remove(id);write();
}catch(Exception e){fail(e);}
}
/** Exact pari-mutuel division; remainder follows stable UUID order. No items are created or rounded away. */
public static Map<UUID,Long> split(long total,SortedMap<UUID,Long> weights){
long sum=0;for(long n:weights.values())sum=Math.addExact(sum,n);if(sum<=0||total<0)throw new IllegalArgumentException("Invalid pot");
var result=new LinkedHashMap<UUID,Long>();long used=0;
for(var e:weights.entrySet()){long n=BigInteger.valueOf(total).multiply(BigInteger.valueOf(e.getValue())).divide(BigInteger.valueOf(sum)).longValueExact();result.put(e.getKey(),n);used=Math.addExact(used,n);}
for(var id:result.keySet()){if(used==total)break;result.put(id,result.get(id)+1);used++;}return result;
}
public void claim(ServerPlayer p){
if(!healthy||!p.isAlive()||p.isRemoved())return;
try{
for(var id:new ArrayList<>(deposits.keySet())){
var d=deposits.getAsJsonObject(id);if(!d.get("player").getAsString().equals(p.getStringUUID()))continue;String state=d.get("state").getAsString();if(state.equals("held"))continue;
if(state.equals("refund")||p.getAttachedOrElse(RECEIPT,"").equals(id+"/paid"))credit(p.getUUID(),decode(d.get("item")),d.get("count").getAsLong());
deposits.remove(id);write();
}
for(var id:new ArrayList<>(payouts.keySet())){
var d=payouts.getAsJsonObject(id);if(!d.get("player").getAsString().equals(p.getStringUUID()))continue;
if(d.has("transfer")){
if(p.getAttachedOrElse(RECEIPT,"").equals(d.get("transfer").getAsString())){checkpoint(p,d.get("transfer").getAsString());d.addProperty("count",d.get("count").getAsLong()-d.get("moving").getAsLong());}
d.remove("transfer");d.remove("moving");write();
}
long left=d.get("count").getAsLong();if(left==0){payouts.remove(id);write();continue;}
var item=decode(d.get("item"));var plan=new CompanionInventory.Plan(p);int capacity=0;
for(int slot:CompanionInventory.slots(p,true)){var at=p.getInventory().getItem(slot);if(at.isEmpty())capacity+=item.getMaxStackSize();else if(ItemStack.isSameItemSameComponents(at,item))capacity+=Math.max(0,item.getMaxStackSize()-at.getCount());}
int moved=(int)Math.min(left,Math.min(capacity,3456));
if(moved>0&&!plan.give(item.copyWithCount(moved)))throw new IllegalStateException("Payout capacity changed");
left-=moved;
if(moved==0)continue;
String receipt=UUID.randomUUID()+"/received";d.addProperty("transfer",receipt);d.addProperty("moving",moved);write();plan.commit();checkpoint(p,receipt);
d.addProperty("count",left);d.remove("transfer");d.remove("moving");if(left==0)payouts.remove(id);write();
}
}catch(Exception e){fail(e);}
}
private JsonObject amount(UUID player,ItemStack item,long count){var d=new JsonObject();d.addProperty("player",player.toString());d.add("item",encode(item.copyWithCount(1)));d.addProperty("count",count);return d;}
private void credit(UUID player,ItemStack item,long count){if(count>0)payouts.add(UUID.randomUUID().toString(),amount(player,item,count));}
private JsonElement encode(ItemStack stack){return ItemStack.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE,server.registryAccess()),stack).getOrThrow();}
private ItemStack decode(JsonElement value){return ItemStack.CODEC.parse(RegistryOps.create(JsonOps.INSTANCE,server.registryAccess()),value).getOrThrow();}
private void write()throws Exception{var root=new JsonObject();root.addProperty("schema",1);root.add("deposits",deposits);root.add("payouts",payouts);previous=AtomicJson.write(path,previous,FamiliarCatalog.JSON.toJson(root),LIMIT);}
private void checkpoint(ServerPlayer p,String receipt)throws Exception{
p.setAttached(RECEIPT,receipt);((fr.koka.sanctuary.mixin.FamiliarPlayerSaveAccess)server.getPlayerList()).sanctuary$saveDuelPlayer(p);
var file=server.getWorldPath(LevelResource.PLAYER_DATA_DIR).resolve(p.getStringUUID()+".dat");
if(!contains(NbtIo.readCompressed(file,NbtAccounter.create(32L*1024*1024)),receipt))throw new IllegalStateException("Arena receipt not saved");
}
private boolean contains(Tag tag,String receipt){if(tag instanceof CompoundTag c){if(c.getString("sanctuary:arena_receipt").orElse("").equals(receipt))return true;for(var key:c.keySet())if(contains(c.get(key),receipt))return true;}else if(tag instanceof ListTag list)for(var child:list)if(contains(child,receipt))return true;return false;}
private void fail(Exception e){healthy=false;SanctuaryMod.LOGGER.error("Arena items paused; journal retained for recovery",e);}
}
@@ -0,0 +1,80 @@
package fr.koka.sanctuary.arena;
import com.google.gson.*;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.companion.CompanionInventory;
import java.util.*;
import net.fabricmc.fabric.api.networking.v1.*;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
public final class ArenaNetwork {
public static final int PAGE_SIZE=12;
public record Request(String action,String id,String target,int revision,int slot,int value,int page,String options) implements CustomPacketPayload {
public static final Type<Request> TYPE=new Type<>(SanctuaryMod.id("arena_request"));
public static final StreamCodec<RegistryFriendlyByteBuf,Request> CODEC=CustomPacketPayload.codec((p,b)->{b.writeUtf(p.action,16);b.writeUtf(p.id,36);b.writeUtf(p.target,36);b.writeVarInt(p.revision);b.writeVarInt(p.slot);b.writeVarInt(p.value);b.writeVarInt(p.page);b.writeUtf(p.options,96);},b->new Request(b.readUtf(16),b.readUtf(36),b.readUtf(36),b.readVarInt(),b.readVarInt(),b.readVarInt(),b.readVarInt(),b.readUtf(96)));
public Type<Request> type(){return TYPE;}
}
public record View(String data,ItemStack currency,List<Integer> slots,List<ItemStack> items,boolean open) implements CustomPacketPayload {
public static final Type<View> TYPE=new Type<>(SanctuaryMod.id("arena_view"));
public static final StreamCodec<RegistryFriendlyByteBuf,View> CODEC=CustomPacketPayload.codec((p,b)->{b.writeUtf(p.data,32767);ItemStack.OPTIONAL_STREAM_CODEC.encode(b,p.currency);b.writeVarInt(p.slots.size());for(int i=0;i<p.slots.size();i++){b.writeVarInt(p.slots.get(i));ItemStack.OPTIONAL_STREAM_CODEC.encode(b,p.items.get(i));}b.writeBoolean(p.open);},b->{var data=b.readUtf(32767);var currency=ItemStack.OPTIONAL_STREAM_CODEC.decode(b);int n=b.readVarInt();if(n<0||n>54)throw new IllegalArgumentException("Invalid arena inventory");var slots=new ArrayList<Integer>();var items=new ArrayList<ItemStack>();for(int i=0;i<n;i++){slots.add(b.readVarInt());items.add(ItemStack.OPTIONAL_STREAM_CODEC.decode(b));}return new View(data,currency,List.copyOf(slots),List.copyOf(items),b.readBoolean());});
public Type<View> type(){return TYPE;}
}
private record Watch(String id,int page,long tick){}
private static final Map<ServerPlayer,Watch> WATCHERS=new WeakHashMap<>();
private static final Map<ServerPlayer,Integer> REQUESTS=new WeakHashMap<>();
private ArenaNetwork(){}
public static void register(){
PayloadTypeRegistry.serverboundPlay().register(Request.TYPE,Request.CODEC);PayloadTypeRegistry.clientboundPlay().register(View.TYPE,View.CODEC);
ServerPlayNetworking.registerGlobalReceiver(Request.TYPE,(r,c)->c.server().execute(()->request(c.player(),r)));
ServerPlayConnectionEvents.DISCONNECT.register((h,s)->{WATCHERS.remove(h.player);REQUESTS.remove(h.player);});
}
public static void request(ServerPlayer p,Request r){
if(r.action.equals("close")){WATCHERS.remove(p);return;}
int tick=p.level().getServer().getTickCount();if(tick-REQUESTS.getOrDefault(p,-100)<3)return;REQUESTS.put(p,tick);
if(p.isSpectator()&&!Set.of("open","refresh","claim").contains(r.action))return;
UUID id=null;try{if(!r.id.isEmpty())id=UUID.fromString(r.id);}catch(IllegalArgumentException e){return;}
var m=id==null?null:Arenas.get(p.level().getServer(),id);String message="";boolean open=r.action.equals("open");
if(m!=null&&!Set.of("open","refresh","claim","bet").contains(r.action)&&r.revision!=m.revision)message="changed";
else try{
message=switch(r.action){
case "open","refresh"->"";
case "create"->{String[] values=r.options.split(",");if(values.length!=5)yield "unavailable";var rules=new Arenas.Rules(Arenas.Mode.valueOf(values[0]),Boolean.parseBoolean(values[1]),Boolean.parseBoolean(values[2]),Integer.parseInt(values[3]),Integer.parseInt(values[4]));yield Arenas.create(p,rules,r.slot,r.value);}
case "join"->Arenas.join(m,p);
case "ready"->Arenas.ready(m,p);
case "start"->Arenas.start(m,p);
case "leave"->{if(m==null||m!=Arenas.match(p))yield "unavailable";Arenas.withdraw(p);yield "updated";}
case "cancel"->{if(m==null||m.legacy||!m.host.equals(p.getUUID())||m.phase!=Arenas.Phase.OPEN)yield "unavailable";Arenas.finish(m,null,"cancelled");yield "updated";}
case "bet"->Arenas.bet(m,p,UUID.fromString(r.target),r.value);
case "claim"->{var ledger=Arenas.ledger(p.level().getServer());if(ledger!=null)ledger.claim(p);yield "updated";}
default->"unavailable";
};
}catch(IllegalArgumentException e){message="unavailable";}
if(message.equals("created"))m=Arenas.match(p);
WATCHERS.put(p,new Watch(m==null?"":m.id.toString(),Math.max(0,Math.min(r.page,1000000)),tick));send(p,message,open);
}
public static void tick(MinecraftServer s){
if(s.getTickCount()%20!=0)return;
for(var p:List.copyOf(WATCHERS.keySet()))if(p.level().getServer()==s){if(s.getTickCount()-WATCHERS.get(p).tick>140||p.isRemoved())WATCHERS.remove(p);else send(p,"",false);}
}
private static String clip(String value){return value.length()>160?value.substring(0,160):value;}
private static JsonObject summary(Arenas.Match m){var d=new JsonObject();d.addProperty("id",m.id.toString());d.addProperty("name",m.name);d.addProperty("phase",m.phase.name());d.addProperty("mode",m.rules.mode().name());d.addProperty("duel",m.rules.duel());d.addProperty("size",m.entrants.size());d.addProperty("entry",m.entry);return d;}
public static void send(ServerPlayer p,String message,boolean open){
if(!ServerPlayNetworking.canSend(p,View.TYPE))return;var watch=WATCHERS.getOrDefault(p,new Watch("",0,0));var data=new JsonObject();data.addProperty("message",message);
var ledger=Arenas.ledger(p.level().getServer());data.addProperty("pending",ledger!=null&&ledger.pending(p));data.addProperty("healthy",ledger!=null&&ledger.healthy());data.addProperty("enrolled",Arenas.enrolled(p));
var events=new ArrayList<>(Arenas.list(p.level().getServer()));events.sort(Comparator.comparingInt(m->m.phase==Arenas.Phase.FINISHED?1:0));
Arenas.Match m=watch.id.isEmpty()?null:Arenas.get(p.level().getServer(),UUID.fromString(watch.id));var currency=ItemStack.EMPTY;
if(m==null){int page=Math.min(watch.page,Math.max(0,(events.size()-1)/PAGE_SIZE));data.addProperty("page",page);data.addProperty("total",events.size());var list=new JsonArray();events.stream().skip((long)page*PAGE_SIZE).limit(PAGE_SIZE).forEach(e->list.add(summary(e)));data.add("events",list);}
else{
var d=summary(m);currency=m.currency.copy();d.addProperty("legacy",m.legacy);d.addProperty("revision",m.revision);d.addProperty("host",m.host.toString());d.addProperty("radius",m.rules.radius());d.addProperty("seconds",m.rules.seconds());d.addProperty("supplies",m.rules.supplies());d.addProperty("remaining",Math.max(0,(m.until-fr.koka.sanctuary.familiar.FamiliarBattle.now()+999)/1000));d.addProperty("dimension",m.level.dimension().identifier().toString());d.addProperty("x",(int)m.center.x);d.addProperty("y",(int)m.center.y);d.addProperty("z",(int)m.center.z);d.addProperty("winner",m.winner==null?"":m.entrants.get(m.winner).name);d.addProperty("reason",m.reason);
d.addProperty("entryPot",m.phase==Arenas.Phase.FINISHED?m.entryPot:ledger==null?0:ledger.pot(m.id,true));d.addProperty("betPot",m.phase==Arenas.Phase.FINISHED?m.betPot:ledger==null?0:ledger.pot(m.id,false));
var own=m.entrants.get(p.getUUID());d.addProperty("participant",own!=null);d.addProperty("ready",own!=null&&own.ready);d.addProperty("out",own!=null&&own.out);
int page=Math.min(watch.page,Math.max(0,(m.entrants.size()-1)/PAGE_SIZE));data.addProperty("page",page);data.addProperty("total",m.entrants.size());var entrants=new JsonArray();m.entrants.values().stream().skip((long)page*PAGE_SIZE).limit(PAGE_SIZE).forEach(e->{var row=new JsonObject();row.addProperty("id",e.id.toString());row.addProperty("name",e.name.length()>160?e.name.substring(0,160):e.name);row.addProperty("ready",e.ready);row.addProperty("out",e.out);row.addProperty("damage",Math.round(e.damage*10)/10F);var bond=fr.koka.sanctuary.familiar.FamiliarBattle.bond(e.player);row.addProperty("pet",bond==null?"":clip(fr.koka.sanctuary.familiar.FamiliarBattle.egg(e.player).getHoverName().getString()));row.addProperty("health",Math.round(e.player.getHealth()*10)/10F);row.addProperty("petHealth",Math.round(fr.koka.sanctuary.familiar.FamiliarLevels.current(bond)*10)/10F);entrants.add(row);});d.add("entrants",entrants);data.add("match",d);
}
var slots=CompanionInventory.slots(p,true);ServerPlayNetworking.send(p,new View(data.toString(),currency,slots,slots.stream().map(i->p.getInventory().getItem(i).copy()).toList(),open));
}
}
@@ -0,0 +1,194 @@
package fr.koka.sanctuary.arena;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.companion.CompanionInventory;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.familiar.*;
import java.util.*;
import net.fabricmc.fabric.api.event.lifecycle.v1.*;
import net.fabricmc.fabric.api.event.player.*;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.*;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.*;
import net.minecraft.world.phys.Vec3;
/** Server-owned free-for-all events. Registration is consent to immutable, publicly visible rules. */
public final class Arenas {
public enum Mode { FAMILIARS, PLAYERS, MIXED }
public enum Phase { OPEN, COUNTDOWN, FIGHT, FINISHED }
public record Rules(Mode mode,boolean duel,boolean supplies,int radius,int seconds) {
public Rules { if(!Set.of(24,48,96).contains(radius)||!Set.of(180,300,600).contains(seconds))throw new IllegalArgumentException("Invalid arena rules"); }
}
public static final class Entrant {
public final UUID id,individual;
public final String name;
public final ServerPlayer player;
public boolean ready,out;
public float damage;
Entrant(ServerPlayer p){player=p;id=p.getUUID();name=p.getDisplayName().getString();individual=FamiliarIdentity.get(FamiliarBattle.egg(p));}
}
public static final class Match {
public final UUID id,host;
public final String name;
public final ServerLevel level;
public final Vec3 center;
public final Rules rules;
public final ItemStack currency;
public final int entry;
public final LinkedHashMap<UUID,Entrant> entrants=new LinkedHashMap<>();
public Phase phase=Phase.OPEN;
public int revision=1;
public long until,entryPot,betPot;
public UUID winner;
public String reason="";
public boolean legacy;
Match(UUID id,ServerPlayer p,Rules rules,ItemStack currency,int entry){this.id=id;host=p.getUUID();name=p.getDisplayName().getString();level=p.level();center=p.position();this.rules=rules;this.currency=currency.copyWithCount(1);this.entry=entry;until=FamiliarBattle.now()+600000;}
public boolean running(){return phase==Phase.COUNTDOWN||phase==Phase.FIGHT;}
}
private static final Map<MinecraftServer,LinkedHashMap<UUID,Match>> WORLDS=new WeakHashMap<>();
private static final Map<MinecraftServer,ArenaLedger> LEDGERS=new WeakHashMap<>();
private static final Map<ServerPlayer,Match> ENROLLED=new WeakHashMap<>();
private static final Map<ServerPlayer,Long> CREATED=new WeakHashMap<>();
private Arenas(){}
public static Component text(String key,Object...args){return Component.translatable("sanctuary.arena."+key,args);}
public static ArenaLedger ledger(MinecraftServer s){return LEDGERS.get(s);}
public static Collection<Match> list(MinecraftServer s){return WORLDS.computeIfAbsent(s,k->new LinkedHashMap<>()).values();}
public static Match get(MinecraftServer s,UUID id){return WORLDS.computeIfAbsent(s,k->new LinkedHashMap<>()).get(id);}
public static Match match(ServerPlayer p){return ENROLLED.get(p);}
public static boolean enrolled(ServerPlayer p){return match(p)!=null;}
public static boolean running(ServerPlayer p){var m=match(p);return m!=null&&!m.legacy&&m.running();}
public static UUID combatId(ServerPlayer p){var m=match(p);return running(p)?m.id:null;}
public static void register(){
ArenaNetwork.register();
ServerLifecycleEvents.SERVER_STARTED.register(s->{try{LEDGERS.put(s,new ArenaLedger(s));}catch(Exception e){SanctuaryMod.LOGGER.error("Arena journal unavailable; file preserved",e);}});
ServerTickEvents.END_SERVER_TICK.register(Arenas::tick);
ServerLifecycleEvents.SERVER_STOPPING.register(s->{for(var m:List.copyOf(list(s)))finish(m,null,"interrupted");});
ServerLifecycleEvents.SERVER_STOPPED.register(s->{WORLDS.remove(s);LEDGERS.remove(s);ENROLLED.keySet().removeIf(p->p.level().getServer()==s);CREATED.keySet().removeIf(p->p.level().getServer()==s);});
ServerLifecycleEvents.END_DATA_PACK_RELOAD.register((s,r,ok)->{if(ok)for(var m:List.copyOf(list(s)))if(!m.legacy)finish(m,null,"interrupted");});
ServerPlayConnectionEvents.DISCONNECT.register((h,s)->{var m=match(h.player);if(m!=null&&!m.legacy){var e=m.entrants.get(h.player.getUUID());if(e!=null&&!e.out)withdraw(h.player);}});
UseBlockCallback.EVENT.register((p,l,h,hit)->p instanceof ServerPlayer s&&running(s)?InteractionResult.FAIL:InteractionResult.PASS);
AttackBlockCallback.EVENT.register((p,l,h,pos,face)->p instanceof ServerPlayer s&&running(s)?InteractionResult.FAIL:InteractionResult.PASS);
UseItemCallback.EVENT.register((p,l,h)->p instanceof ServerPlayer s&&running(s)&&(!match(s).rules.supplies()&&consumable(p.getItemInHand(h))||p.getItemInHand(h).getItem() instanceof BlockItem)?InteractionResult.FAIL:InteractionResult.PASS);
}
public static String create(ServerPlayer p,Rules rules,int slot,int count){
if(enrolled(p)||FamiliarDuels.match(p)!=null||!available(p,rules)||FamiliarBattle.fighting(p))return "unavailable";
if(CREATED.getOrDefault(p,0L)>FamiliarBattle.now())return "wait";
var ledger=ledger(p.level().getServer());if(ledger==null||!ledger.healthy())return "storage";
ItemStack currency=new ItemStack(Items.EMERALD);
if(slot>=0){if(!CompanionInventory.slots(p,true).contains(slot))return "missing";currency=p.getInventory().getItem(slot);if(currency.isEmpty())return "missing";}
if(count<0||count>64)return "missing";
var m=new Match(UUID.randomUUID(),p,rules,currency,count);
if(count>0&&!ledger.deposit(m.id,p,p.getUUID(),true,m.currency,count))return "missing";
m.entrants.put(p.getUUID(),new Entrant(p));WORLDS.computeIfAbsent(p.level().getServer(),k->new LinkedHashMap<>()).put(m.id,m);ENROLLED.put(p,m);CREATED.put(p,FamiliarBattle.now()+10000);announce(m,"announced",p.getDisplayName());return "created";
}
public static boolean consumable(ItemStack stack){return stack.has(net.minecraft.core.component.DataComponents.CONSUMABLE)||stack.is(Items.SPLASH_POTION)||stack.is(Items.LINGERING_POTION)||stack.is(Items.ENDER_PEARL);}
private static boolean available(ServerPlayer p,Rules r){return fr.koka.sanctuary.progression.ProgressEffects.get(p)!=null&&p.isAlive()&&!p.isRemoved()&&!p.isSpectator()&&!p.isCreative()&&(r.mode()==Mode.PLAYERS||FamiliarDuels.ready(p));}
public static boolean inside(Match m,ServerPlayer p){return p.level()==m.level&&p.position().distanceToSqr(m.center)<=m.rules.radius()*m.rules.radius();}
public static String join(Match m,ServerPlayer p){
var ledger=ledger(p.level().getServer());
if(m==null||m.legacy||m.phase!=Phase.OPEN||enrolled(p)||m.entrants.containsKey(p.getUUID())||FamiliarDuels.match(p)!=null||!available(p,m.rules)||FamiliarBattle.fighting(p)||!inside(m,p)||m.rules.duel()&&m.entrants.size()>=2)return "unavailable";
if(ledger==null||ledger.hasBet(m.id,p.getUUID()))return "bettor";
if(m.entry>0&&!ledger.deposit(m.id,p,p.getUUID(),true,m.currency,m.entry))return "missing";
m.entrants.put(p.getUUID(),new Entrant(p));for(var e:m.entrants.values())e.ready=false;ENROLLED.put(p,m);m.revision++;return "joined";
}
public static String ready(Match m,ServerPlayer p){if(m==null||m.legacy||m.phase!=Phase.OPEN||!m.entrants.containsKey(p.getUUID()))return "unavailable";var e=m.entrants.get(p.getUUID());e.ready=!e.ready;m.revision++;return "updated";}
public static String start(Match m,ServerPlayer p){
if(m==null||m.legacy||m.phase!=Phase.OPEN||!m.host.equals(p.getUUID())||m.entrants.size()<2)return "unavailable";
for(var e:m.entrants.values())if(!e.ready||!available(e.player,m.rules)||!inside(m,e.player)||FamiliarBattle.fighting(e.player)||m.rules.mode()!=Mode.PLAYERS&&!Objects.equals(e.individual,FamiliarIdentity.get(FamiliarBattle.egg(e.player))))return "not_ready";
m.phase=Phase.COUNTDOWN;m.until=FamiliarBattle.now()+10000;m.revision++;
for(var e:m.entrants.values()){e.player.stopUsingItem();e.player.stopRiding();e.player.ejectPassengers();fr.koka.sanctuary.mining.MiningManager.interrupt(e.player);fr.koka.sanctuary.building.BuildingManager.interrupt(e.player);FamiliarBattle.cancel(e.player);FamiliarBattle.state(e.player).workMode=false;FamiliarBattle.combat(e.player);}
announce(m,"starting",text("mode."+m.rules.mode().name().toLowerCase(Locale.ROOT)));return "started";
}
public static String bet(Match m,ServerPlayer p,UUID target,int count){
if(m==null||m.phase!=Phase.OPEN||count<1||count>3456||!m.entrants.containsKey(target))return "closed";
if(m.entrants.containsKey(p.getUUID()))return "participant_bet";
var ledger=ledger(p.level().getServer());if(ledger==null||!ledger.deposit(m.id,p,target,false,m.currency,count))return "missing";
return "bet_placed";
}
public static void withdraw(ServerPlayer p){var m=match(p);if(m==null||m.legacy)return;if(m.phase==Phase.OPEN){
if(m.host.equals(p.getUUID())){finish(m,null,"cancelled");return;}
var ledger=ledger(m.level.getServer());if(ledger!=null){ledger.leave(m.id,p.getUUID());if(!ledger.healthy())return;}m.entrants.remove(p.getUUID());ENROLLED.remove(p);for(var e:m.entrants.values())e.ready=false;m.revision++;if(ledger!=null)for(var q:m.level.getServer().getPlayerList().getPlayers())ledger.claim(q);
}else eliminate(p,"forfeit");}
public static void knockedOut(ServerPlayer p){var m=match(p);if(m!=null&&!m.legacy&&m.rules.mode()==Mode.FAMILIARS)eliminate(p,"ko");}
public static void eliminate(ServerPlayer p,String reason){
var m=match(p);if(m==null||m.legacy||!m.running())return;var e=m.entrants.get(p.getUUID());if(e.out)return;e.out=true;m.revision++;FamiliarBattle.cancel(p);p.sendSystemMessage(text("eliminated",text(reason)));
var alive=m.entrants.values().stream().filter(x->!x.out).toList();if(alive.size()<=1)finish(m,alive.isEmpty()?null:alive.getFirst().id,reason);
}
public static void finish(Match m,UUID winner,String reason){
if(m==null||m.phase==Phase.FINISHED)return;m.phase=Phase.FINISHED;m.winner=winner;m.reason=reason;m.until=FamiliarBattle.now()+300000;m.revision++;
var ledger=ledger(m.level.getServer());if(ledger!=null){m.entryPot=ledger.pot(m.id,true);m.betPot=ledger.pot(m.id,false);ledger.finish(m.id,winner);}
for(var e:m.entrants.values()){ENROLLED.remove(e.player,m);FamiliarBattle.cancel(e.player);FamiliarBattle.state(e.player).order=FamiliarBattle.Order.FOLLOW;}
announce(m,"result",winner==null?text("nobody"):Component.literal(m.entrants.get(winner).name));
if(ledger!=null)for(var p:m.level.getServer().getPlayerList().getPlayers())ledger.claim(p);
}
public static Boolean enemy(ServerPlayer p,LivingEntity target){
var m=match(p);var owner=owner(target);var other=owner==null?null:match(owner);
if(m==null||m.legacy)return other!=null&&!other.legacy&&other.running()?false:null;
if(!m.running())return null;
return m.phase==Phase.FIGHT&&m.rules.mode()!=Mode.PLAYERS&&owner!=p&&other==m&&!m.entrants.get(p.getUUID()).out&&!m.entrants.get(owner.getUUID()).out&&(target instanceof FamiliarEntity||m.rules.mode()==Mode.MIXED);
}
public static ServerPlayer owner(Entity e){if(e instanceof FamiliarEntity f)return f.owner();if(e instanceof ServerPlayer p)return p;if(e instanceof net.minecraft.world.entity.projectile.Projectile shot)return owner(shot.getOwner());return null;}
public static Boolean playerEnemy(ServerPlayer p,Player q){
var m=match(p);var other=q instanceof ServerPlayer s?match(s):null;
if((m==null||m.legacy||!m.running())&&(other==null||other.legacy||!other.running()))return null;
return m!=null&&m==other&&!m.legacy&&m.phase==Phase.FIGHT&&m.rules.mode()!=Mode.FAMILIARS&&p!=q&&!m.entrants.get(p.getUUID()).out&&!m.entrants.get(q.getUUID()).out;
}
/** null = ordinary world rules. No damage can cross the consent boundary. */
public static Boolean damageAllowed(LivingEntity target,DamageSource source){
var victim=owner(target);var attacker=owner(source.getEntity());var m=victim==null?null:match(victim);var a=attacker==null?null:match(attacker);
boolean v=m!=null&&!m.legacy&&m.running(),s=a!=null&&!a.legacy&&a.running();
if(!v&&!s)return null;
if(!v)return false;
if(m.phase!=Phase.FIGHT||m.entrants.get(victim.getUUID()).out)return false;
if(target instanceof Player&&m.rules.mode()==Mode.FAMILIARS)return false;
if(target instanceof FamiliarEntity&&m.rules.mode()==Mode.PLAYERS)return false;
if(attacker==null)return true;
if(!s||a!=m||m.entrants.get(attacker.getUUID()).out||attacker==victim)return false;
if(m.rules.mode()==Mode.FAMILIARS&&!(source.getEntity() instanceof FamiliarEntity))return false;
if(m.rules.mode()==Mode.PLAYERS&&source.getEntity() instanceof FamiliarEntity)return false;
return true;
}
/** Count real damage after vanilla armor and absorption. Death remains entirely native. */
public static void afterDamage(LivingEntity entity,DamageSource source,float lost){
var attacker=owner(source.getEntity());if(attacker!=null){var m=match(attacker);if(m!=null&&!m.legacy&&m.phase==Phase.FIGHT)m.entrants.get(attacker.getUUID()).damage+=Math.max(0,lost);}
}
public static void died(ServerPlayer p){var m=match(p);if(m==null||m.legacy)return;if(m.phase==Phase.OPEN)withdraw(p);else if(m.running())eliminate(p,"death");}
public static boolean castAllowed(FamiliarTechniques.Cast cast){var m=match(cast.owner);return m==null||m.legacy||!m.running()||m.phase==Phase.FIGHT&&m.rules.mode()!=Mode.PLAYERS&&!m.entrants.get(cast.owner.getUUID()).out;}
public static void tick(MinecraftServer server){
long now=FamiliarBattle.now();
for(var m:List.copyOf(list(server))){
if(m.phase==Phase.FINISHED){if(now>=m.until)WORLDS.get(server).remove(m.id);continue;}if(m.legacy)continue;
if(now>=m.until){if(m.phase==Phase.COUNTDOWN){m.phase=Phase.FIGHT;m.until=now+m.rules.seconds()*1000L;m.revision++;announce(m,"fight",text("mode."+m.rules.mode().name().toLowerCase(Locale.ROOT)));}else{finish(m,null,m.phase==Phase.OPEN?"expired":"draw");continue;}}
if(!m.running())continue;
for(var e:List.copyOf(m.entrants.values())){
if(!m.running())break;if(e.out)continue;var p=e.player;
if(!inside(m,p)||p.isRemoved()||!p.isAlive()||p.isCreative()||p.isSpectator()){eliminate(p,"left");continue;}
if(m.rules.mode()!=Mode.PLAYERS&&(!Objects.equals(e.individual,FamiliarIdentity.get(FamiliarBattle.egg(p)))||FamiliarBattle.bond(p)!=null&&FamiliarBattle.bond(p).recalled())){eliminate(p,"forfeit");continue;}
if(m.rules.mode()==Mode.FAMILIARS&&!FamiliarDuels.ready(p)){eliminate(p,"ko");continue;}
if(server.getTickCount()%20==0){
FamiliarBattle.combat(p);
if(m.phase==Phase.FIGHT&&m.rules.mode()!=Mode.PLAYERS&&FamiliarBattle.target(p)==null){
LivingEntity nearest=null;double best=24*24;
for(var rival:m.entrants.values())if(!rival.out&&rival!=e){LivingEntity target=FamiliarService.active(rival.player);if(target==null&&m.rules.mode()==Mode.MIXED)target=rival.player;if(target!=null&&target.isAlive()&&enemy(p,target)==Boolean.TRUE&&p.distanceToSqr(target)<best){nearest=target;best=p.distanceToSqr(target);}}
if(nearest!=null)FamiliarBattle.engage(p,nearest,false);
}
}
}
}
ArenaNetwork.tick(server);
}
public static void legacyCreated(FamiliarDuels.Match duel){
var p=duel.players[0];var m=new Match(duel.id,p,new Rules(Mode.FAMILIARS,true,true,24,180),new ItemStack(Items.EMERALD),0);m.legacy=true;
for(var e:duel.players)m.entrants.put(e.getUUID(),new Entrant(e));WORLDS.computeIfAbsent(p.level().getServer(),k->new LinkedHashMap<>()).put(m.id,m);announce(m,"announced",p.getDisplayName());
}
public static void legacyStarted(FamiliarDuels.Match duel){var m=get(duel.players[0].level().getServer(),duel.id);if(m!=null){m.phase=Phase.COUNTDOWN;m.until=duel.until;announce(m,"starting",text("mode.familiars"));}}
public static void legacyFight(FamiliarDuels.Match duel){var m=get(duel.players[0].level().getServer(),duel.id);if(m!=null){m.phase=Phase.FIGHT;m.until=duel.until;}}
public static void legacyFinished(FamiliarDuels.Match duel,int winner,String reason){finish(get(duel.players[0].level().getServer(),duel.id),winner<0?null:duel.players[winner].getUUID(),reason);}
private static void announce(Match m,String key,Component detail){m.level.getServer().getPlayerList().broadcastSystemMessage(text(key,text(m.rules.duel()?"duel":"arena"),m.id.toString().substring(0,8),detail),false);}
}
@@ -0,0 +1,16 @@
package fr.koka.sanctuary.boats;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.entity.vehicle.boat.AbstractBoat;
/** Boat seats remain usable; passengers carried on somebody's head keep their protection. */
public final class BoatPassengerInteractions {
private BoatPassengerInteractions() {}
public static boolean aboardTogether(Entity actor, Entity target) {
return actor instanceof Player && target instanceof Mob && actor.getVehicle() != target
&& target.getVehicle() instanceof AbstractBoat boat && actor.getRootVehicle() == boat;
}
}
@@ -9,6 +9,7 @@ import net.minecraft.world.entity.vehicle.boat.AbstractBoat;
import net.minecraft.world.item.Item;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.phys.AABB;
/** One native vehicle with a fixed rectangular seating plan. Its movement is always simulated by the server. */
public final class CrewBoat extends AbstractBoat {
@@ -44,15 +45,43 @@ public final class CrewBoat extends AbstractBoat {
return seatOffset(Math.max(0, getPassengers().indexOf(passenger))).yRot((float)Math.toRadians(-getYRot()));
}
@Override protected net.minecraft.world.phys.AABB makeBoundingBox(Vec3 at) {
if(columns < 1 || rows < 1 || columns == rows)return super.makeBoundingBox(at);
// TiledBoatModel repeats 28px longitudinal floors, 16px wooden / 20px raft
// floors across. Wooden exterior rails add 2px per side; paddles are decorative.
private double halfHullWidth() { return wood.equals("bamboo") ? columns * 10.0 / 16 : (columns * 8.0 + 2) / 16; }
private double halfHullLength() { return (rows * 14.0 + (wood.equals("bamboo") ? 0 : 2)) / 16; }
@Override protected AABB makeBoundingBox(Vec3 at) {
if(columns < 1 || rows < 1)return super.makeBoundingBox(at); // Entity constructor precedes our fields.
double angle=Math.toRadians(getYRot()), c=Math.abs(Math.cos(angle)), s=Math.abs(Math.sin(angle));
double x=.6875*(columns*c+rows*s), z=.6875*(rows*c+columns*s);
return new net.minecraft.world.phys.AABB(at.x-x,at.y,at.z-z,at.x+x,at.y+getBbHeight(),at.z+z);
return hullBox(at,halfHullWidth()*c+halfHullLength()*s,halfHullLength()*c+halfHullWidth()*s);
}
private AABB hullBox(Vec3 at,double x,double z) {
return new AABB(at.x-x,at.y,at.z-z,at.x+x,at.y+getBbHeight(),at.z+z);
}
@Override public void setYRot(float yaw) {
super.setYRot(yaw);
if(columns>0 && rows>0 && columns!=rows)setBoundingBox(makeBoundingBox(position()));
if(columns>0 && rows>0)setBoundingBox(makeBoundingBox(position()));
}
/** Translation collides natively, but changing yaw must not start that move inside a wall. */
private void turnWithCollisions() {
float target=getYRot()+turn;
if(target==getYRot())return;
double from=Math.toRadians(net.minecraft.util.Mth.wrapDegrees(getYRot()));
double to=from+Math.toRadians(target-getYRot());
var swept=hullBox(position(),sweptExtent(halfHullWidth(),halfHullLength(),from,to),
sweptExtent(halfHullLength(),halfHullWidth(),from,to));
if(level().noCollision(this,swept.deflate(1.0E-7)))setYRot(target);
else turn=0; // Stop angular inertia at contact; keep native tangential/reverse movement.
}
private static double sweptExtent(double a,double b,double from,double to) {
double low=Math.min(from,to),high=Math.max(from,to);
double maximum=Math.max(a*Math.abs(Math.cos(from))+b*Math.abs(Math.sin(from)),
a*Math.abs(Math.cos(to))+b*Math.abs(Math.sin(to)));
// Include extrema between endpoints: checking the target alone misses a corner's arc.
double peak=Math.atan2(b,a);
for(int k=-2;k<=2;k++)for(double angle:new double[]{k*Math.PI+peak,k*Math.PI-peak})
if(angle>=low&&angle<=high)return Math.hypot(a,b);
return maximum;
}
public record Orders(int pilots, double forward, double steering, double lift) { }
@@ -76,7 +105,7 @@ public final class CrewBoat extends AbstractBoat {
var profile = engines.count() == 0 ? null : FlightBalance.profile(engines, FlightMotors.load(this), columns, rows);
double response = profile == null ? .1 : profile.response();
turn += (float)((orders.steering() * 5 - turn) * response);
setYRot(getYRot() + turn);
turnWithCollisions();
double heading = Math.toRadians(getYRot()), throttle = orders.forward();
entityData.set(ROWING, (byte)((throttle != 0 || orders.steering() > 0 ? 1 : 0) | (throttle != 0 || orders.steering() < 0 ? 2 : 0)));
if (profile == null) {
@@ -0,0 +1,31 @@
package fr.koka.sanctuary.building;
import fr.koka.sanctuary.progression.ProgressEffects;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.phys.AABB;
/** Placement-only reach: independent mining, native containers/combat and external range bonuses. */
public final class BuildReach {
private BuildReach() { }
public static double range(Player player) {
double vanilla=player.blockInteractionRange();
var progress=ProgressEffects.get(player);
return progress==null || player.isCreative() || player.isSpectator() ? vanilla
: Math.max(0,vanilla+BuildingLimits.reach(progress.building())-4.5);
}
public static boolean within(Player player,BlockPos support,double buffer) {
double range=range(player)+buffer;
return new AABB(support).distanceToSqr(player.getEyePosition())<range*range;
}
public static double pickRange(Player player) {
double base=Math.max(player.blockInteractionRange(),fr.koka.sanctuary.mining.MiningReach.range(player));
return player.getMainHandItem().getItem() instanceof BlockItem || player.getOffhandItem().getItem() instanceof BlockItem
? Math.max(base,range(player)) : base;
}
public static boolean grouped(Player player) {
return player instanceof ServerPlayer server && BuildingManager.placing(server);
}
}
@@ -39,13 +39,14 @@ public final class BuildSelection {
}
public static BlockHitResult aimed(ServerPlayer player,BlockPos origin) {
if (!player.isAlive() || player.isSpectator() || !usable(player) || !permitted(player,origin)
|| !player.isWithinBlockInteractionRange(origin,0)) return null;
var hit=player.pick(player.blockInteractionRange(),1,false);
|| !BuildReach.within(player,origin,0)) return null;
var hit=player.pick(BuildReach.range(player),1,false);
return hit instanceof BlockHitResult block && hit.getType()!=HitResult.Type.MISS && block.getBlockPos().equals(origin) ? block : null;
}
public static Plan plan(Player player,BlockHitResult hit,int rank) {
if (!usable(player) || rank<1 || rank>BuildingLimits.MAX_RANK || hit.getType()==HitResult.Type.MISS
|| !loaded(player.level(),hit.getBlockPos())) return null;
|| !loaded(player.level(),hit.getBlockPos())
|| player.getEyePosition().distanceToSqr(hit.getLocation())>Math.pow(BuildReach.range(player),2)) return null;
var base=new BlockPlaceContext(player,InteractionHand.MAIN_HAND,player.getMainHandItem(),hit);
BlockPos anchor=base.getClickedPos();
var view=PlacementOrientation.View.capture(player);
@@ -1,9 +1,11 @@
package fr.koka.sanctuary.building;
/** Diameter is independent from the player's native interaction/attack reach. */
/** Surface size and placement reach progress independently; entity reach stays native. */
public final class BuildingLimits {
public static final int MAX_RANK=7;
private static final double[] REACH={3,3.5,4,4.5,4.75,5,5.5,6};
private BuildingLimits() { }
public static double reach(int rank) {return REACH[Math.clamp(rank,0,MAX_RANK)];}
public static int diameter(int rank) {return 1+2*Math.clamp(rank,0,MAX_RANK);}
public static int area(int rank) {int d=diameter(rank);return d*d;}
public static int requested(int requested,int earned) {return Math.clamp(requested,0,Math.clamp(earned,0,MAX_RANK));}
@@ -41,7 +41,7 @@ final class BuildingSession {
|| player.gameMode.getGameModeForPlayer()!=mode || player.containerMenu!=player.inventoryMenu
|| level.getServer().getTickCount()-heartbeat>12 || !sameStack()
|| !BuildSelection.permitted(player,plan.hit().getBlockPos())
|| !player.isWithinBlockInteractionRange(plan.hit().getBlockPos(),0)) return false;
|| !BuildReach.within(player,plan.hit().getBlockPos(),0)) return false;
if (!player.isCreative() && ++ticks%2!=0) return true;
int budget=player.isCreative() ? 16 : 1;
for (int processed=0;index<plan.targets().size() && processed<budget;processed++) {
@@ -0,0 +1,20 @@
package fr.koka.sanctuary.client;
import com.google.gson.*;
import fr.koka.sanctuary.arena.*;
import net.fabricmc.fabric.api.client.networking.v1.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
public final class ArenaClient {
public static ArenaNetwork.View view;
public static JsonObject data=new JsonObject();
private ArenaClient(){}
public static void register(){
ClientPlayNetworking.registerGlobalReceiver(ArenaNetwork.View.TYPE,(packet,c)->c.client().execute(()->{view=packet;data=JsonParser.parseString(packet.data()).getAsJsonObject();if(c.client().gui.screen() instanceof ArenaScreen s)s.accept();}));
ClientPlayConnectionEvents.DISCONNECT.register((h,c)->{view=null;data=new JsonObject();});
}
public static void open(Screen from){view=null;data=new JsonObject();Minecraft.getInstance().gui.setScreen(new ArenaScreen(from));send("open","","",0,-1,0,0,"");}
public static void send(String action,String id,String target,int revision,int slot,int value,int page,String options){if(ClientPlayNetworking.canSend(ArenaNetwork.Request.TYPE))ClientPlayNetworking.send(new ArenaNetwork.Request(action,id,target,revision,slot,value,page,options));}
public static void action(String action,String target,int value,int page){var m=data.has("match")?data.getAsJsonObject("match"):null;send(action,m==null?"":m.get("id").getAsString(),target,m==null?0:m.get("revision").getAsInt(),-1,value,page,"");}
}
@@ -0,0 +1,106 @@
package fr.koka.sanctuary.client;
import com.google.gson.*;
import fr.koka.sanctuary.arena.*;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.narration.NarrationElementOutput;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.ItemStack;
/** Vanilla widgets, paged public roster and explicit registration/betting actions. */
public final class ArenaScreen extends BlocodexFrame {
private boolean creating,duel,supplies=true;
private Arenas.Mode mode=Arenas.Mode.FAMILIARS;
private int radius=24,seconds=180,slot=-1,ticks;
private String entry="0",bet="1",structure="",message="";
public ArenaScreen(Screen parent){super(parent);}
private static String str(JsonObject o,String key){return o.has(key)?o.get(key).getAsString():"";}
private static boolean flag(JsonObject o,String key){return o.has(key)&&o.get(key).getAsBoolean();}
private static int number(JsonObject o,String key){return o.has(key)?o.get(key).getAsInt():0;}
public void accept(){var d=ArenaClient.data;if(!str(d,"message").isEmpty()){message=str(d,"message");if(message.equals("created"))creating=false;}
var m=d.has("match")?d.getAsJsonObject("match"):null;String key=creating?"create":m==null?d.get("events")+"/"+flag(d,"pending"):str(m,"id")+str(m,"phase")+number(m,"revision")+number(d,"page")+flag(d,"pending");
if(!key.equals(structure)||!str(d,"message").isEmpty()){structure=key;rebuildWidgets();}
}
@Override protected void init(){
initFrame("identity",Arenas.text("title"));int w=contentWidth-28;var l=LinearLayout.vertical().spacing(5);var d=ArenaClient.data;
if(!message.isEmpty())line(l,w,Arenas.text(message));
if(ArenaClient.view==null)line(l,w,Arenas.text("loading"));
else if(creating)creation(l,w);
else if(d.has("match"))detail(l,w,d.getAsJsonObject("match"));
else{
line(l,w,Arenas.text("intro"));
l.addChild(Button.builder(Arenas.text("create"),b->{creating=true;structure="create";message="";rebuildWidgets();}).width(w).build()).active=!flag(d,"enrolled")&&flag(d,"healthy");
if(d.has("events"))for(var value:d.getAsJsonArray("events")){var e=value.getAsJsonObject();
l.addChild(Button.builder(Arenas.text("event",Arenas.text(flag(e,"duel")?"duel":"arena"),str(e,"name"),number(e,"size"),Arenas.text("phase."+str(e,"phase").toLowerCase(java.util.Locale.ROOT))),b->{message="";ArenaClient.send("refresh",str(e,"id"),"",0,-1,0,0,"");}).width(w).build());
}
if(number(d,"total")==0)line(l,w,Arenas.text("empty"));pages(l,w);
}
if(!creating&&flag(d,"pending"))l.addChild(Button.builder(Arenas.text("claim"),b->ArenaClient.action("claim","",0,number(d,"page"))).width(w).build());
var scroll=new ScrollableLayout(minecraft,l,Math.max(24,contentBottom-contentTop),ScrollableLayout.ReserveStrategy.RIGHT);scroll.setPosition(contentX+8,contentTop);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
}
private void creation(LinearLayout l,int w){
l.addChild(Button.builder(Arenas.text(duel?"duel":"arena"),b->{duel=!duel;rebuildWidgets();}).width(w).build());
l.addChild(Button.builder(Arenas.text("mode."+mode.name().toLowerCase(java.util.Locale.ROOT)),b->{mode=Arenas.Mode.values()[(mode.ordinal()+1)%Arenas.Mode.values().length];rebuildWidgets();}).width(w).build());
var rules=l.addChild(LinearLayout.horizontal().spacing(4));int third=(w-8)/3;
rules.addChild(Button.builder(Arenas.text("radius",radius),b->{radius=radius==24?48:radius==48?96:24;rebuildWidgets();}).width(third).build());
rules.addChild(Button.builder(Arenas.text("duration",seconds/60),b->{seconds=seconds==180?300:seconds==300?600:180;rebuildWidgets();}).width(third).build());
rules.addChild(Button.builder(Arenas.text(supplies?"supplies_on":"supplies_off"),b->{supplies=!supplies;rebuildWidgets();}).width(third).build());
line(l,w,Arenas.text("rules_help"));line(l,w,Arenas.text("currency_help"));
var v=ArenaClient.view;
if(v!=null)for(int i=0;i<v.slots().size();){var row=l.addChild(LinearLayout.horizontal().spacing(0));for(int col=0;col<9&&i<v.slots().size();col++,i++)row.addChild(new CurrencySlot(i));}
Component item=Component.translatable("item.minecraft.emerald");if(v!=null&&v.slots().contains(slot))item=v.items().get(v.slots().indexOf(slot)).getHoverName();line(l,w,Arenas.text("currency",item));
line(l,w,Arenas.text("entry_help"));var amount=l.addChild(new EditBox(font,w,20,Arenas.text("entry")));amount.setMaxLength(2);amount.setValue(entry);amount.setResponder(s->entry=s);
l.addChild(Button.builder(Arenas.text("create_confirm"),b->ArenaClient.send("create","","",0,slot,parse(entry),0,mode.name()+","+duel+","+supplies+","+radius+","+seconds)).width(w).build());
l.addChild(Button.builder(Arenas.text("back"),b->{creating=false;rebuildWidgets();}).width(w).build());
}
private void detail(LinearLayout l,int w,JsonObject m){
l.addChild(Button.builder(Arenas.text("back"),b->{message="";ArenaClient.send("refresh","","",0,-1,0,0,"");}).width(w).build());
line(l,w,Arenas.text("event",Arenas.text(flag(m,"duel")?"duel":"arena"),str(m,"name"),number(m,"size"),Arenas.text("phase."+str(m,"phase").toLowerCase(java.util.Locale.ROOT))));
line(l,w,Arenas.text("mode."+str(m,"mode").toLowerCase(java.util.Locale.ROOT)));
line(l,w,Arenas.text("location",str(m,"dimension"),number(m,"x"),number(m,"y"),number(m,"z"),number(m,"radius")));
line(l,w,Arenas.text("rules",number(m,"seconds")/60,Arenas.text(flag(m,"supplies")?"supplies_on":"supplies_off")));line(l,w,Arenas.text("rules_short"));
var item=ArenaClient.view.currency().getHoverName();line(l,w,Arenas.text("entry_price",number(m,"entry"),item));
l.addChild(new LiveStatus(w));
if(flag(m,"legacy"))line(l,w,Arenas.text("legacy"));
boolean open=str(m,"phase").equals("OPEN"),member=flag(m,"participant"),host=str(m,"host").equals(minecraft.player.getStringUUID()),legacy=flag(m,"legacy");
if(open&&!member&&!legacy)l.addChild(Button.builder(Arenas.text("join"),b->ArenaClient.action("join","",0,page())).width(w).build()).active=!flag(ArenaClient.data,"enrolled");
if(open&&member&&!legacy){
l.addChild(Button.builder(Arenas.text(flag(m,"ready")?"unready":"ready"),b->ArenaClient.action("ready","",0,page())).width(w).build());
if(host)l.addChild(Button.builder(Arenas.text("start"),b->ArenaClient.action("start","",0,page())).width(w).build());
}
if(member&&!legacy&&!str(m,"phase").equals("FINISHED"))l.addChild(Button.builder(Arenas.text(open?(host?"cancel_entry":"leave_entry"):"forfeit_button"),b->ArenaClient.action("leave","",0,page())).width(w).build());
var betsLine=new MultiLineTextWidget(Arenas.text(open?"bets_short":"bets_closed"),font).setMaxWidth(w);betsLine.setTooltip(Tooltip.create(Arenas.text("bets_help")));l.addChild(betsLine);
if(open&&!member){var amount=l.addChild(new EditBox(font,w,20,Arenas.text("bet_amount")));amount.setMaxLength(4);amount.setValue(bet);amount.setResponder(s->bet=s);}
if(m.has("entrants"))for(var value:m.getAsJsonArray("entrants")){var e=value.getAsJsonObject();
line(l,w,Arenas.text("entrant",str(e,"name"),Arenas.text(flag(e,"out")?"out":flag(e,"ready")?"ready":"waiting")));
if(!str(e,"pet").isEmpty()&&!str(m,"mode").equals("PLAYERS"))line(l,w,Component.literal(str(e,"pet")));
if(open&&!member)l.addChild(Button.builder(Arenas.text("bet_on",str(e,"name")),b->ArenaClient.action("bet",str(e,"id"),parse(bet),page())).width(w).build());
}
pages(l,w);
if(str(m,"phase").equals("FINISHED"))line(l,w,Arenas.text("winner",str(m,"winner").isEmpty()?Arenas.text("nobody"):Component.literal(str(m,"winner"))));
}
private int page(){return number(ArenaClient.data,"page");}
private void pages(LinearLayout l,int w){int total=number(ArenaClient.data,"total"),page=page();if(total<=ArenaNetwork.PAGE_SIZE)return;var row=l.addChild(LinearLayout.horizontal().spacing(4));row.addChild(Button.builder(Component.literal(""),b->ArenaClient.action("refresh","",0,page-1)).width(40).build()).active=page>0;row.addChild(new StringWidget(Arenas.text("page",page+1,(total+ArenaNetwork.PAGE_SIZE-1)/ArenaNetwork.PAGE_SIZE),font));row.addChild(Button.builder(Component.literal(""),b->ArenaClient.action("refresh","",0,page+1)).width(40).build()).active=(page+1)*ArenaNetwork.PAGE_SIZE<total;}
private void line(LinearLayout l,int w,Component text){l.addChild(new MultiLineTextWidget(text,font).setMaxWidth(w));}
private static int parse(String s){try{return Integer.parseInt(s);}catch(NumberFormatException e){return -1;}}
@Override public void tick(){super.tick();if(++ticks%80==0)ArenaClient.action("refresh","",0,page());}
@Override public void onClose(){ArenaClient.send("close","","",0,-1,0,0,"");super.onClose();}
@Override public boolean isPauseScreen(){return false;}
private final class LiveStatus extends AbstractWidget {
LiveStatus(int w){super(0,0,w,22,Arenas.text("status"));}
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int x,int y,float partial){if(!ArenaClient.data.has("match"))return;var m=ArenaClient.data.getAsJsonObject("match");g.text(font,Arenas.text("pots",str(m,"entryPot"),str(m,"betPot")),getX(),getY(),0xFFFFFFFF,false);g.text(font,Arenas.text("time",number(m,"remaining")),getX(),getY()+11,0xFFFFFF55,false);}
@Override protected void updateWidgetNarration(NarrationElementOutput out){defaultButtonNarrationText(out);}
}
private final class CurrencySlot extends AbstractWidget {
private final int index;
CurrencySlot(int index){super(0,0,20,20,Arenas.text("currency_help"));this.index=index;}
private ItemStack item(){return ArenaClient.view==null||index>=ArenaClient.view.items().size()?ItemStack.EMPTY:ArenaClient.view.items().get(index);}
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int mx,int my,float partial){int x=getX(),y=getY();g.fill(x,y,x+20,y+20,0xFF373737);g.fill(x+1,y+1,x+20,y+20,0xFFFFFFFF);g.fill(x+1,y+1,x+19,y+19,isHovered?0xFFB0B0B0:0xFF8B8B8B);g.item(item(),x+2,y+2);g.itemDecorations(font,item(),x+2,y+2);if(isHovered&&!item().isEmpty())g.setTooltipForNextFrame(font,Screen.getTooltipFromItem(minecraft,item()),item().getTooltipImage(),mx,my);}
@Override public boolean mouseClicked(MouseButtonEvent e,boolean twice){if(e.button()!=0||!isMouseOver(e.x(),e.y())||item().isEmpty())return false;slot=ArenaClient.view.slots().get(index);rebuildWidgets();return true;}
@Override protected void updateWidgetNarration(NarrationElementOutput out){defaultButtonNarrationText(out);}
}
}
@@ -55,6 +55,7 @@ public final class CompanionClient {
CompanionLocalState.readyTick=context.client().level==null?Long.MAX_VALUE:context.client().level.getGameTime()+packet.equipTicks();
if(message.equals("trade_done")||message.equals("quote_expired")||message.equals("price_changed"))choice=null;
if(context.client().gui.screen() instanceof CompanionChoiceScreen screen)screen.accept(message);
else if(context.client().gui.screen() instanceof FamiliarScreen screen)screen.accept();
else if(context.client().gui.screen() instanceof ProgressionScreen screen)screen.accept("");
else if(!message.isEmpty()&&context.client().player!=null)context.client().player.sendOverlayMessage(label(message));
}catch(RuntimeException error){record=null;CompanionLocalState.clear();message="storage_invalid";}
@@ -32,6 +32,8 @@ public final class CompanionTooltips {
boolean details=CompanionClient.studied||ProgressionClient.operator;
var extra=new java.util.ArrayList<Component>();
extra.add(rarity(stack).label().copy().withStyle(color(stack)));
if(fr.koka.sanctuary.cosmetics.FamiliarEntity.burnsInDaylight(stack))
extra.add(Component.translatable("sanctuary.familiar.sunlight.hint").withStyle(ChatFormatting.GOLD));
long xp=FamiliarClient.charge(stack);
if(xp>0)extra.add(fr.koka.sanctuary.familiar.FamiliarBattle.label("xp.tooltip",fr.koka.sanctuary.familiar.FamiliarLevels.level(xp),xp).copy().withStyle(ChatFormatting.GREEN));
if(fr.koka.sanctuary.cosmetics.FamiliarSize.value(stack)>0)extra.add(CompanionClient.label("tooltip_size",
@@ -39,10 +41,25 @@ public final class CompanionTooltips {
if(fr.koka.sanctuary.cosmetics.FamiliarSize.value(stack)>0)extra.add(Component.translatable(
fr.koka.sanctuary.cosmetics.FamiliarSize.colossal(stack)?"sanctuary.familiar.mount.hint":"sanctuary.familiar.carry.hint",
Math.round(fr.koka.sanctuary.cosmetics.FamiliarSize.carrySpeed(stack)*100)).withStyle(ChatFormatting.GRAY));
if(fr.koka.sanctuary.cosmetics.CarryGlide.lifts(stack))extra.add(Component.translatable("sanctuary.familiar.dragon.lift",
net.minecraft.client.Minecraft.getInstance().options.keyJump.getTranslatedKeyMessage()).withStyle(ChatFormatting.GRAY));
if(fr.koka.sanctuary.cosmetics.FamiliarMountTraits.preservesBreath(stack)&&fr.koka.sanctuary.cosmetics.FamiliarSize.colossal(stack))
extra.add(Component.translatable("sanctuary.familiar.nautilus.mount.hint").withStyle(ChatFormatting.AQUA));
if(fr.koka.sanctuary.cosmetics.FamiliarMountTraits.walksOnLava(stack)&&fr.koka.sanctuary.cosmetics.FamiliarSize.colossal(stack))
extra.add(Component.translatable("sanctuary.familiar.strider.mount.hint").withStyle(ChatFormatting.GOLD));
var profile=fr.koka.sanctuary.familiar.FamiliarCatalog.bundled().forEgg(stack);
if(profile!=null){
if(FamiliarClient.profile!=null&&FamiliarClient.profile.id().equals(profile.id()))profile=FamiliarClient.profile;
for(var technique:java.util.List.of(profile.initiative(),profile.signature())){
var initiative=profile.initiative();
var individual=fr.koka.sanctuary.familiar.FamiliarIdentity.get(stack);
if(individual!=null){
var personality=fr.koka.sanctuary.familiar.FamiliarPersonality.of(individual,profile.id());
initiative=personality.initiative(profile,false);
extra.add(fr.koka.sanctuary.familiar.FamiliarBattle.label("personality",personality.label()).copy().withStyle(ChatFormatting.GRAY));
}
if(fr.koka.sanctuary.cosmetics.FamiliarMotion.climbs(stack)&&fr.koka.sanctuary.cosmetics.FamiliarSize.colossal(stack))
extra.add(Component.translatable("sanctuary.familiar.climb.hint").withStyle(ChatFormatting.GRAY));
for(var technique:java.util.List.of(initiative,profile.signature())){
var text=FamiliarClient.name(technique).copy();
if(details)text.append("").append(FamiliarClient.help(technique));
var m=net.minecraft.client.Minecraft.getInstance();int width=Math.max(100,Math.min(280,m.getWindow().getGuiScaledWidth()-32));
@@ -23,7 +23,7 @@ public final class CycleClient {
}
public static void open(Screen parent,String tab) {
Minecraft.getInstance().gui.setScreen(new CycleFactionScreen(parent,tab));
refresh();
refresh();ProgressionClient.action("refresh");
}
public static void refresh() { send("refresh","","",view==null ? 0 : view.revision(),0); }
public static void send(String action,String subject,String value,int revision,int progressRevision) {
@@ -32,12 +32,12 @@ public final class CycleFactionScreen extends BlocodexFrame {
else if (confirmation!=null) initConfirmation(contents);
else {
var view=CycleClient.view;
text(contents,"summary",view.prestige()+1,view.prestige(),view.charges());
if (!factionPage()) text(contents,"summary",view.prestige()+1,view.prestige(),view.charges());
LinearLayout navigation=contents.addChild(LinearLayout.horizontal().spacing(4));
for (String target:new String[]{"cycle","faction","log"}) {
for (String target:factionPage()?new String[]{"faction","faction_log"}:new String[]{"cycle","log"}) {
Button button=navigation.addChild(Button.builder(CycleService.label(target),b -> {
tab=target;scroll=null;localMessage="";rebuildWidgets();
}).width((innerWidth-8)/3).build());button.active=!target.equals(tab);
tab=target;scroll=null;localMessage="";rebuildWidgets();CycleClient.refresh();
}).width((innerWidth-4)/2).build());button.active=!target.equals(tab);
}
if (!localMessage.isEmpty()) text(contents,localMessage);
else if (!view.message().isEmpty()) text(contents,view.message());
@@ -45,6 +45,7 @@ public final class CycleFactionScreen extends BlocodexFrame {
case "cycle" -> initCycle(contents);
case "faction" -> initFaction(contents);
case "log" -> initLog(contents);
case "faction_log" -> initFactionLog(contents);
default -> throw new IllegalArgumentException("Unknown cycle page");
}
}
@@ -54,6 +55,18 @@ public final class CycleFactionScreen extends BlocodexFrame {
scroll.setMinHeight(height);scroll.arrangeElements();scroll.setX(contentX);scroll.setY(contentTop);
scroll.visitWidgets(widget -> { addRenderableWidget(widget);if(widget instanceof AbstractScrollArea area) area.setScrollAmount(offset); });
}
private boolean factionPage() { return tab.equals("faction") || tab.equals("faction_log"); }
private void initFactionLog(LinearLayout contents) {
button(contents,CycleService.label("refresh"),CycleClient::refresh,true);
text(contents,"log_hint");
var events=CycleClient.view.history().stream().filter(e -> e.type().startsWith("sanctuary:faction_")).toList();
for (var event:events) logLine(contents,event);
if (events.isEmpty()) text(contents,"log_empty");
}
private void logLine(LinearLayout contents,CycleView.Log event) {
text(contents,"log_line",stamp(event.at()),CycleService.label("event."+event.type().substring("sanctuary:".length())+(event.type().equals("sanctuary:faction_create")&&"levels".equals(event.currency())?"_levels":""),
event.actor(),event.faction(),event.target(),event.amount(),event.capacity()));
}
private void text(LinearLayout contents,String key,Object... args) {
contents.addChild(new MultiLineTextWidget(CycleService.label(key,args),font).setMaxWidth(innerWidth));
}
@@ -68,6 +81,10 @@ public final class CycleFactionScreen extends BlocodexFrame {
var progress=ProgressionClient.progress;var config=CycleClient.view.config();
text(contents,"completion",6-progress.remaining().size());
for (String skill:ProgressRecord.SKILLS) text(contents,"skill",ProgressionService.label(skill),progress.rank(skill),ProgressRecord.cap(skill));
button(contents,CycleService.label("cycle_progress"),() -> {
if(parent instanceof ProgressionScreen)onClose();
else ProgressionClient.open(this,"progress");
},true);
text(contents,"xp_hint");
text(contents,"reward",config.reward());
text(contents,"retained");text(contents,"reset");text(contents,config.keepXp() ? "xp_kept" : "xp_reset");
@@ -83,8 +100,9 @@ public final class CycleFactionScreen extends BlocodexFrame {
if (faction==null) {
text(contents,"founding_path",config.reward());
text(contents,"creation_price",config.creationLevels(),config.initialCapacity());
edit(contents,"name",createName,32,v -> createName=v);
button(contents,CycleService.label("create"),() -> confirm("create","",createName,createName),minecraft.player!=null&&minecraft.player.experienceLevel>=config.creationLevels());
var name=edit(contents,"name",createName,32,v -> createName=v);
var create=button(contents,CycleService.label("create"),() -> confirm("create","",createName.strip(),createName.strip()),canCreate());
name.setResponder(v -> {createName=v;create.active=canCreate();});
} else {
literal(contents,Component.literal(faction.name()).withStyle(s -> s.withColor(faction.color())));
text(contents,"capacity",faction.members().size(),faction.capacity());
@@ -92,8 +110,9 @@ public final class CycleFactionScreen extends BlocodexFrame {
() -> confirm("expand","","",faction.name()),view.charges()>=config.expansionCost() && faction.capacity()<config.maxMembers());
boolean owner=faction.owner().equals(view.self());
if (owner) {
edit(contents,"invite_name",inviteName,36,v -> inviteName=v);
button(contents,CycleService.label("invite"),() -> send("invite",inviteName,""),faction.members().size()<faction.capacity());
var name=edit(contents,"invite_name",inviteName,36,v -> inviteName=v);
var invite=button(contents,CycleService.label("invite"),() -> send("invite",inviteName.strip(),""),canInvite());
name.setResponder(v -> {inviteName=v;invite.active=canInvite();});
}
for (var member:faction.members()) {
text(contents,member.id().equals(faction.owner()) ? "owner" : "member",member.name());
@@ -116,13 +135,33 @@ public final class CycleFactionScreen extends BlocodexFrame {
}
}
private void initLog(LinearLayout contents) {
button(contents,CycleService.label("refresh"),() -> {CycleClient.refresh();ProgressionClient.action("refresh");},true);
text(contents,"log_events");
text(contents,"log_hint");
for (var event:CycleClient.view.history()) {
text(contents,"log_line",stamp(event.at()),CycleService.label("event."+event.type().substring("sanctuary:".length())+(event.type().equals("sanctuary:faction_create")&&"levels".equals(event.currency())?"_levels":""),
event.actor(),event.faction(),event.target(),event.amount(),event.capacity()));
logLine(contents,event);
}
if (CycleClient.view.history().isEmpty()) text(contents,"log_empty");
text(contents,"log_purchases",ProgressionClient.progress.cycle()+1);
text(contents,"log_purchases_hint");
var history=ProgressionClient.progress.history();
for(var event:history.reversed()) {
boolean refund=event.target().startsWith("remove_");
literal(contents,ProgressionService.label(refund?"history_refund":"history_purchase",stamp(event.at()),
ProgressionService.label(refund?event.target().substring(7):event.target()),event.cost()));
}
if(history.isEmpty())text(contents,"log_purchases_empty");
literal(contents,ProgressionService.label("history_created",stamp(ProgressionClient.identity.createdAt())));
}
private boolean canCreate() {
return pending==0&&minecraft.player!=null&&minecraft.player.experienceLevel>=CycleClient.view.config().creationLevels()
&&CycleStore.validName(createName.strip());
}
private boolean canInvite() {
var faction=CycleClient.view.faction();
return pending==0&&!inviteName.isBlank()&&faction!=null&&faction.members().size()<faction.capacity();
}
public void refreshProgress() { if(confirmation==null&&pending==0)rebuildWidgets(); }
private static String stamp(String at) { return at.substring(0,10)+" "+at.substring(11,16)+" UTC"; }
private void confirm(String action,String subject,String value,String display) {
confirmation=new Confirmation(action,subject,value,display,CycleClient.view.revision(),ProgressionClient.progress.revision());
@@ -0,0 +1,41 @@
package fr.koka.sanctuary.client;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/** Sibling categories share one parent: Escape never walks through previously visited tabs. */
abstract class DiscoveryFrame extends BlocodexFrame {
private static final String[] CATEGORIES={"general","blocks","items","mobs","recipes"};
protected DiscoveryFrame(Screen parent) { super(parent); }
protected final void initDiscovery(String selected) {
initFrame("blocks");
int gap=4, buttonWidth=(contentWidth-gap*4)/5;
for (int i=0;i<CATEGORIES.length;i++) {
String category=CATEGORIES[i];
var button=addRenderableWidget(Button.builder(discoveryLabel(category),b -> open(category))
.bounds(contentX+i*(buttonWidth+gap),contentTop,buttonWidth,20).build());
button.active=!category.equals(selected);
}
contentTop+=26;
}
private void open(String category) {
minecraft.gui.setScreen(switch(category) {
case "blocks" -> new SanctuaryBlocodexScreen(parent);
case "recipes" -> new RecipeScreen(parent);
default -> new DiscoveryStatsScreen(parent,category);
});
}
public static Component discoveryLabel(String key,Object... args) {
return Component.translatable("sanctuary.discovery."+key,args);
}
static boolean canBrowseHistory() {
return ProgressionClient.operator || RecipeClient.ready && !RecipeClient.invalid
&& RecipeClient.knowledge!=null && RecipeClient.knowledge.learned();
}
}
@@ -0,0 +1,173 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.inventory.AccessoryAccess;
import fr.koka.sanctuary.inventory.SanctuaryOverflowAccess;
import java.util.*;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.network.protocol.game.ServerboundClientCommandPacket;
import net.minecraft.stats.*;
import net.minecraft.world.Container;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.item.*;
/** Read-only views of server-synchronized vanilla statistics, constrained by personal knowledge. */
public final class DiscoveryStatsScreen extends DiscoveryFrame {
private static final List<StatType<Item>> ITEM_STATS=List.of(Stats.ITEM_CRAFTED,Stats.ITEM_USED,
Stats.ITEM_BROKEN,Stats.ITEM_PICKED_UP,Stats.ITEM_DROPPED);
private final String category;
private ScrollableLayout scroll;
private EditBox search;
private String query="";
private boolean requested,ready,browseHistory,owned=true;
private int ticks;
private record Entry(Component name,ItemStack icon,List<Component> details) {}
public DiscoveryStatsScreen(Screen parent,String category) {super(parent);this.category=category;}
@Override protected void init() {
initDiscovery(category);
browseHistory=canBrowseHistory();
if (!browseHistory) owned=true;
int controlsY=contentTop;
search=addRenderableWidget(new EditBox(font,contentX,controlsY,contentWidth-104,20,discoveryLabel("search")));
search.setHint(discoveryLabel("search"));search.setMaxLength(128);search.setValue(query);
search.setResponder(value -> {query=value;scroll=null;refreshContents();});
var refresh=addRenderableWidget(Button.builder(Component.translatable("sanctuary.blocodex.refresh"),b -> request())
.bounds(contentX+contentWidth-100,controlsY,100,20).build());
refresh.active=ticks>=20 || !requested;
int top=controlsY+26;
if(category.equals("items")) {
var filter=addRenderableWidget(Button.builder(discoveryLabel(owned?"owned":"known"),b -> {owned=!owned;scroll=null;rebuildWidgets();})
.bounds(contentX,top,contentWidth,20).build());
filter.active=browseHistory;
filter.setTooltip(Tooltip.create(discoveryLabel(browseHistory?"item_filter":"catalogue_required")));
top+=26;
}
var contents=LinearLayout.vertical().spacing(6);
int inner=contentWidth-18;
if(!ready) contents.addChild(new MultiLineTextWidget(discoveryLabel(ticks>=120?"unavailable":"loading"),font).setMaxWidth(inner));
else {
if(category.equals("mobs"))contents.addChild(new MultiLineTextWidget(discoveryLabel("mobs_hint"),font).setMaxWidth(inner));
List<Entry> entries=entries();
contents.addChild(new StringWidget(discoveryLabel("count",entries.size()),font));
for(var entry:entries) {
if(category.equals("general"))contents.addChild(new MultiLineTextWidget(entry.details().getFirst(),font).setMaxWidth(inner));
else {
var row=contents.addChild(LinearLayout.horizontal().spacing(4));
row.defaultCellSetting().alignVerticallyMiddle();
if(!entry.icon().isEmpty())row.addChild(new ItemIcon(entry.icon()));
row.addChild(Button.builder(entry.name(),b -> minecraft.gui.setScreen(new DetailScreen(this,entry)))
.width(inner-(entry.icon().isEmpty()?0:22)).tooltip(Tooltip.create(entry.name())).build());
}
}
if(entries.isEmpty())contents.addChild(new MultiLineTextWidget(discoveryLabel("empty"),font).setMaxWidth(inner));
}
double offset=scroll==null?0:scroll.getScrollAmount();
scroll=addScroll(contents,top,offset);
if(!requested)request();
}
private void request() {
if(requested && ticks<20 || minecraft.getConnection()==null)return;
requested=true;ticks=0;
minecraft.getConnection().send(new ServerboundClientCommandPacket(ServerboundClientCommandPacket.Action.REQUEST_STATS));
}
/** Called after vanilla has applied the authoritative statistics packet. */
public void statsUpdated() {ready=true;refreshContents();}
public void knowledgeUpdated() {refreshContents();}
private void refreshContents() {
boolean editing=search!=null&&search.isFocused();
int cursor=search==null?0:search.getCursorPosition();
rebuildWidgets();
if(editing) {search.moveCursorTo(cursor,false);setFocused(search);}
}
@Override public void tick() {
ticks++;
if(browseHistory!=canBrowseHistory() || ticks==20 || !ready&&ticks==120)refreshContents();
}
private List<Entry> entries() {
var stats=minecraft.player.getStats();
var entries=new ArrayList<Entry>();
if(category.equals("general")) {
for(var id:BuiltInRegistries.CUSTOM_STAT) {
var stat=Stats.CUSTOM.get(id);
var name=Component.translatable("stat."+id.toString().replace(':','.'));
entries.add(new Entry(name,ItemStack.EMPTY,List.of(line(name,stat.format(stats.getValue(stat))))));
}
} else if(category.equals("items")) {
Set<Item> carried=carried();
for(var item:BuiltInRegistries.ITEM) {
if(item==Items.AIR || (owned?!carried.contains(item):!known(item,carried)))continue;
var stack=new ItemStack(item);
var detail=new ArrayList<Component>();
for(var type:ITEM_STATS)detail.add(line(type.getDisplayName(),type.get(item).format(stats.getValue(type,item))));
entries.add(new Entry(stack.getHoverName(),stack,detail));
}
} else if(category.equals("mobs")) {
for(EntityType<?> type:BuiltInRegistries.ENTITY_TYPE) {
int killed=stats.getValue(Stats.ENTITY_KILLED,type),deaths=stats.getValue(Stats.ENTITY_KILLED_BY,type);
if(killed==0&&deaths==0)continue;
var icon=SpawnEggItem.byId(type).map(ItemStack::new).orElse(ItemStack.EMPTY);
entries.add(new Entry(type.getDescription(),icon,List.of(line(Stats.ENTITY_KILLED.getDisplayName(),Integer.toString(killed)),
line(Stats.ENTITY_KILLED_BY.getDisplayName(),Integer.toString(deaths)))));
}
}
String filter=query.strip().toLowerCase(Locale.ROOT);
return entries.stream().filter(e -> e.name().getString().toLowerCase(Locale.ROOT).contains(filter))
.sorted(Comparator.comparing(e -> e.name().getString(),String.CASE_INSENSITIVE_ORDER)).toList();
}
private boolean known(Item item,Set<Item> carried) {
if(carried.contains(item))return true;
if(RecipeClient.ready&&!RecipeClient.invalid&&RecipeClient.knowledge!=null
&&RecipeClient.knowledge.items.contains(BuiltInRegistries.ITEM.getKey(item).toString()))return true;
return ITEM_STATS.stream().anyMatch(type -> minecraft.player.getStats().getValue(type,item)>0);
}
private Set<Item> carried() {
Set<Item> items=new HashSet<>();
for(Container container:List.of(minecraft.player.getInventory(),SanctuaryOverflowAccess.of(minecraft.player),AccessoryAccess.of(minecraft.player)))
for(int i=0;i<container.getContainerSize();i++)if(!container.getItem(i).isEmpty())items.add(container.getItem(i).getItem());
return items;
}
private static Component line(Component name,String value) {return discoveryLabel("stat",name,value);}
private ScrollableLayout addScroll(LinearLayout contents,int top,double offset) {
int height=Math.max(20,contentBottom-top);
var result=new ScrollableLayout(minecraft,contents,height,ScrollableLayout.ReserveStrategy.RIGHT);
result.setMinHeight(height);result.arrangeElements();result.setX(contentX);result.setY(top);
result.visitWidgets(widget -> {addRenderableWidget(widget);if(widget instanceof AbstractScrollArea area)area.setScrollAmount(offset);});
return result;
}
private static final class ItemIcon extends AbstractWidget {
private final ItemStack stack;
ItemIcon(ItemStack stack) {super(0,0,18,20,stack.getHoverName());this.stack=stack;}
@Override protected void extractWidgetRenderState(net.minecraft.client.gui.GuiGraphicsExtractor graphics,int x,int y,float delta) {
graphics.item(stack,getX()+1,getY()+2);
}
@Override protected void updateWidgetNarration(net.minecraft.client.gui.narration.NarrationElementOutput output) {defaultButtonNarrationText(output);}
}
private static final class DetailScreen extends BlocodexFrame {
private final Entry entry;
DetailScreen(Screen parent,Entry entry) {super(parent);this.entry=entry;}
@Override protected void init() {
initFrame("blocks",entry.name());
var contents=LinearLayout.vertical().spacing(10);
contents.addChild(new ItemIcon(entry.icon()));
for(var detail:entry.details())contents.addChild(new MultiLineTextWidget(detail,font).setMaxWidth(contentWidth-18));
var list=new ScrollableLayout(minecraft,contents,Math.max(20,contentBottom-contentTop),ScrollableLayout.ReserveStrategy.RIGHT);
list.arrangeElements();list.setX(contentX);list.setY(contentTop);list.visitWidgets(this::addRenderableWidget);
}
}
}
@@ -14,9 +14,13 @@ public final class FamiliarClient {
public static FamiliarProfile profile;public static FamiliarBond bond;public static FamiliarNetwork.View view;
public static FamiliarDuelNetwork.View duel;public static long duelReceived;
private static long received;private static KeyMapping orders;
private static int heldTicks;
private static boolean waitingForRelease;
private FamiliarClient(){}
public static void register(KeyMapping.Category category){
ArenaClient.register();
orders=KeyMappingHelper.registerKeyMapping(new KeyMapping("sanctuary.familiar.orders_key",InputConstants.Type.KEYBOARD,InputConstants.KEY_H,category));
net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents.CLIENT_STARTED.register(FamiliarClient::migrateOrdersKey);
ClientConfigurationNetworking.registerGlobalReceiver(FamiliarNetwork.Capability.TYPE,(packet,context)->{});
ClientPlayNetworking.registerGlobalReceiver(FamiliarDuelNetwork.View.TYPE,(packet,context)->context.client().execute(()->{
duel=packet;duelReceived=System.nanoTime();
@@ -30,17 +34,38 @@ public final class FamiliarClient {
view=packet;received=System.nanoTime();CompanionLocalState.combatControls=profile!=null&&!packet.work();
if(context.client().gui.screen() instanceof FamiliarScreen screen)screen.accept();
else if(context.client().gui.screen() instanceof FamiliarExperienceScreen screen)screen.accept();
else if(!packet.message().isEmpty()&&context.client().player!=null)context.client().player.sendOverlayMessage(FamiliarBattle.label(packet.message()));
else if(!packet.message().isEmpty()&&context.client().player!=null)context.client().player.sendOverlayMessage(feedback(packet.message()));
}catch(RuntimeException error){reset();}
}));
ClientPlayConnectionEvents.DISCONNECT.register((handler,client)->reset());
ClientConfigurationConnectionEvents.INIT.register((handler,client)->reset());
ClientTickEvents.END_CLIENT_TICK.register(client->{
boolean pressed=false;while(orders.consumeClick())pressed=true;
if(pressed&&client.player!=null&&ProgressionClient.active()&&client.gui.screen()==null){action("refresh");client.gui.setScreen(new FamiliarOrdersScreen());}
boolean down=orders.isDown();
if(client.player==null||!client.player.isAlive()||!ProgressionClient.active()||client.gui.screen()!=null||!client.isWindowActive()){heldTicks=0;waitingForRelease=down;return;}
if(waitingForRelease){if(!down)waitingForRelease=false;return;}
if(pressed&&heldTicks==0)heldTicks=1;
if(heldTicks==0)return;
if(!down){heldTicks=0;action("quick");return;}
if(++heldTicks>=8){
heldTicks=0;waitingForRelease=true;action("refresh");client.gui.setScreen(new FamiliarOrdersScreen());
}
});
}
public static void reset(){profile=null;bond=null;view=null;duel=null;received=0;duelReceived=0;CompanionLocalState.combatControls=false;}
private static void migrateOrdersKey(Minecraft client){
var marker=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-familiar-key-v2.txt");
if(java.nio.file.Files.exists(marker))return;
try{
// Undo the beta.067/.068 default once, including installations already migrated to F.
// Preserve other custom keys and any deliberate remapping after this migration.
if(orders.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_F))){
orders.setKey(orders.getDefaultKey());KeyMapping.resetMapping();client.options.save();
}
java.nio.file.Files.createDirectories(marker.getParent());
java.nio.file.Files.writeString(marker,"beta.069\n",java.nio.file.StandardOpenOption.CREATE_NEW);
}catch(java.io.IOException error){fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Cannot record familiar shortcut migration",error);}
}
public static void reset(){profile=null;bond=null;view=null;duel=null;received=0;duelReceived=0;heldTicks=0;waitingForRelease=false;CompanionLocalState.combatControls=false;}
public static void duelAction(String action,int slot,int count){if(duel!=null&&ClientPlayNetworking.canSend(FamiliarDuelNetwork.Request.TYPE))ClientPlayNetworking.send(new FamiliarDuelNetwork.Request(duel.id(),duel.revision(),action,slot,count));}
public static boolean french(){return Minecraft.getInstance().getLanguageManager().getSelected().startsWith("fr");}
public static Component text(String fr,String en){return Component.literal(french()?fr:en);}
@@ -52,5 +77,6 @@ public final class FamiliarClient {
public static void open(Screen parent){action("refresh");Minecraft.getInstance().gui.setScreen(new FamiliarScreen(parent));}
public static long now(){return view==null?0:view.now()+(System.nanoTime()-received)/1000000;}
public static int remaining(long until){return (int)Math.max(0,(until-now()+999)/1000);}
public static Component feedback(String message){return message.startsWith("work.")?CompanionClient.label(message.substring(5)):FamiliarBattle.label(message);}
public static Component controls(){return FamiliarBattle.label("controls",orders.getTranslatedKeyMessage(),CompanionClient.controls());}
}
@@ -17,6 +17,7 @@ public final class FamiliarDuelScreen extends BlocodexFrame {
@Override protected void init(){
initFrame("identity");int w=contentWidth-28;var l=LinearLayout.vertical().spacing(5);var v=FamiliarClient.duel;
l.addChild(new StringWidget(FamiliarBattle.label("duel.title"),font));
l.addChild(Button.builder(fr.koka.sanctuary.arena.Arenas.text("title"),b->ArenaClient.open(this)).width(w).build());
if(v==null||v.phase().equals("NONE")){
l.addChild(new MultiLineTextWidget(FamiliarBattle.label("duel.invite_help"),font).setMaxWidth(w));
l.addChild(Button.builder(FamiliarBattle.label("duel.invite"),b->FamiliarClient.action("duel_invite")).width(w).build());
@@ -92,6 +92,8 @@ public final class FamiliarRenderer extends EntityRenderer<FamiliarEntity,Famili
if(fr.koka.sanctuary.cosmetics.CarryService.carried(pet))CarryPose.apply(appearance);
state.size=FamiliarSize.renderScale(mob,pet.egg());
float height=mob.getBbHeight()*state.size;
// The dispatcher draws fire from the real entity state, outside the native proxy.
state.boundingBoxWidth=mob.getBbWidth()*state.size;state.boundingBoxHeight=height;
state.visualRadius=Math.max(mob.getBbWidth()*state.size,height)*.5F;
if(state.nameTagAttachment!=null)state.nameTagAttachment=new net.minecraft.world.phys.Vec3(0,height+.2,0);
}
@@ -100,8 +102,10 @@ public final class FamiliarRenderer extends EntityRenderer<FamiliarEntity,Famili
boolean first=visual.tick<0;visual.tick=pet.tickCount;Mob mob=visual.mob;
boolean moving=pet.getDeltaMovement().lengthSqr()>.0005,water=pet.isInWater(),flying=pet.motion().flies();
if(mob instanceof net.minecraft.world.entity.boss.enderdragon.EnderDragon dragon){
if(first)for(int i=0;i<64;i++)dragon.flightHistory.record(pet.getY(),pet.getYRot());
dragon.flightHistory.record(pet.getY(),pet.getYRot());dragon.oFlapTime=dragon.flapTime;
// Native dragon geometry faces the opposite axis to ordinary living models.
float dragonYaw=pet.getYRot()+180;
if(first)for(int i=0;i<64;i++)dragon.flightHistory.record(pet.getY(),dragonYaw);
dragon.flightHistory.record(pet.getY(),dragonYaw);dragon.oFlapTime=dragon.flapTime;
dragon.flapTime+=moving?.08F:.12F;
}
if(mob instanceof net.minecraft.world.entity.animal.parrot.Parrot parrot){
@@ -14,13 +14,13 @@ import net.minecraft.resources.Identifier;
/** Orders and equipped technique, using Minecraft widgets and the existing inhabitant frame. */
public final class FamiliarScreen extends BlocodexFrame {
private String structure="";
private ScrollableLayout scroll;
public FamiliarScreen(Screen parent){super(parent);}
public void accept(){String next=key();if(!next.equals(structure))rebuildWidgets();}
private String key(){return FamiliarClient.profile==null?"":FamiliarClient.profile.id()+"/"+(FamiliarClient.bond==null?"":FamiliarClient.bond.variant()+"/"+FamiliarClient.bond.recalled()+"/"+FamiliarClient.bond.learned())+"/"+(FamiliarClient.view==null?"":FamiliarClient.view.work()+"/"+FamiliarClient.view.fighting()+"/"+FamiliarClient.view.message());}
private String key(){return FamiliarClient.profile==null?"":FamiliarClient.profile.id()+"/"+(FamiliarClient.bond==null?"":FamiliarClient.bond.id()+"/"+FamiliarClient.bond.variant()+"/"+FamiliarClient.bond.recalled()+"/"+FamiliarClient.bond.learned())+"/"+(FamiliarClient.view==null?"":FamiliarClient.view.work()+"/"+FamiliarClient.view.fighting()+"/"+FamiliarClient.view.message())+"/"+(CompanionClient.record!=null&&CompanionClient.record.unlocked())+"/"+CompanionClient.studied+"/"+ProgressionClient.operator;}
@Override protected void init(){
initFrame("identity");structure=key();var layout=LinearLayout.vertical().spacing(6);int width=contentWidth-28;
initFrame("identity",FamiliarBattle.label("title"));structure=key();var layout=LinearLayout.vertical().spacing(6);int width=contentWidth-28;
var profile=FamiliarClient.profile;var bond=FamiliarClient.bond;
layout.addChild(new StringWidget(FamiliarBattle.label("title"),font));
if(profile==null||bond==null)layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("unavailable"),font).setMaxWidth(width));
else{
var egg=AccessoryAccess.of(minecraft.player).getItem(AccessoryContainer.FAMILIAR);
@@ -29,6 +29,9 @@ public final class FamiliarScreen extends BlocodexFrame {
layout.addChild(new Health(width));
layout.addChild(Button.builder(FamiliarBattle.label("xp.title"),b->minecraft.gui.setScreen(new FamiliarExperienceScreen(this))).width(width).build());
layout.addChild(new MultiLineTextWidget(FamiliarClient.text(profile.roleFr(),profile.roleEn()),font).setMaxWidth(width));
var personality=FamiliarPersonality.of(bond);
layout.addChild(new StringWidget(FamiliarBattle.label("personality",personality.label()),font));
layout.addChild(new MultiLineTextWidget(personality.help(),font).setMaxWidth(width));
var commands=layout.addChild(LinearLayout.vertical().spacing(3));
var first=commands.addChild(LinearLayout.horizontal().spacing(4));
int half=(width-4)/2;
@@ -37,28 +40,41 @@ public final class FamiliarScreen extends BlocodexFrame {
second.addChild(button("protect",half,true));second.addChild(button("recall",half,true));
layout.addChild(Button.builder(FamiliarBattle.label("duel.title"),b->FamiliarClient.action("duel_open")).width(width).build());
layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("orders_help"),font).setMaxWidth(width));
boolean workMode=FamiliarClient.view!=null&&FamiliarClient.view.work();
var selected=bond.variant()?profile.variant():profile.signature();
var type=CompanionTooltips.type(egg);
Component workName=CompanionClient.label(type.id()+".active_name");
layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("selected_power",workMode?workName:FamiliarClient.name(selected)),font).setMaxWidth(width));
layout.addChild(new StringWidget(FamiliarBattle.label("technique"),font));
var technique=layout.addChild(LinearLayout.horizontal().spacing(4));
boolean combat=FamiliarClient.view!=null&&FamiliarClient.view.fighting();
var signature=technique.addChild(Button.builder(FamiliarClient.name(profile.signature()),b->FamiliarClient.action("signature")).width(half).build());signature.active=!combat;
var signature=technique.addChild(Button.builder(FamiliarClient.name(profile.signature()),b->FamiliarClient.action("signature")).width(half).build());signature.active=!combat&&(workMode||bond.variant());
var variant=technique.addChild(Button.builder(FamiliarClient.name(profile.variant()),b->FamiliarClient.action("variant")).width(half).build());
variant.active=!combat&&bond.learned()&&CompanionClient.record!=null&&CompanionClient.record.unlocked();
var selected=bond.variant()?profile.variant():profile.signature();
layout.addChild(new MultiLineTextWidget(FamiliarClient.help(selected),font).setMaxWidth(width));
layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("technique_stats",selected.windup()/20F,selected.cooldown()/20F),font).setMaxWidth(width));
variant.active=!combat&&(workMode||!bond.variant())&&bond.learned()&&CompanionClient.record!=null&&CompanionClient.record.unlocked();
var work=layout.addChild(Button.builder(FamiliarBattle.label(workMode?"work_selected":"work"),b->FamiliarClient.action("work")).width(width).build());
boolean unlocked=ProgressionClient.operator||(CompanionClient.record!=null&&CompanionClient.record.unlocked());
work.active=!combat&&!workMode&&unlocked;
work.setTooltip(Tooltip.create(FamiliarBattle.label(unlocked?"work_help":"work_locked",CompanionClient.controls())));
if(!unlocked)layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("work_locked"),font).setMaxWidth(width));
if(workMode){
layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("work_help",CompanionClient.controls()),font).setMaxWidth(width));
if(CompanionClient.studied)layout.addChild(new MultiLineTextWidget(CompanionClient.label(type.id()+".active"),font).setMaxWidth(width));
}else{
layout.addChild(new MultiLineTextWidget(FamiliarClient.help(selected),font).setMaxWidth(width));
layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("technique_stats",selected.windup()/20F,selected.cooldown()/20F),font).setMaxWidth(width));
}
if(!bond.learned())layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("learning",bond.memories().containsKey("signature")?"":"·",bond.memories().containsKey("defence")?"":"·",bond.memories().containsKey("journey")?"":"·"),font).setMaxWidth(width));
var work=layout.addChild(button("work",width,false));work.active=!combat;
layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("work_help"),font).setMaxWidth(width));
layout.addChild(button("care",width,false));
layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("care_help"),font).setMaxWidth(width));
layout.addChild(new StringWidget(FamiliarBattle.label("memories"),font));
for(var entry:new java.util.TreeMap<>(bond.memories()).entrySet())layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("memory."+entry.getKey(),entry.getValue()),font).setMaxWidth(width));
layout.addChild(new MultiLineTextWidget(FamiliarBattle.label("temperament."+bond.temperament()),font).setMaxWidth(width));
}
if(FamiliarClient.view!=null&&!FamiliarClient.view.message().isEmpty())layout.addChild(new MultiLineTextWidget(FamiliarBattle.label(FamiliarClient.view.message()),font).setMaxWidth(width));
if(FamiliarClient.view!=null&&!FamiliarClient.view.message().isEmpty())layout.addChild(new MultiLineTextWidget(FamiliarClient.feedback(FamiliarClient.view.message()),font).setMaxWidth(width));
layout.addChild(new MultiLineTextWidget(FamiliarClient.controls(),font).setMaxWidth(width));
var scroll=new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop),ScrollableLayout.ReserveStrategy.RIGHT);
scroll.setPosition(contentX+8,contentTop);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
double offset=scroll==null?0:scroll.getScrollAmount();
scroll=new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop),ScrollableLayout.ReserveStrategy.RIGHT);
scroll.setPosition(contentX+8,contentTop);scroll.arrangeElements();scroll.visitWidgets(widget->{addRenderableWidget(widget);if(widget instanceof AbstractScrollArea area)area.setScrollAmount(offset);});
}
private Button button(String action,int width,boolean close){return Button.builder(FamiliarBattle.label(action),b->{FamiliarClient.action(action);if(close)minecraft.gui.setScreen(null);}).width(width).build();}
@Override public boolean isPauseScreen(){return false;}
@@ -0,0 +1,79 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.multiblock.FourneauAppearance;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Predicate;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.*;
import net.fabricmc.fabric.api.client.networking.v1.*;
import net.fabricmc.fabric.api.client.model.loading.v1.*;
import net.fabricmc.fabric.api.client.model.loading.v1.wrapper.WrapperBlockStateModel;
import net.fabricmc.fabric.api.client.renderer.v1.mesh.QuadEmitter;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.block.BlockAndTintGetter;
import net.minecraft.client.renderer.block.dispatch.BlockStateModel;
import net.minecraft.client.resources.model.sprite.Material;
import net.minecraft.core.*;
import net.minecraft.resources.Identifier;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
/** Immutable chunk snapshots can be read safely by section mesh workers. */
public final class FourneauModels {
private static volatile Map<Long,Map<BlockPos,Identifier>> chunks=Map.of();
private static final List<Identifier> MODELS=new ArrayList<>();
static {for(int y=0;y<3;y++)for(int z=0;z<3;z++)for(int x=0;x<3;x++)MODELS.add(caskId(x,y,z));for(var d:Direction.Plane.HORIZONTAL)for(int y=0;y<3;y++)for(int z=0;z<3;z++)for(int x=0;x<3;x++)MODELS.add(id(d,x,y,z));}
private static Identifier id(Direction d,int x,int y,int z){return SanctuaryMod.id("block/fourneau/"+d.getName()+"_"+x+"_"+y+"_"+z);}
private static Identifier caskId(int x,int y,int z){return SanctuaryMod.id("block/fut/"+x+"_"+y+"_"+z);}
public static Identifier modelAt(BlockPos pos){var cells=chunks.get(ChunkPos.pack(pos));return cells==null?null:cells.get(pos);}
public static void register(){
ClientPlayNetworking.registerGlobalReceiver(FourneauAppearance.Update.TYPE,(p,c)->c.client().execute(()->apply(p)));
ClientPlayNetworking.registerGlobalReceiver(FourneauAppearance.CaskUpdate.TYPE,(p,c)->c.client().execute(()->apply(p.view(),false)));
ClientPlayConnectionEvents.DISCONNECT.register((h,c)->chunks=Map.of());
ClientLevelEvents.AFTER_CLIENT_LEVEL_CHANGE.register((m,l)->chunks=Map.of());
ClientChunkEvents.CHUNK_UNLOAD.register((l,c)->{var next=new HashMap<>(chunks);next.remove(c.getPos().pack());chunks=Map.copyOf(next);});
ModelLoadingPlugin.register(context->{
var sources=new HashMap<Identifier,SimpleUnbakedExtraModel<BlockStateModel>>();
for(var id:MODELS){var source=SimpleUnbakedExtraModel.blockStateModel(id);sources.put(id,source);context.addModel(ExtraModelKey.create(id::toString),source);}
// Shared by all furnace states, rebuilt with the resource pack on each reload.
var baked=new ConcurrentHashMap<Identifier,BlockStateModel>();
context.modifyBlockModelAfterBake().register((original,ctx)->{
if(!ctx.state().is(Blocks.FURNACE)&&!ctx.state().is(Blocks.BARREL))return original;
for(var entry:sources.entrySet())baked.computeIfAbsent(entry.getKey(),key->entry.getValue().bake(ctx.baker()));
return new FacadeModel(original,Map.copyOf(baked));
});
});
}
public static void apply(FourneauAppearance.Update p){apply(p,true);}
private static void apply(FourneauAppearance.Update p,boolean furnace){
var m=Minecraft.getInstance();if(m.level==null||!m.level.dimension().identifier().equals(p.dimension()))return;
long key=p.chunk().pack();var previous=chunks.getOrDefault(key,Map.of());var cells=new HashMap<>(previous);
var dirty=new HashSet<BlockPos>();
if(p.action()==FourneauAppearance.Action.CLEAR_CHUNK){dirty.addAll(cells.keySet());cells.clear();}
else if(p.facing().getAxis().isHorizontal())for(int y=0;y<3;y++)for(int z=0;z<3;z++)for(int x=0;x<3;x++){
var pos=p.origin().offset(x,y,z);if(ChunkPos.pack(pos)!=key)continue;
var id=p.action()==FourneauAppearance.Action.PUT?(furnace?id(p.facing(),x,y,z):caskId(x,y,z)):null;
var before=id==null?cells.remove(pos):cells.put(pos,id);if(!Objects.equals(before,id))dirty.add(pos);
}
var next=new HashMap<>(chunks);if(cells.isEmpty())next.remove(key);else next.put(key,Map.copyOf(cells));chunks=Map.copyOf(next);
var sections=new HashSet<SectionPos>();for(var pos:dirty)sections.add(SectionPos.of(pos));
for(var section:sections)m.levelExtractor.setSectionDirty(section.x(),section.y(),section.z());
}
private static final class FacadeModel extends WrapperBlockStateModel {
private final Map<Identifier,BlockStateModel> tiled;
FacadeModel(BlockStateModel original,Map<Identifier,BlockStateModel> tiled){super(original);this.tiled=tiled;}
private BlockStateModel select(BlockPos pos){var id=modelAt(pos);return id==null?wrapped:tiled.getOrDefault(id,wrapped);}
@Override public void emitQuads(QuadEmitter emitter,BlockAndTintGetter view,BlockPos pos,BlockState state,RandomSource random,Predicate<Direction> cull){
select(pos).emitQuads(emitter,view,pos,state,random,cull);
}
@Override public Object createGeometryKey(BlockAndTintGetter view,BlockPos pos,BlockState state,RandomSource random){
var model=select(pos);return model==wrapped?wrapped.createGeometryKey(view,pos,state,random):model;
}
@Override public Material.Baked particleMaterial(BlockAndTintGetter view,BlockPos pos,BlockState state){return select(pos).particleMaterial(view,pos,state);}
@Override public int materialFlags(BlockAndTintGetter view,BlockPos pos,BlockState state,RandomSource random){return select(pos).materialFlags(view,pos,state,random);}
}
private FourneauModels(){}
}
@@ -4,6 +4,7 @@ import fr.koka.sanctuary.cosmetics.HeadCosmeticData;
import java.util.WeakHashMap;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.block.BlockModelResolver;
import net.minecraft.client.renderer.block.BlockModelSet;
import net.minecraft.client.renderer.block.model.BlockDisplayContext;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.entity.EntityRendererProvider;
@@ -25,6 +26,7 @@ import net.minecraft.world.level.block.state.properties.*;
public final class HeadCosmeticModels {
private final BlockModelResolver blocks;
private final ItemModelResolver items;
private BlockModelSet placedBlocks;
private final BlockDisplayContext display=BlockDisplayContext.create();
private final WeakHashMap<LivingEntity,Visual> visuals=new WeakHashMap<>();
private static final class Visual {
@@ -116,7 +118,14 @@ public final class HeadCosmeticModels {
head.centerX=-head.secondX*.5F;head.centerZ=-head.secondZ*.5F;
blocks.update(head.second,state.setValue(AbstractBedBlock.PART,BedPart.HEAD),display);
}
blocks.update(head.block,state,display);
if(head.blockEntity!=null && !(head.blockEntity instanceof net.minecraft.client.renderer.blockentity.state.SignRenderState)) {
// Isolated block models also include a static chest lid, bell, book, etc.
// A live block entity already draws those: retain only the placed block's
// geometry here (bell support, enchanting table base, campfire...), with its tints.
if(placedBlocks==null)placedBlocks=new BlockModelSet(mc.getModelManager().getBlockStateModelSet(),java.util.Map.of(),mc.getBlockColors());
placedBlocks.get(state).update(head.block,state,display,42L);
head.block.blockLightCoords=state.emissiveRendering()?15728880:net.minecraft.util.LightCoordsUtil.pack(state.getLightEmission(),0);
} else blocks.update(head.block,state,display);
} else items.updateForLiving(head.item,stack,ItemDisplayContext.HEAD,player);
}
private static EntityType<?> boatType(ItemStack stack) {
@@ -67,6 +67,9 @@ public final class HelloWorldScreen extends Screen {
if(familiarName!=null)familiarName.setEditable(!waiting);
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){
g.fill(0,0,width,height,IntroScreen.WELCOME_BACKGROUND);
}
@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);
@@ -34,7 +34,7 @@ public final class IntroClient {
skipped -> {
if(!skipped) IntroSpawnFlash.begin();
ClientConfigurationNetworking.send(new ProgressionPayloads.IntroFinished());
}));
},context.client().gui.screen() instanceof HelloWorldScreen));
}));
ClientPlayNetworking.registerGlobalReceiver(ProgressionPayloads.IntroPreview.TYPE,(payload,context) ->
context.client().execute(()->previewRequested=true));
@@ -20,11 +20,14 @@ import com.mojang.blaze3d.platform.InputConstants;
/** First apparition lives entirely in configuration: no player can fall or receive damage. */
public final class IntroScreen extends Screen {
static final int WELCOME_BACKGROUND=0xFF171717;
static final double WELCOME_FADE_SECONDS=1.0;
private static final FontDescription GALACTIC = new FontDescription.Resource(Identifier.withDefaultNamespace("alt"));
private final String glyphs;
private final UUID uuid;
private final java.util.function.Consumer<Boolean> complete;
private final LongSupplier clock;
private final double welcomeFade;
private final List<ApparitionSound> sounds = new ArrayList<>();
private long started=-1;
private int nextBeat;
@@ -34,10 +37,17 @@ public final class IntroScreen extends Screen {
public IntroScreen(UUID uuid,java.util.function.Consumer<Boolean> complete) {
this(uuid,complete,System::nanoTime);
}
public IntroScreen(UUID uuid,java.util.function.Consumer<Boolean> complete,boolean fromWelcome) {
this(uuid,complete,System::nanoTime,fromWelcome);
}
// Injectable monotonic clock for native rendering checks, never a gameplay setting.
IntroScreen(UUID uuid,java.util.function.Consumer<Boolean> complete,LongSupplier clock) {
this(uuid,complete,clock,false);
}
IntroScreen(UUID uuid,java.util.function.Consumer<Boolean> complete,LongSupplier clock,boolean fromWelcome) {
super(Component.translatable("sanctuary.intro.title"));
this.uuid=uuid;this.glyphs=IntroSequence.encode(uuid);this.complete=complete;this.clock=clock;
this.welcomeFade=fromWelcome?WELCOME_FADE_SECONDS:0;
}
public UUID uuid() { return uuid; }
boolean isFinished() { return finished; }
@@ -46,7 +56,8 @@ public final class IntroScreen extends Screen {
protected void init() {
if (started < 0) {started=clock.getAsLong();minecraft.getMusicManager().stopPlaying();}
}
private double seconds() { return started < 0 ? 0 : Math.max(0,(clock.getAsLong()-started)/1_000_000_000.0); }
private double elapsed() { return started < 0 ? 0 : Math.max(0,(clock.getAsLong()-started)/1_000_000_000.0); }
private double seconds() { return Math.max(0,elapsed()-welcomeFade); }
public void tick() {
if (finished) return;
double time=seconds();
@@ -79,10 +90,11 @@ public final class IntroScreen extends Screen {
sounds.add(sound);minecraft.getSoundManager().play(sound);
}
public void extractBackground(GuiGraphicsExtractor graphics,int mouseX,int mouseY,float partialTick) {
graphics.fill(0,0,width,height,finished && !skipped ? 0xFFFFFFFF : 0xFF000000);
int gray=welcomeFade==0?0:(int)Math.round((WELCOME_BACKGROUND&255)*(1-IntroSequence.smooth(elapsed()/welcomeFade)));
graphics.fill(0,0,width,height,finished && !skipped ? 0xFFFFFFFF : 0xFF000000|gray<<16|gray<<8|gray);
}
public void extractRenderState(GuiGraphicsExtractor graphics,int mouseX,int mouseY,float partialTick) {
if (finished) return;
if (finished || elapsed()<welcomeFade) return;
double time=seconds(), rise=IntroSequence.crescendo(time), fade=IntroSequence.fade(time);
float unit=Math.min(width/360f,height/230f);
// Sparse drifting motes establish depth before any readable sign appears.
@@ -0,0 +1,34 @@
package fr.koka.sanctuary.client;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.FontDescription;
import net.minecraft.resources.Identifier;
/** A small activity indicator, independent of game ticks and of server progress. */
public final class LoadingGlyphs {
private static final Component[] GLYPHS = "sanctuary".chars()
.mapToObj(c -> Component.literal(Character.toString(c)).withStyle(style ->
style.withFont(new FontDescription.Resource(Identifier.withDefaultNamespace("alt")))))
.toArray(Component[]::new);
private static final Component LEFT = Component.literal("[");
private static final Component RIGHT = Component.literal("]");
private LoadingGlyphs() {}
public static void render(GuiGraphicsExtractor graphics, Font font, int centerX, int y) {
// The integrated server's initial loading loop renders without ticking screens.
// A monotonic clock keeps the indicator moving even during a long unchanged phase.
double travel = Math.floorMod(System.nanoTime() / 1_000_000L, 3_200L) / 200.0;
double head = travel <= 8 ? travel : 16 - travel;
graphics.centeredText(font, LEFT, centerX - 60, y, 0xff777777);
for (int i = 0; i < GLYPHS.length; i++) {
double light = Math.max(0, 1 - Math.abs(i - head) / 2.0);
int shade = 88 + (int) (152 * light * light);
graphics.centeredText(font, GLYPHS[i], centerX + (i - 4) * 12, y,
0xff000000 | shade << 16 | shade << 8 | shade);
}
graphics.centeredText(font, RIGHT, centerX + 60, y, 0xff777777);
}
}
@@ -65,7 +65,7 @@ public final class MiningClient {
stop(); // Keep the gesture latched until release, even after an interruption.
}
if (BuildingClient.previewMode(c)) {stop();preview=List.of();return;}
BlockPos target=c.hitResult instanceof BlockHitResult hit && hit.getType()!=HitResult.Type.MISS ? hit.getBlockPos() : null;
BlockPos target=c.hitResult instanceof BlockHitResult hit && hit.getType()!=HitResult.Type.MISS && fr.koka.sanctuary.mining.MiningReach.within(c.player,hit.getBlockPos(),0) ? hit.getBlockPos() : null;
preview=engaged ? locked.stream().filter(p -> !c.level.getBlockState(p).isAir()).toList()
: target==null ? List.of() : VeinSelection.preview(c.level,target,selected);
MiningPreviewRenderer.collect(c,preview,0xFFE4B85F);
@@ -0,0 +1,47 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.multiblock.*;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.inventory.ContainerScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.player.Inventory;
/** Native chest textures and slots; compact controls alongside the stock. */
public final class MultiblockScreen extends ContainerScreen {
private EditBox search;
private Button previous,next,apply;
private final java.util.List<Button> sections=new java.util.ArrayList<>();
public MultiblockScreen(MultiblockMenu menu,Inventory inv,Component title){super(menu,inv,title);}
private MultiblockMenu machine(){return (MultiblockMenu)menu;}
private void change(int page,int section){if(menu.getCarried().isEmpty())ClientPlayNetworking.send(new MultiblockMenu.Change(menu.containerId,page,search.getValue(),section,localizedItems(search.getValue())));}
static int[] localizedItems(String query){
String needle=query.strip().toLowerCase(java.util.Locale.ROOT);if(needle.isEmpty())return new int[0];
var items=net.minecraft.core.registries.BuiltInRegistries.ITEM;
return items.stream().filter(item->new net.minecraft.world.item.ItemStack(item).getHoverName().getString().toLowerCase(java.util.Locale.ROOT).contains(needle)).limit(4096).mapToInt(items::getId).toArray();
}
@Override protected void init(){
super.init();sections.clear();var v=machine().view;int x=leftPos+imageWidth+4;int w=Math.max(64,Math.min(112,width-x-4));
search=addRenderableWidget(new EditBox(font,x,topPos,w,18,Multiblocks.text("search")));search.setMaxLength(64);search.setValue(v.query());search.setHint(Multiblocks.text("search"));
apply=addRenderableWidget(Button.builder(Multiblocks.text("search"),b->change(0,v.section())).bounds(x,topPos+20,w,20).build());
previous=addRenderableWidget(Button.builder(Component.literal("<"),b->change(v.page()-1,v.section())).bounds(x,topPos+44,w/2-1,20).build());
next=addRenderableWidget(Button.builder(Component.literal(">"),b->change(v.page()+1,v.section())).bounds(x+w/2+1,topPos+44,w/2-1,20).build());
if(v.furnace())for(int n=0;n<3;n++){int section=n;var b=addRenderableWidget(Button.builder(Multiblocks.text(new String[]{"inputs","fuel","outputs"}[n]),button->change(0,section)).bounds(x,topPos+84+n*22,w,20).build());sections.add(b);}
controls();
}
private void controls(){if(apply==null)return;var v=machine().view;boolean empty=menu.getCarried().isEmpty();apply.active=empty;previous.active=empty&&v.page()>0;next.active=empty&&v.page()+1<v.pages();for(int i=0;i<sections.size();i++)sections.get(i).active=empty&&i!=v.section();}
@Override protected void containerTick(){super.containerTick();controls();}
@Override protected void extractLabels(net.minecraft.client.gui.GuiGraphicsExtractor g,int mouseX,int mouseY){
super.extractLabels(g,mouseX,mouseY);var m=machine();int x=imageWidth+4;
g.text(font,Multiblocks.text("page",m.view.page()+1,m.view.pages()),x,68,0xFFFFFFFF,false);
if(m.view.furnace()){
g.text(font,Multiblocks.text("heat",m.heat),x,154,0xFFFFFFFF,false);
g.text(font,Multiblocks.text("progress",m.progress),x,166,0xFFFFFFFF,false);
}
for(int i=m.view.indices().length;i<m.displaySlots();i++){var s=menu.getSlot(i);g.fill(s.x,s.y,s.x+16,s.y+16,0xB0181818);}
}
public java.util.List<net.minecraft.client.renderer.Rect2i> extraAreas(){return search==null?java.util.List.of():java.util.List.of(new net.minecraft.client.renderer.Rect2i(search.getX()-2,search.getY()-2,search.getWidth()+4,machine().view.furnace()?182:82));}
public static void register(){
net.minecraft.client.gui.screens.MenuScreens.<net.minecraft.world.inventory.ChestMenu,MultiblockScreen>register(Multiblocks.MENU,(m,i,t)->new MultiblockScreen((MultiblockMenu)m,i,t));
net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback.EVENT.register((stack,context,flags,lines)->{if(stack.is(Multiblocks.WRENCH))lines.add(Multiblocks.text("hint").copy().withStyle(net.minecraft.ChatFormatting.GRAY));});
}
}
@@ -0,0 +1,163 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.plans.*;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper;
import net.fabricmc.fabric.api.client.networking.v1.*;
import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.*;
import net.minecraft.core.*;
import net.minecraft.gizmos.*;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.*;
/** Local plans and cheap visible faces; the survival overlay never sends a placement packet. */
public final class PlanClient {
private static final ExecutorService WORKER=Executors.newSingleThreadExecutor(r->{Thread t=new Thread(r,"Sanctuary plans");t.setDaemon(true);return t;});
private static int generation, ticks;
private static KeyMapping key;
public static BuildPlan plan;
public static BlockPos origin;
public static boolean visible=true,busy;
public static int layer=-1,completed,conflicts;
public static Component status=Component.empty();
private static Map<BlockState,Integer> colors=Map.of();
private static net.minecraft.client.multiplayer.ClientLevel planLevel;
private static List<Face> faces=List.of();
private static Upload upload;
private record Face(BlockPos pos,AABB box,Direction direction,int color,boolean conflict) { }
private static final class Upload {
final BuildPlan plan;final BlockPos origin;final UUID id=UUID.randomUUID();int offset;
Upload(BuildPlan plan,BlockPos origin){this.plan=plan;this.origin=origin;}
}
private PlanClient() { }
public static Path directory(){return FabricLoader.getInstance().getGameDir().resolve("schematics").resolve("sanctuary");}
public static void register(KeyMapping.Category category){
key=KeyMappingHelper.registerKeyMapping(new KeyMapping("sanctuary.plans.key",InputConstants.Type.KEYBOARD,InputConstants.KEY_K,category));
ClientPlayConnectionEvents.DISCONNECT.register((h,c)->clear());
ClientConfigurationConnectionEvents.INIT.register((h,c)->clear());
ClientTickEvents.END_CLIENT_TICK.register(PlanClient::tick);
HudElementRegistry.addLast(SanctuaryMod.id("plan_overlay"),(g,d)->{
var mc=Minecraft.getInstance();if(plan==null||!visible||mc.player==null||mc.gui.screen()!=null)return;
var text=label("hud",plan.name(),completed,plan.cells().size(),conflicts,key.getTranslatedKeyMessage());
int x=(g.guiWidth()-mc.font.width(text))/2,y=g.guiHeight()-65;
g.fill(x-4,y-3,x+mc.font.width(text)+4,y+mc.font.lineHeight+3,0xB0101720);g.text(mc.font,text,x,y,0xFFFFFFFF);
});
}
public static void generate(Entity entity,int height,String family){
if(busy)return;var mc=Minecraft.getInstance();
try{
var mesh=StatueModels.capture(entity);var palette=new StatuePalette(family);
String name=net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getKey(entity.getType()).getPath()+"-"+height;
task(()->{
var pixels=StatueVoxelizer.convert(mesh,height);var blocks=new HashMap<BlockPos,BlockState>();
pixels.forEach((pos,color)->blocks.put(pos,palette.nearest(color)));
return BuildPlan.normalized(name,blocks);
},palette.colors());
}catch(Exception e){fail(e);}
}
@FunctionalInterface private interface Work {BuildPlan run() throws Exception;}
private static void task(Work work,Map<BlockState,Integer> palette){
if(busy)return;busy=true;status=label("working");int token=++generation;var mc=Minecraft.getInstance();var world=mc.level;
CompletableFuture.supplyAsync(()->{try{return work.run();}catch(Exception e){throw new CompletionException(e);}},WORKER)
.whenComplete((result,error)->mc.execute(()->{
if(token!=generation)return;
busy=false;
if(mc.level!=world)return;
if(error!=null){fail(error.getCause()==null?error:error.getCause());return;}
select(result,palette);if(mc.gui.screen() instanceof PlansScreen screen)screen.showPlan();
}));
}
public static void load(Path file){task(()->SchematicIO.read(file),Map.of());}
public static void select(BuildPlan result,Map<BlockState,Integer> palette){
plan=result;colors=Map.copyOf(palette);layer=-1;visible=true;upload=null;
planLevel=Minecraft.getInstance().level;
anchor();status=label("ready",result.cells().size());
}
public static void anchor(){
var mc=Minecraft.getInstance();if(mc.player==null||plan==null)return;
BlockPos target=mc.hitResult instanceof BlockHitResult h&&h.getType()!=HitResult.Type.MISS?h.getBlockPos().relative(h.getDirection()):mc.player.blockPosition().relative(mc.player.getDirection(),5);
origin=target.offset(-plan.width()/2,0,-plan.depth()/2);refresh();
}
public static void move(int x,int y,int z){if(origin!=null){origin=origin.offset(x,y,z);refresh();}}
public static void rotate(){if(plan==null)return;BlockPos before=origin;select(plan.rotate(),colors);origin=before;refresh();}
public static void export(){
if(plan==null||busy)return;BuildPlan current=plan;busy=true;int token=++generation;status=label("working");
CompletableFuture.supplyAsync(()->{try{return SchematicIO.write(current,directory());}catch(IOException e){throw new CompletionException(e);}},WORKER)
.whenComplete((path,error)->Minecraft.getInstance().execute(()->{
if(token!=generation)return;busy=false;if(error!=null)fail(error.getCause());else status=label("saved",path.getFileName().toString());
}));
}
public static void paste(){
var mc=Minecraft.getInstance();
if(plan==null||origin==null||mc.player==null||!mc.player.isCreative()||!ClientPlayNetworking.canSend(PlanPayload.TYPE)){status=label("result.creative_only");return;}
upload=new Upload(plan,origin);status=label("uploading");
}
public static boolean uploading(){return upload!=null;}
private static void tick(Minecraft mc){
while(key.consumeClick())if(mc.player!=null&&mc.gui.screen()==null)mc.gui.setScreen(new PlansScreen(null));
if(mc.level==null||mc.player==null)return;
if(planLevel!=null&&planLevel!=mc.level){clear();return;}
if(upload!=null){
if(!mc.player.isCreative()||!ClientPlayNetworking.canSend(PlanPayload.TYPE))upload=null;
else{
var u=upload;int end=Math.min(u.offset+PlanPayload.CHUNK,u.plan.cells().size());
ClientPlayNetworking.send(new PlanPayload(u.id,u.origin,u.plan.width(),u.plan.height(),u.plan.depth(),u.plan.cells().size(),u.offset,u.plan.cells().subList(u.offset,end)));
u.offset=end;if(end==u.plan.cells().size()){upload=null;status=label("sent");}
}
}
if(plan==null||origin==null||!visible)return;
if(++ticks%10==0)refresh();
try(var ignored=mc.collectPerTickGizmos()){
Gizmos.cuboid(new AABB(origin.getX(),origin.getY(),origin.getZ(),origin.getX()+plan.width(),origin.getY()+plan.height(),origin.getZ()+plan.depth()),GizmoStyle.stroke(0xFF80DAC4,1));
for(Face face:faces){
BlockPos p=origin.offset(face.pos);
if(mc.player.position().distanceToSqr(Vec3.atCenterOf(p))>96*96)continue;
Vec3 a=Vec3.atLowerCornerOf(p).add(face.box.minX,face.box.minY,face.box.minZ);
Vec3 b=Vec3.atLowerCornerOf(p).add(face.box.maxX,face.box.maxY,face.box.maxZ);
Gizmos.rect(a,b,face.direction,GizmoStyle.fill((face.conflict?0x90FF5544:0x98000000|(face.color&0xFFFFFF))));
}
}
}
public static void refresh(){
var mc=Minecraft.getInstance();if(plan==null||origin==null||mc.level==null)return;
var remaining=new HashSet<BlockPos>();var occupied=new HashSet<BlockPos>();completed=0;conflicts=0;
for(var c:plan.cells()){
var actual=mc.level.getBlockState(origin.offset(c.pos()));
if(actual==c.state()){completed++;continue;}
if(!actual.isAir()){conflicts++;occupied.add(c.pos());}
if(layer<0||c.pos().getY()==layer)remaining.add(c.pos());
}
var next=new ArrayList<Face>();
for(var c:plan.cells())if(remaining.contains(c.pos())){
int color=colors.getOrDefault(c.state(),0xFF000000|c.state().getMapColor(mc.level,origin.offset(c.pos())).col);
var boxes=c.state().getShape(mc.level,origin.offset(c.pos())).toAabbs();
if(boxes.isEmpty())boxes=List.of(new AABB(0,0,0,1,1,1));
boolean full=boxes.size()==1&&boxes.getFirst().equals(new AABB(0,0,0,1,1,1));
for(AABB box:boxes)for(Direction direction:Direction.values())if(!full||!remaining.contains(c.pos().relative(direction)))
next.add(new Face(c.pos(),box,direction,color,occupied.contains(c.pos())));
}
faces=List.copyOf(next);
}
public static Map<BlockState,Integer> materials(boolean remainingOnly){
var result=new LinkedHashMap<BlockState,Integer>();var mc=Minecraft.getInstance();if(plan==null)return result;
for(var cell:plan.cells())if(!remainingOnly||mc.level==null||mc.level.getBlockState(origin.offset(cell.pos()))!=cell.state())result.merge(cell.state(),1,Integer::sum);
return result;
}
public static void clear(){generation++;busy=false;plan=null;origin=null;planLevel=null;faces=List.of();colors=Map.of();upload=null;completed=0;conflicts=0;status=Component.empty();}
private static void fail(Throwable e){
busy=false;String reason=e.getMessage()==null?"unknown":e.getMessage().split(":",2)[0];
if(!net.minecraft.locale.Language.getInstance().has("sanctuary.plans.problem."+reason))reason="unknown";
status=label("error",label("problem."+reason));SanctuaryMod.LOGGER.warn("Plan operation failed",e);
}
public static Component label(String key,Object...args){return Component.translatable("sanctuary.plans."+key,args);}
}
@@ -0,0 +1,180 @@
package fr.koka.sanctuary.client;
import java.nio.file.*;
import java.util.*;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.screens.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import com.mojang.blaze3d.Blaze3D;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.SpawnEggItem;
import net.minecraft.world.phys.EntityHitResult;
import static fr.koka.sanctuary.client.PlanClient.label;
/** Three native panels: create, local library, and construction. */
public final class PlansScreen extends Screen {
private final Screen parent;
private final Entity target;
private String tab,model="minecraft:creeper",heightText="32",palette="mixed";
private int page;
private boolean materialList;
public PlansScreen(Screen parent){
super(label("title"));this.parent=parent;tab=PlanClient.plan==null?"statue":"plan";
var mc=net.minecraft.client.Minecraft.getInstance();target=mc.hitResult instanceof EntityHitResult hit?hit.getEntity():null;
if(mc.player!=null&&mc.player.getMainHandItem().getItem() instanceof SpawnEggItem)
model=BuiltInRegistries.ENTITY_TYPE.getKey(SpawnEggItem.getType(mc.player.getMainHandItem())).toString();
}
public void showPlan(){tab="plan";materialList=false;rebuildWidgets();}
@Override protected void init(){
if(PlanClient.plan!=null)PlanClient.refresh();
int w=Math.min(450,width-24),x=(width-w)/2;
var tabs=LinearLayout.horizontal().spacing(4);
for(String name:List.of("statue","library","plan")){
var b=tabs.addChild(Button.builder(label("tab."+name),button->{tab=name;page=0;materialList=false;rebuildWidgets();}).width((w-8)/3).build());
b.active=!name.equals(tab)&&(!name.equals("plan")||PlanClient.plan!=null);
}
tabs.setPosition(x,29);tabs.arrangeElements();tabs.visitWidgets(this::addRenderableWidget);
var layout=LinearLayout.vertical().spacing(6);
if(tab.equals("statue"))statue(layout,w);else if(tab.equals("library"))library(layout,w);else plan(layout,w);
var scroll=new ScrollableLayout(minecraft,layout,Math.max(32,height-110),ScrollableLayout.ReserveStrategy.RIGHT);
scroll.setPosition(x,57);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
addRenderableWidget(Button.builder(label("return"),b->onClose()).bounds(x,height-27,w,20).build());
}
private void text(LinearLayout l,String key,int width,Object...args){l.addChild(new MultiLineTextWidget(label(key,args),font).setMaxWidth(width-14));}
private Button button(LinearLayout l,String key,int w,Runnable action){return l.addChild(Button.builder(label(key),b->action.run()).width(w-14).build());}
private void statue(LinearLayout l,int w){
text(l,"statue_help",w);
text(l,"model",w);
var input=l.addChild(new EditBox(font,w-14,20,label("model")));input.setMaxLength(100);input.setValue(model);input.setResponder(v->model=v);
button(l,"models",w,()->{tab="models";modelPicker();});
if(target!=null)button(l,"target",w,()->generate(target));
text(l,"height",w);
var height=l.addChild(new EditBox(font,w-14,20,label("height")));height.setMaxLength(2);height.setValue(heightText);height.setResponder(v->heightText=v);
var choices=l.addChild(LinearLayout.horizontal().spacing(4));
for(String family:List.of("mixed","concrete","wool")){
var b=choices.addChild(Button.builder(label("palette."+family),p->{palette=family;rebuildWidgets();}).width((w-22)/3).build());b.active=!palette.equals(family);
}
var generate=button(l,"generate",w,()->{
try{
var id=Identifier.tryParse(model.trim());var type=id==null?null:BuiltInRegistries.ENTITY_TYPE.getOptional(id).orElse(null);
Entity e=type==null?null:type.create(minecraft.level,EntitySpawnReason.MOB_SUMMONED);
if(e==null){PlanClient.status=label("unknown_model");return;}
e.setPos(minecraft.player.position());e.xOld=e.getX();e.yOld=e.getY();e.zOld=e.getZ();e.xo=e.getX();e.yo=e.getY();e.zo=e.getZ();
e.setYRot(0);e.setXRot(0);e.yRotO=0;e.xRotO=0;
if(e instanceof LivingEntity living){living.yBodyRot=0;living.yBodyRotO=0;living.yHeadRot=0;living.yHeadRotO=0;}
e.setId(Integer.MIN_VALUE+72);generate(e);
}catch(Exception e){PlanClient.status=label("error",e.getMessage());}
});generate.active=!PlanClient.busy;
text(l,"model_note",w);
}
private void generate(Entity e){
try{int height=Integer.parseInt(heightText);if(height<4||height>96)throw new NumberFormatException();PlanClient.generate(e,height,palette);}
catch(NumberFormatException ex){PlanClient.status=label("height_error");}
}
private void modelPicker(){
// A separate native list avoids reinitializing the text field while the player types.
minecraft.gui.setScreen(new ModelPicker(this));
}
private void library(LinearLayout l,int w){
text(l,"library_help",w);
button(l,"folder",w,()->{
try{Files.createDirectories(PlanClient.directory());Blaze3D.openPath(PlanClient.directory());}
catch(Exception e){PlanClient.status=label("error",e.getMessage());}
});
button(l,"refresh",w,this::rebuildWidgets);
text(l,"formats",w);
try{
var files=new ArrayList<Path>();
// Include existing community downloads in the conventional schematics folder.
for(Path dir:List.of(PlanClient.directory(),PlanClient.directory().getParent()))if(Files.isDirectory(dir)){
try(var stream=Files.list(dir)){stream.filter(Files::isRegularFile).filter(p->p.getFileName().toString().matches("(?i).+\\.(litematic|schem|nbt|schematic)")).limit(1000).forEach(files::add);}
}
files.sort(Comparator.comparing(p->p.getFileName().toString().toLowerCase(Locale.ROOT)));
if(files.isEmpty())text(l,"empty_library",w);
for(Path file:files)l.addChild(Button.builder(Component.literal(file.getFileName().toString()),b->PlanClient.load(file)).width(w-14).build());
}catch(Exception e){text(l,"error",w,e.getMessage());}
}
private void plan(LinearLayout l,int w){
var plan=PlanClient.plan;if(plan==null){text(l,"empty_library",w);return;}
l.addChild(new StringWidget(Component.literal(plan.name()),font));
text(l,"dimensions",w,plan.width(),plan.height(),plan.depth(),plan.cells().size());
text(l,"progress",w,PlanClient.completed,PlanClient.conflicts);
if(materialList){
button(l,"back_plan",w,()->{materialList=false;rebuildWidgets();});
text(l,"materials_help",w);
var materials=PlanClient.materials(true);
materials.entrySet().stream().sorted(Comparator.comparing(e->e.getKey().getBlock().getName().getString())).forEach(e->{
var state=e.getKey();String props=net.minecraft.commands.arguments.blocks.BlockStateParser.serialize(state);
var count=Component.literal(e.getValue()+" × ").append(state.getBlock().getName());
var row=l.addChild(new StringWidget(count,font));row.setTooltip(Tooltip.create(Component.literal(props)));
});
if(materials.isEmpty())text(l,"complete",w);return;
}
text(l,"position",w,PlanClient.origin.getX(),PlanClient.origin.getY(),PlanClient.origin.getZ());
var moves=l.addChild(LinearLayout.horizontal().spacing(3));
for(var d:net.minecraft.core.Direction.values())moves.addChild(Button.builder(label("move."+d.getName()),b->{PlanClient.move(d.getStepX(),d.getStepY(),d.getStepZ());rebuildWidgets();}).width((w-29)/6).build());
button(l,"anchor",w,()->{PlanClient.anchor();rebuildWidgets();});
button(l,"rotate",w,()->{PlanClient.rotate();rebuildWidgets();});
var layers=l.addChild(LinearLayout.horizontal().spacing(4));
layers.addChild(Button.builder(label("layer_down"),b->{PlanClient.layer=PlanClient.layer<0?0:Math.max(0,PlanClient.layer-1);PlanClient.refresh();rebuildWidgets();}).width((w-22)/3).build());
layers.addChild(Button.builder(PlanClient.layer<0?label("all_layers"):label("layer",PlanClient.layer+1),b->{PlanClient.layer=-1;PlanClient.refresh();rebuildWidgets();}).width((w-22)/3).build());
layers.addChild(Button.builder(label("layer_up"),b->{PlanClient.layer=Math.min(plan.height()-1,PlanClient.layer+1);PlanClient.refresh();rebuildWidgets();}).width((w-22)/3).build());
button(l,PlanClient.visible?"hide":"show",w,()->{PlanClient.visible=!PlanClient.visible;rebuildWidgets();});
button(l,"materials",w,()->{materialList=true;rebuildWidgets();});
button(l,"export",w,PlanClient::export).active=!PlanClient.busy;
if(minecraft.player.isCreative()){
button(l,"paste",w,()->minecraft.gui.setScreen(new ConfirmScreen(confirmed->{
minecraft.gui.setScreen(this);if(confirmed)PlanClient.paste();
},label("paste_confirm"),label("paste_detail",plan.cells().size(),PlanClient.origin.getX(),PlanClient.origin.getY(),PlanClient.origin.getZ())))).active=!PlanClient.uploading();
}else text(l,"survival_help",w);
text(l,"blocks_only",w);
button(l,"clear",w,()->{PlanClient.clear();tab="statue";rebuildWidgets();});
}
@Override public void onFilesDrop(List<Path> files){if(!files.isEmpty())PlanClient.load(files.getFirst());}
@Override public void extractRenderState(GuiGraphicsExtractor g,int mouseX,int mouseY,float partial){
super.extractRenderState(g,mouseX,mouseY,partial);g.centeredText(font,title,width/2,12,0xFFFFFFFF);
var lines=font.split(PlanClient.status,Math.min(450,width-24));
for(int i=0;i<Math.min(2,lines.size());i++)g.text(font,lines.get(i),(width-Math.min(450,width-24))/2,height-49+i*font.lineHeight,0xFFF3D68A);
}
@Override public boolean isPauseScreen(){return false;}
@Override public void onClose(){minecraft.gui.setScreen(parent);}
private final class ModelPicker extends Screen {
private final PlansScreen back;
private String filter="";
private EditBox search;
private final List<Button> entries=new ArrayList<>();
private int pickerPage;
ModelPicker(PlansScreen back){super(label("models"));this.back=back;}
@Override protected void init(){
entries.clear();
int w=Math.min(420,width-24),x=(width-w)/2;
search=addRenderableWidget(new EditBox(font,x,32,w,20,label("search_model")));search.setValue(filter);
search.setResponder(v->{filter=v;pickerPage=0;update();});
int rows=Math.max(1,(height-106)/24);
for(int i=0;i<rows;i++)entries.add(addRenderableWidget(Button.builder(Component.empty(),b->{}).bounds(x,59+i*24,w,20).build()));
addRenderableWidget(Button.builder(Component.literal(""),b->{pickerPage=Math.max(0,pickerPage-1);update();}).bounds(x,height-43,40,20).build());
addRenderableWidget(Button.builder(label("return"),b->onClose()).bounds(x+44,height-43,w-88,20).build());
addRenderableWidget(Button.builder(Component.literal(""),b->{pickerPage++;update();}).bounds(x+w-40,height-43,40,20).build());
update();
}
private void update(){
var models=BuiltInRegistries.ENTITY_TYPE.stream().filter(t->BuiltInRegistries.ITEM.stream().anyMatch(i->i instanceof SpawnEggItem&&SpawnEggItem.getType(i.getDefaultInstance())==t))
.filter(t->(t.getDescription().getString()+" "+BuiltInRegistries.ENTITY_TYPE.getKey(t)).toLowerCase(Locale.ROOT).contains(filter.toLowerCase(Locale.ROOT)))
.sorted(Comparator.comparing(t->t.getDescription().getString())).toList();
pickerPage=Math.min(pickerPage,Math.max(0,(models.size()-1)/entries.size()));
for(int i=0;i<entries.size();i++){
int index=pickerPage*entries.size()+i;Button old=entries.get(i);removeWidget(old);
EntityType<?> type=index<models.size()?models.get(index):null;
Button next=Button.builder(type==null?Component.empty():type.getDescription(),b->{model=BuiltInRegistries.ENTITY_TYPE.getKey(type).toString();onClose();})
.bounds(old.getX(),old.getY(),old.getWidth(),old.getHeight()).build();next.visible=type!=null;entries.set(i,addRenderableWidget(next));
}
}
@Override public void extractRenderState(GuiGraphicsExtractor g,int x,int y,float p){super.extractRenderState(g,x,y,p);g.centeredText(font,title,width/2,12,0xFFFFFFFF);}
@Override public boolean isPauseScreen(){return false;}
@Override public void onClose(){back.tab="statue";minecraft.gui.setScreen(back);}
}
}
@@ -62,6 +62,7 @@ public final class ProgressionClient {
if(changed && context.client().gui.screen() instanceof SanctuaryAtlasScreen map)map.permissionsChanged();
if (context.client().player != null) ProgressEffects.bind(context.client().player,progress);
if (context.client().gui.screen() instanceof ProgressionScreen screen) screen.accept(payload.message());
if (context.client().gui.screen() instanceof CycleFactionScreen screen) screen.refreshProgress();
}));
ClientPlayNetworking.registerGlobalReceiver(ProgressionPayloads.Names.TYPE,(payload,context) ->
context.client().execute(() -> names=MobNamesConfig.parse(payload.json())));
@@ -40,7 +40,6 @@ public final class ProgressionScreen extends BlocodexFrame {
switch (tab) {
case "progress" -> initProgression();
case "identity" -> initIdentity();
case "history" -> initStory();
default -> throw new IllegalArgumentException("Unknown Blocodex page: "+tab);
}
}
@@ -52,15 +51,23 @@ public final class ProgressionScreen extends BlocodexFrame {
private void initProgression() {
var progress=ProgressionClient.progress;
var cycleButton=addRenderableWidget(Button.builder(fr.koka.sanctuary.cycle.CycleService.label("entry"),
b -> CycleClient.open(this,"cycle")).bounds(contentX+contentWidth-150,contentTop,150,20).build());
cycleButton.setTooltip(Tooltip.create(fr.koka.sanctuary.cycle.CycleService.label("eligibility")));
int navigationWidth=(contentWidth-4)/2;
String[] pages={"cycle","log"};
for(int i=0;i<pages.length;i++) {
String page=pages[i];
var button=addRenderableWidget(Button.builder(fr.koka.sanctuary.cycle.CycleService.label(page),
b -> CycleClient.open(this,page)).bounds(contentX+i*(navigationWidth+4),contentTop+14,navigationWidth,20).build());
if(page.equals("cycle"))button.setTooltip(Tooltip.create(fr.koka.sanctuary.cycle.CycleService.label("eligibility")));
}
int fullNameWidth=0;
for (String skill:SKILLS) fullNameWidth=Math.max(fullNameWidth,font.width(ProgressionService.label(skill)));
// Give the XP bar the remaining width; keep the aptitude column and inline controls.
int fixedWidth=9+40+4*4;
panel=Math.min(contentWidth-120,Math.max((contentWidth-10)*3/5,fixedWidth+fullNameWidth+96+24));
panelTop=contentTop+24;listTop=panelTop+16;listBottom=contentBottom-14;
panelTop=contentTop+40;listTop=panelTop+16;listBottom=contentBottom-14;
addRenderableWidget(Button.builder(Component.translatable("gui.advancements"),b -> minecraft.gui.setScreen(
new net.minecraft.client.gui.screens.advancements.AdvancementsScreen(minecraft.player.connection.getAdvancements(),this)))
.bounds(contentX+6,panelTop,panel-12,20).build());
shownXp=minecraft.player.experienceLevel;
LinearLayout skillContents=LinearLayout.vertical().spacing(4);
int rowWidth=panel-24;
@@ -88,7 +95,7 @@ public final class ProgressionScreen extends BlocodexFrame {
plus.setTooltip(Tooltip.create(ProgressionService.label("upgrade_cost",Math.max(0,cost)).copy().append("\n").append(detail)));
}
double skillOffset=skills==null ? 0 : skills.getScrollAmount();
skills=scroll(skillContents,contentX+6,listTop,listBottom-listTop,skillOffset);
skills=scroll(skillContents,contentX+6,listTop+24,Math.max(24,listBottom-listTop-24),skillOffset);
int innerWidth=contentWidth-panel-32;
LinearLayout contents=LinearLayout.vertical().spacing(12);
for(String target:ProgressRecord.APTITUDES) {
@@ -127,15 +134,15 @@ public final class ProgressionScreen extends BlocodexFrame {
if (target.equals("hunger")) tooltip.append("\n").append(ProgressionService.label("sprint_hint"));
if (target.equals("mining")) {
tooltip=MiningClient.label("capacity",rank,7,percent(fr.koka.sanctuary.mining.MiningLimits.speed(rank)),
fr.koka.sanctuary.mining.MiningLimits.blocks(rank)).copy();
fr.koka.sanctuary.mining.MiningLimits.blocks(rank),number(fr.koka.sanctuary.mining.MiningLimits.reach(rank))).copy();
if (rank<7) tooltip.append("\n").append(MiningClient.label("next",percent(fr.koka.sanctuary.mining.MiningLimits.speed(rank+1)),
fr.koka.sanctuary.mining.MiningLimits.blocks(rank+1)));
fr.koka.sanctuary.mining.MiningLimits.blocks(rank+1),number(fr.koka.sanctuary.mining.MiningLimits.reach(rank+1))));
tooltip.append("\n").append(MiningClient.controls());
}
if (target.equals("building")) {
int diameter=fr.koka.sanctuary.building.BuildingLimits.diameter(rank);
tooltip=BuildingClient.label("capacity",rank,7,diameter,diameter).copy();
if (rank<7) tooltip.append("\n").append(BuildingClient.label("next",fr.koka.sanctuary.building.BuildingLimits.diameter(rank+1)));
tooltip=BuildingClient.label("capacity",rank,7,diameter,diameter,number(fr.koka.sanctuary.building.BuildingLimits.reach(rank))).copy();
if (rank<7) tooltip.append("\n").append(BuildingClient.label("next",fr.koka.sanctuary.building.BuildingLimits.diameter(rank+1),number(fr.koka.sanctuary.building.BuildingLimits.reach(rank+1))));
tooltip.append("\n").append(BuildingClient.controlsLabel());
}
if (target.equals("inventory")) {
@@ -182,26 +189,6 @@ public final class ProgressionScreen extends BlocodexFrame {
return section;
}
private void initStory() {
LinearLayout contents=LinearLayout.vertical().spacing(12);
contents.addChild(Button.builder(ProgressionService.label("atlas"),b -> minecraft.gui.setScreen(new SanctuaryAtlasScreen(parent)))
.width(Math.min(220,contentWidth-10)).build());
contents.addChild(new MultiLineTextWidget(SanctuaryAtlasScreen.label("entry_hint"),font).setMaxWidth(contentWidth-10));
contents.addChild(Button.builder(fr.koka.sanctuary.cycle.CycleService.label("faction"),b -> CycleClient.open(parent,"faction"))
.width(Math.min(220,contentWidth-10)).build());
contents.addChild(Button.builder(fr.koka.sanctuary.cycle.CycleService.label("log"),b -> CycleClient.open(parent,"log"))
.width(Math.min(220,contentWidth-10)).build());
var history=ProgressionClient.progress.history();
for (int i=history.size()-1;i>=0;i--) {
var event=history.get(i);
contents.addChild(new MultiLineTextWidget(ProgressionService.label(event.target().startsWith("remove_") ? "history_refund" : "history_purchase",stamp(event.at()),
ProgressionService.label(event.target().startsWith("remove_") ? event.target().substring(7) : event.target()),event.cost()),font).setMaxWidth(contentWidth-10));
}
contents.addChild(new MultiLineTextWidget(ProgressionService.label("history_created",stamp(ProgressionClient.identity.createdAt())),font)
.setMaxWidth(contentWidth-10));
scroll(contents,contentX,contentTop,contentBottom-contentTop,0);
}
private ScrollableLayout scroll(LinearLayout contents,int x,int y,int height,double offset) {
ScrollableLayout scroll=new ScrollableLayout(minecraft,contents,height,ScrollableLayout.ReserveStrategy.RIGHT);
scroll.setMinHeight(height);scroll.arrangeElements();scroll.setX(x);scroll.setY(y);
@@ -224,6 +211,7 @@ public final class ProgressionScreen extends BlocodexFrame {
return button;
}
private static String number(double value) {return java.text.NumberFormat.getNumberInstance(java.util.Locale.ROOT).format(value);}
private static String percent(double speed) {return java.text.NumberFormat.getNumberInstance(java.util.Locale.ROOT).format(speed*100);}
public void accept(String message) { this.message=message;pendingTicks=0;rebuildWidgets(); }
@@ -241,10 +229,9 @@ public final class ProgressionScreen extends BlocodexFrame {
if (!ProgressionClient.active()) return;
if (tab.equals("progress")) {
graphics.text(font,ProgressionService.label("levels",minecraft.player.experienceLevel),contentX,contentTop,0xFFDDDDDD);
graphics.text(font,ProgressionService.label(ProgressionClient.operator ? "skills_operator" : "skills"),contentX+6,panelTop+3,0xFFA9D7A0);
graphics.text(font,ProgressionService.label(ProgressionClient.operator ? "skills_operator" : "skills"),contentX+6,panelTop+27,0xFFA9D7A0);
graphics.text(font,ProgressionService.label("aptitudes"),contentX+panel+16,panelTop+3,0xFFA6CAE8);
if (!message.isEmpty()) graphics.centeredText(font,ProgressionService.label(message),width/2,contentBottom-9,0xFFFFFFAA);
}
}
private static String stamp(String iso) { return iso.substring(5,10)+" "+iso.substring(11,16)+" UTC"; }
}
@@ -65,6 +65,7 @@ public final class RecipeClient {
SanctuaryJeiPlugin.apply();applied=fingerprint;
}
var screen=Minecraft.getInstance().gui.screen();if(screen instanceof RecipeScreen recipes)recipes.refresh();
else if(screen instanceof DiscoveryStatsScreen discoveries)discoveries.knowledgeUpdated();
else if(screen instanceof ProgressionScreen progress)progress.accept("");
}catch(RuntimeException error){invalid=true;ready=false;knowledge=null;SanctuaryJeiPlugin.apply();}
}
@@ -6,12 +6,12 @@ import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.screens.Screen;
/** Recipes are a tool inside Discovery, with an explicit, reversible operator view. */
public final class RecipeScreen extends BlocodexFrame {
public final class RecipeScreen extends DiscoveryFrame {
private ScrollableLayout scroll;
public RecipeScreen(Screen parent){super(parent);}
public void refresh(){rebuildWidgets();}
@Override protected void init(){
initFrame("blocks",RecipeClient.label("title"));int inner=Math.max(100,contentWidth-24);
initDiscovery("recipes");int inner=Math.max(100,contentWidth-24);
var list=LinearLayout.vertical().spacing(8);
list.addChild(new StringWidget(RecipeClient.label("title"),font));
list.addChild(new MultiLineTextWidget(RecipeClient.label("manual"),font).setMaxWidth(inner));
@@ -29,7 +29,7 @@ import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Block;
/** A personal server snapshot, with independent history facets and current carried stock. */
public final class SanctuaryBlocodexScreen extends BlocodexFrame {
public final class SanctuaryBlocodexScreen extends DiscoveryFrame {
private static final AtomicInteger REQUEST_IDS = new AtomicInteger();
private static final int ROW_HEIGHT = 44;
private int listTop;
@@ -70,7 +70,7 @@ public final class SanctuaryBlocodexScreen extends BlocodexFrame {
@Override
protected void init() {
inspection=ProgressionClient.operator;
initFrame("blocks",inspection?Component.translatable("sanctuary.operator.discovery"):fr.koka.sanctuary.progression.ProgressionService.label("blocks"));
initDiscovery("blocks");
fullCatalogue=canBrowseAll();
if (!fullCatalogue) filter=Filter.POSSESSED;
int margin = contentX;
@@ -97,8 +97,6 @@ public final class SanctuaryBlocodexScreen extends BlocodexFrame {
listTop = controlsY + 50;
paginationY = contentBottom - 20;
addRenderableWidget(Button.builder(RecipeClient.label("title"),b -> minecraft.gui.setScreen(new RecipeScreen(this)))
.bounds(contentX+contentWidth-96,paginationY,96,20).build());
int listHeight = paginationY - listTop - 4;
pageSize = Math.max(1, (listHeight - 8) / ROW_HEIGHT);
list = addRenderableWidget(new EntryList(contentWidth, listHeight, listTop));
@@ -20,6 +20,11 @@ public final class SanctuaryJeiPlugin implements IModPlugin {
private static final List<Group<?>> groups=new ArrayList<>();
private static final List<OtherIngredients<?>> otherIngredients=new ArrayList<>();
public Identifier getPluginUid(){return Identifier.fromNamespaceAndPath("sanctuary","recipes");}
@Override public void registerGuiHandlers(mezz.jei.api.registration.IGuiHandlerRegistration registration){
registration.addGuiContainerHandler(MultiblockScreen.class,new mezz.jei.api.gui.handlers.IGuiContainerHandler<MultiblockScreen>(){
@Override public List<net.minecraft.client.renderer.Rect2i> getGuiExtraAreas(MultiblockScreen screen){return screen.extraAreas();}
});
}
@Override public void onRuntimeAvailable(IJeiRuntime r){
runtime=r;items=List.copyOf(r.getIngredientManager().getAllItemStacks());hiddenItems.clear();groups.clear();otherIngredients.clear();recipeDetails.clear();blueprintOutputs.clear();
r.getRecipeManager().createRecipeCategoryLookup().includeHidden().get().forEach(SanctuaryJeiPlugin::capture);
@@ -17,6 +17,8 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
}
public static void registerClient() {
SanctuaryResourcePack.register();
VortexCloudRenderer.register();
ProgressionClient.register();
StarClient.register();
WeatherClient.register();
@@ -31,6 +33,8 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
NotificationClient.register();
FoodDetailsClient.register();
FoodKnowledgeClient.register();
MultiblockScreen.register();
FourneauModels.register();
GraveScreen.register();
GraveInventoryScreen.register();
TutorialClient.register();
@@ -46,6 +50,7 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
HotbarClient.register(category);
InventorySortClient.register(category);
BuildingClient.register();
PlanClient.register(category);
ClientPlayNetworking.registerGlobalReceiver(BlockKnowledgePayloads.SnapshotPart.TYPE, (part, context) ->
context.client().execute(() -> {
if (context.client().gui.screen() instanceof SanctuaryBlocodexScreen screen) screen.accept(part);
@@ -0,0 +1,21 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.SanctuaryMod;
import net.fabricmc.fabric.api.resource.v1.ResourceLoader;
import net.fabricmc.fabric.api.resource.v1.pack.PackActivationType;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.network.chat.Component;
/** Shipped with the mod, enabled on first discovery and still controlled by the player. */
public final class SanctuaryResourcePack {
private SanctuaryResourcePack() {}
public static void register() {
var mod = FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow();
var title = Component.translatable("sanctuary.resource_pack.name");
if (!ResourceLoader.registerBuiltinPack(SanctuaryMod.id("textures"), mod, title,
PackActivationType.DEFAULT_ENABLED)) {
throw new IllegalStateException("Missing built-in Sanctuary resource pack");
}
}
}
@@ -0,0 +1,93 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.NativeImage;
import com.mojang.blaze3d.vertex.*;
import fr.koka.sanctuary.mixin.client.*;
import fr.koka.sanctuary.plans.StatueVoxelizer;
import java.io.*;
import java.lang.reflect.*;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.model.Model;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.rendertype.RenderType;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.Entity;
/** Copies the quads actually submitted by native renderers. No gameplay entity is spawned. */
public final class StatueModels {
private StatueModels() { }
@SuppressWarnings({"rawtypes", "unchecked"})
public static List<StatueVoxelizer.Face> capture(Entity entity) throws IOException {
Minecraft mc = Minecraft.getInstance();
net.minecraft.client.renderer.entity.EntityRenderer renderer = mc.getEntityRenderDispatcher().getRenderer(entity);
var state = renderer.createRenderState(entity, 1);
state.nameTag = null; state.isInvisible = false;
var camera = new CameraRenderState(); camera.pos = entity.position();
var faces = new ArrayList<StatueVoxelizer.Face>(); var textures = new HashMap<Identifier, StatueVoxelizer.Texture>();
InvocationHandler handler = (proxy, method, args) -> {
if (method.getName().equals("order")) return proxy;
if (method.getDeclaringClass() == Object.class) return switch (method.getName()) {
case "toString" -> "Sanctuary statue capture"; case "hashCode" -> System.identityHashCode(proxy); case "equals" -> proxy == args[0]; default -> null;
};
if (method.isDefault()) return InvocationHandler.invokeDefault(proxy, method, args);
if (method.getName().equals("submitModel")) {
RenderType type = (RenderType)args[3];
var setup = ((CompanionRenderTypeAccess)type).sanctuary$setup();
var binding = ((CompanionRenderSetupAccess)(Object)setup).sanctuary$textures().get("Sampler0");
if (!(binding instanceof CompanionTextureBindingAccess access)) throw new IOException("model_texture");
// Atlas-wrapped or moving UVs need a dedicated adapter, never a guessed skin.
if (args[7] != null) throw new IOException("model_texture");
Identifier id = access.sanctuary$location();
var texture = textures.get(id);
if (texture == null) { texture = readTexture(id); textures.put(id, texture); }
Model model = (Model)args[0]; model.setupAnim(args[1]);
var sink = new Capture(texture, (int)args[6], faces);
model.renderToBuffer((PoseStack)args[2], sink, (int)args[4], (int)args[5], (int)args[6]);
sink.finish();
}
// Shadows, labels, leashes and particles are not part of the sculpted model.
return null;
};
var collector = (SubmitNodeCollector)Proxy.newProxyInstance(StatueModels.class.getClassLoader(), new Class<?>[]{SubmitNodeCollector.class}, handler);
try { renderer.submit(state, new PoseStack(), collector, camera); }
catch (UndeclaredThrowableException e) { if (e.getCause() instanceof IOException io) throw io; throw e; }
if (faces.isEmpty()) throw new IOException("unsupported_model");
return List.copyOf(faces);
}
public static StatueVoxelizer.Texture readTexture(Identifier id) throws IOException {
var resource = Minecraft.getInstance().getResourceManager().getResource(id).orElseThrow(() -> new IOException("model_texture: " + id));
try (var stream = resource.open(); var image = NativeImage.read(stream)) {
int w = image.getWidth(), h = image.getHeight();
if (w > 4096 || h > 4096 || (long)w*h > 4_194_304) throw new IOException("texture_limit");
int[] colors = new int[w*h];
for (int y=0;y<h;y++) for(int x=0;x<w;x++) colors[y*w+x] = image.getPixel(x,y);
return new StatueVoxelizer.Texture(w,h,colors);
}
}
private static final class Capture implements VertexConsumer {
private final StatueVoxelizer.Texture texture; private final int tint; private final List<StatueVoxelizer.Face> faces;
private final List<StatueVoxelizer.Vertex> quad = new ArrayList<>(4);
private float x,y,z,u,v; private boolean pending;
Capture(StatueVoxelizer.Texture texture,int tint,List<StatueVoxelizer.Face> faces) { this.texture=texture;this.tint=tint;this.faces=faces; }
private void vertex() {
if (!pending) return;
quad.add(new StatueVoxelizer.Vertex(x,y,z,u,v));pending=false;
if(quad.size()==4) {
if(faces.size()>=16_384)throw new IllegalArgumentException("model_limit");
faces.add(new StatueVoxelizer.Face(quad.get(0),quad.get(1),quad.get(2),quad.get(3),texture,tint));quad.clear();
}
}
void finish(){vertex();if(!quad.isEmpty())throw new IllegalArgumentException("model_geometry");}
public VertexConsumer addVertex(float x,float y,float z){vertex();this.x=x;this.y=y;this.z=z;pending=true;return this;}
public VertexConsumer setUv(float u,float v){this.u=u;this.v=v;return this;}
public VertexConsumer setColor(int r,int g,int b,int a){return this;}
public VertexConsumer setColor(int color){return this;}
public VertexConsumer setUv1(int u,int v){return this;}
public VertexConsumer setUv2(int u,int v){return this;}
public VertexConsumer setUv3(float u,float v){return this;}
public VertexConsumer setNormal(float x,float y,float z){return this;}
public VertexConsumer setLineWidth(float width){return this;}
}
}
@@ -0,0 +1,43 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.plans.StatueVoxelizer;
import java.io.IOException;
import java.util.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.DyeColor;
import net.minecraft.world.level.block.state.BlockState;
/** Small stable palettes of full blocks, sampled from the active resource pack. */
public final class StatuePalette {
public record Material(BlockState state, int color, double[] lab) { }
private final List<Material> materials;
private final Map<Integer, BlockState> matches = new HashMap<>();
public StatuePalette(String family) throws IOException {
if (!List.of("concrete", "wool", "mixed").contains(family)) throw new IllegalArgumentException("palette");
var result = new ArrayList<Material>();
for (String set : family.equals("mixed") ? List.of("concrete","wool","terracotta") : List.of(family)) {
for (DyeColor dye : DyeColor.values()) add(result,dye.getName()+"_"+set);
}
if (family.equals("mixed")) for (String id : List.of("snow_block","clay","terracotta","bricks","stone","andesite","diorite","granite","obsidian","oak_planks","spruce_planks","birch_planks","dark_oak_planks")) add(result,id);
materials=List.copyOf(result);
}
private static void add(List<Material> result,String name) throws IOException {
String texture = name.equals("snow_block") ? "snow" : name;
var pixels=StatueModels.readTexture(Identifier.withDefaultNamespace("textures/block/"+texture+".png"));
long r=0,g=0,b=0,count=0;
for(int color:pixels.argb()) if((color>>>24)>=128){r+=(color>>16)&255;g+=(color>>8)&255;b+=color&255;count++;}
if(count==0)throw new IOException("palette_texture");
int color=0xFF000000|(int)(r/count)<<16|(int)(g/count)<<8|(int)(b/count);
var state=BuiltInRegistries.BLOCK.getOptional(Identifier.withDefaultNamespace(name)).orElseThrow().defaultBlockState();
result.add(new Material(state,color,StatueVoxelizer.colorSpace(color)));
}
public BlockState nearest(int color) {
return matches.computeIfAbsent(color, c->{
double[] lab=StatueVoxelizer.colorSpace(c);double best=Double.POSITIVE_INFINITY;Material chosen=materials.getFirst();
for(Material m:materials){double distance=0;for(int i=0;i<3;i++)distance+=(lab[i]-m.lab[i])*(lab[i]-m.lab[i]);if(distance<best){best=distance;chosen=m;}}
return chosen.state;
});
}
public Map<BlockState,Integer> colors(){var result=new HashMap<BlockState,Integer>();materials.forEach(m->result.put(m.state,m.color));return Map.copyOf(result);}
}
@@ -0,0 +1,113 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.buffers.Std140SizeCalculator;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.commands.RenderPass;
import com.mojang.renderpearl.api.pipeline.PrimitiveTopology;
import com.mojang.renderpearl.api.pipeline.RenderPipeline;
import com.mojang.renderpearl.api.textures.GpuTextureView;
import fr.koka.sanctuary.sky.VortexClouds;
import java.nio.ByteBuffer;
import java.util.*;
import net.fabricmc.fabric.api.client.networking.v1.*;
import net.minecraft.client.CloudStatus;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.MappableRingBuffer;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.oit.OitRenderPassProvider;
import net.minecraft.client.renderer.oit.OitStage;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
import net.minecraft.util.ARGB;
import net.minecraft.world.phys.Vec3;
import org.lwjgl.system.MemoryUtil;
/** One connected spiral, cached in the native cloud face format. Only its transform changes each frame. */
public final class VortexCloudRenderer {
private static final Map<Identifier,VortexClouds.Settings> WORLDS=new HashMap<>();
private static final int CELL=12,THICKNESS=4;
private static GpuBuffer fancyMesh,flatMesh;
private static MappableRingBuffer info;
private static int fancyFaces,flatFaces,builtDiameter=-1,builds,draws,oitDraws;
private static boolean visible,inside;
private static float angle;
private VortexCloudRenderer(){}
public static void register(){
ClientPlayNetworking.registerGlobalReceiver(VortexClouds.Settings.TYPE,(p,c)->c.client().execute(()->WORLDS.put(p.dimension(),p)));
ClientPlayConnectionEvents.DISCONNECT.register((h,c)->{WORLDS.clear();release();});
ClientConfigurationConnectionEvents.INIT.register((h,c)->{WORLDS.clear();release();});
}
// The island's diameter is the cloud disc's radius: its footprint is twice as wide.
static boolean occupied(int x,int z,int diameter){
double dx=(x+.5)*CELL,dz=(z+.5)*CELL,r=Math.hypot(dx,dz)/diameter;
if(r<.055||r>=1)return false;
if(r<.18)return true;
double phase=3*(Math.atan2(dz,dx)-3.3*r);
double taper=Math.max(0,(r-.78)/.22);
return Math.cos(phase)>-.28+1.24*taper;
}
private static void face(ByteBuffer data,int x,int z,Direction direction,int flags){
data.put((byte)(x>>1)).put((byte)(z>>1)).put((byte)(direction.get3DDataValue()|flags|((x&1)<<7)|((z&1)<<6)));
}
private static void build(int diameter){
if(diameter==builtDiameter)return;
closeMeshes();builtDiameter=diameter;builds++;
int radius=(int)Math.ceil(diameter/(double)CELL),side=radius*2+2;
ByteBuffer fancy=MemoryUtil.memAlloc(side*side*6*3*2),flat=MemoryUtil.memAlloc(side*side*3);
try{
for(int x=-radius;x<radius;x++)for(int z=-radius;z<radius;z++)if(occupied(x,z,diameter)){
face(flat,x,z,Direction.DOWN,32);
face(fancy,x,z,Direction.UP,0);face(fancy,x,z,Direction.DOWN,0);
for(Direction direction:Direction.Plane.HORIZONTAL)if(!occupied(x+direction.getStepX(),z+direction.getStepZ(),diameter))face(fancy,x,z,direction,0);
}
fancyFaces=fancy.position()/3;flatFaces=flat.position()/3;
// Inside faces are appended once and drawn only while crossing the four-block layer.
for(int i=0;i<fancyFaces;i++)fancy.put(fancy.get(i*3)).put(fancy.get(i*3+1)).put((byte)(fancy.get(i*3+2)|16));
fancy.flip();flat.flip();
fancyMesh=RenderSystem.getDevice().createBuffer(()->"Sanctuary vortex clouds",264,fancy);
flatMesh=RenderSystem.getDevice().createBuffer(()->"Sanctuary flat vortex clouds",264,flat);
}finally{MemoryUtil.memFree(fancy);MemoryUtil.memFree(flat);}
if(info==null)info=new MappableRingBuffer(()->"Sanctuary vortex cloud info",130,new Std140SizeCalculator().putVec4().putVec3().putVec3().get());
}
public static void prepare(int color,CloudStatus status,int range,Vec3 camera,long ticks,float partial){
visible=false;var level=Minecraft.getInstance().level;if(level==null||status==CloudStatus.OFF)return;
var settings=WORLDS.get(level.dimension().identifier());if(settings==null)return;
double x=camera.x-settings.centerX(),z=camera.z-settings.centerZ();
if(Math.hypot(x,z)>settings.islandDiameter()+range*16.0)return;
build(settings.islandDiameter());visible=true;inside=camera.y>=VortexClouds.HEIGHT&&camera.y<=VortexClouds.HEIGHT+THICKNESS;
angle=(float)((Math.floorMod(ticks,VortexClouds.ROTATION_TICKS)+partial)*Math.PI*2/VortexClouds.ROTATION_TICKS);
// Rotate the camera into cloud space, so vanilla's fog distance remains correct.
double cos=Math.cos(angle),sin=Math.sin(angle);
float localX=(float)(cos*x-sin*z),localZ=(float)(sin*x+cos*z);
try(var mapped=info.currentBuffer().map(false,true)){
Std140Builder.intoBuffer(mapped.data()).putVec4(ARGB.vector4fFromARGB32(color))
.putVec3(-localX,(float)(VortexClouds.HEIGHT-camera.y),-localZ).putVec3(CELL,THICKNESS,CELL);
}
RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS).requestIndexCount(6*Math.max(fancyFaces*2,flatFaces));
}
public static void render(CloudStatus status,RenderPass pass){
if(visible)draw(pass,status==CloudStatus.FANCY?RenderPipelines.CLOUDS:RenderPipelines.FLAT_CLOUDS,status);
}
public static void renderOit(CloudStatus status,OitStage stage,GpuTextureView depth,OitRenderPassProvider.Parameters parameters){
if(!visible)return;
oitDraws++;
var pipeline=(status==CloudStatus.FANCY?RenderPipelines.OIT_CLOUDS:RenderPipelines.OIT_FLAT_CLOUDS).getPipeline(stage);
try(var pass=OitRenderPassProvider.createRenderPass(stage,()->"Sanctuary vortex clouds",parameters)){draw(pass,pipeline,status);}
}
private static void draw(RenderPass pass,RenderPipeline pipeline,CloudStatus status){
boolean fancy=status==CloudStatus.FANCY;int faces=fancy?fancyFaces*(inside?2:1):flatFaces;
var indices=RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS);
pass.pushDebugGroup(()->"Sanctuary vortex clouds");pass.setPipeline(RenderSystem.getCompiledPipeline(pipeline));RenderSystem.bindDefaultUniforms(pass);
pass.setUniform("DynamicTransforms",RenderSystem.getDynamicUniforms().writeTransform(RenderSystem.getModelViewMatrixCopy().rotateY(angle)));
pass.setUniform("CloudInfo",info.currentBuffer());pass.setUniform("CloudFaces",fancy?fancyMesh:flatMesh);
pass.setIndexBuffer(indices.getBuffer(),indices.type());pass.setVertexBuffer(0,null);pass.drawIndexed(faces*6,1,0,0,0);pass.popDebugGroup();draws++;
}
public static void endFrame(){if(info!=null)info.rotate();visible=false;}
private static void closeMeshes(){if(fancyMesh!=null)fancyMesh.close();if(flatMesh!=null)flatMesh.close();fancyMesh=null;flatMesh=null;builtDiameter=-1;}
public static void release(){closeMeshes();if(info!=null)info.close();info=null;visible=false;}
static int builds(){return builds;}
static int draws(){return draws;}
static int oitDraws(){return oitDraws;}
}
@@ -7,6 +7,7 @@ import net.minecraft.world.entity.player.Player;
/** Holding a real flying passenger slows descent; shared client/server physics, no timed potion. */
public final class CarryGlide {
public static final double DESCENT=.12;
public static final double ASCENT=.18;
private CarryGlide(){}
public static boolean flyer(Entity rider){
if(rider instanceof FamiliarEntity familiar&&!(familiar.egg().getItem() instanceof net.minecraft.world.item.SpawnEggItem))return false;
@@ -17,10 +18,24 @@ public final class CarryGlide {
return ProgressEffects.get(p)!=null&&p.isAlive()&&!p.isSpectator()&&!p.isPassenger()&&!p.getAbilities().flying&&!p.isFallFlying()
&&!p.isInWater()&&!p.isInLava()&&p.getPassengers().stream().anyMatch(e->CarryService.carried(e)&&flyer(e));
}
public static boolean active(Player p){return supported(p)&&!p.onGround()&&p.getDeltaMovement().y<=0;}
public static boolean lifts(net.minecraft.world.item.ItemStack egg){
int size=FamiliarSize.value(egg);
return FamiliarMotion.of(egg)==FamiliarMotion.DRAGON&&size>1200&&size<2500;
}
public static boolean powered(Player p){
return supported(p)&&p.getPassengers().stream().anyMatch(e->CarryService.carried(e)&&e instanceof FamiliarEntity pet&&lifts(pet.egg()));
}
public static boolean lifting(Player p){
boolean jump=p instanceof net.minecraft.server.level.ServerPlayer server?server.getLastClientInput().jump():p.isJumping();
return jump&&powered(p);
}
public static boolean active(Player p){return supported(p)&&!p.onGround()&&(p.getDeltaMovement().y<=0||lifting(p));}
public static void tick(Player p){
if(!active(p))return;
var velocity=p.getDeltaMovement();if(velocity.y<-DESCENT)p.setDeltaMovement(velocity.x,-DESCENT,velocity.z);
boolean lift=lifting(p);
if(!lift&&!active(p))return;
var velocity=p.getDeltaMovement();
if(lift)p.setDeltaMovement(velocity.x,Math.min(ASCENT,Math.max(0,velocity.y)+.03),velocity.z);
else if(velocity.y<-DESCENT)p.setDeltaMovement(velocity.x,-DESCENT,velocity.z);
// Clear the distance already accumulated, including when the animal was picked up mid-fall.
p.fallDistance=0;
}
@@ -42,7 +42,9 @@ public final class CarryService {
public static boolean carrying(Entity e){return e instanceof Player&&e.getPassengers().stream().anyMatch(CarryService::carried);}
public static boolean pair(Entity a,Entity b){return a!=null&&b!=null&&a!=b&&(
(carried(a)||carried(b))&&root(a)==root(b)
||(a instanceof FamiliarEntity||b instanceof FamiliarEntity)&&a.getRootVehicle()==b.getRootVehicle());}
||(a instanceof FamiliarEntity||b instanceof FamiliarEntity)&&a.getRootVehicle()==b.getRootVehicle()
&&!fr.koka.sanctuary.boats.BoatPassengerInteractions.aboardTogether(a,b)
&&!fr.koka.sanctuary.boats.BoatPassengerInteractions.aboardTogether(b,a));}
private static Entity root(Entity e){while(carried(e))e=e.getVehicle();return e;}
/** One seat per head; the chain includes babies even though they add no carrying penalty. */
private static Entity passenger(Entity e){
@@ -34,6 +34,13 @@ public final class FamiliarEntity extends PathfinderMob {
}
@Override protected void defineSynchedData(SynchedEntityData.Builder builder) {super.defineSynchedData(builder);builder.define(EGG,ItemStack.EMPTY);}
public ItemStack egg() {return entityData.get(EGG);}
public static boolean burnsInDaylight(ItemStack egg){
if(!(egg.getItem() instanceof net.minecraft.world.item.SpawnEggItem))return false;
var type=net.minecraft.world.item.SpawnEggItem.getType(egg);
return type.builtInRegistryHolder().is(net.minecraft.tags.EntityTypeTags.BURN_IN_DAYLIGHT)&&!type.fireImmune();
}
public boolean burnsInDaylight(){return burnsInDaylight(egg());}
@Override public boolean fireImmune(){return !burnsInDaylight();}
public FamiliarMotion motion(){return FamiliarMotion.of(egg());}
public void bind(ServerPlayer owner,ItemStack egg) {
this.owner=owner;entityData.set(EGG,egg.copyWithCount(1));
@@ -50,7 +57,7 @@ public final class FamiliarEntity extends PathfinderMob {
var at=position().add(0,mountHeight(),0).subtract(player.getVehicleAttachmentPoint(this));
if(!mountRoom(player,at)||!player.startRiding(this,true,true))return false;
mountShiftReleased=!player.isShiftKeyDown();getNavigation().stop();setTarget(null);setDeltaMovement(Vec3.ZERO);
positionRider(player);
positionRider(player);FamiliarMountTraits.tick(this,player);
player.connection.send(new net.minecraft.network.protocol.game.ClientboundSetPassengersPacket(this));
player.sendOverlayMessage(net.minecraft.network.chat.Component.translatable("sanctuary.familiar.mount.controls"));return true;
}
@@ -91,7 +98,18 @@ public final class FamiliarEntity extends PathfinderMob {
}
return rider.position();
}
@Override public boolean canStandOnFluid(net.minecraft.world.level.material.FluidState fluid){
return FamiliarMountTraits.walksOnLava(egg())&&fluid.is(net.minecraft.tags.FluidTags.LAVA)||super.canStandOnFluid(fluid);
}
@Override public net.minecraft.world.phys.shapes.VoxelShape getLiquidCollisionShape(){
return FamiliarMountTraits.walksOnLava(egg())?net.minecraft.world.level.block.Block.column(16,0,8):super.getLiquidCollisionShape();
}
private boolean mountedClimbing;
@Override public boolean onClimbable(){
return FamiliarMotion.climbs(egg())&&!isInWater()&&(pilot()==null?horizontalCollision:mountedClimbing);
}
@Override public void travel(Vec3 input){
mountedClimbing=false;
var player=pilot();
if(player==null){super.travel(input);return;}
var keys=player.getLastClientInput();
@@ -111,6 +129,11 @@ public final class FamiliarEntity extends PathfinderMob {
setSpeed(motion()==FamiliarMotion.AQUATIC?.05F:.28F);
if(onGround()&&(keys.jump()||motion().hops()&&tickCount%14==0&&(side!=0||forward!=0)))jumpFromGround();
input=new Vec3(side,0,forward);if(input.lengthSqr()>1)input=input.normalize();
if(FamiliarMotion.climbs(egg())&&!isInWater()&&input.lengthSqr()>0){
var probe=getInputVector(input,.12F,getYRot());
mountedClimbing=!level().noBlockCollision(this,getBoundingBox().deflate(.01).move(probe));
if(mountedClimbing){setDeltaMovement(getDeltaMovement().with(net.minecraft.core.Direction.Axis.Y,.20));resetFallDistance();player.resetFallDistance();}
}
super.travel(input);
}
}
@@ -164,8 +187,10 @@ public final class FamiliarEntity extends PathfinderMob {
@Override public void tick() {
configureMotion();
super.tick();
FamiliarMountTraits.floatOnLava(this);
if(!(level() instanceof ServerLevel level))return;
clearFire();setAirSupply(getMaxAirSupply());
if(!burnsInDaylight())clearFire();
setAirSupply(getMaxAirSupply());
if(motion()==FamiliarMotion.AQUATIC&&!isInWater()&&tickCount%10==0){
level.sendParticles(net.minecraft.core.particles.ParticleTypes.BUBBLE,getX(),getY()+.25,getZ(),3,.22,.16,.22,0);
}
@@ -174,6 +199,7 @@ public final class FamiliarEntity extends PathfinderMob {
if(isVehicle()){
if(!FamiliarSize.colossal(egg())||pilot()==null||fr.koka.sanctuary.familiar.FamiliarDuels.fighting(player))ejectPassengers();
else {
FamiliarMountTraits.tick(this,player);
if(!player.isShiftKeyDown())mountShiftReleased=true;getNavigation().stop();
// Manual techniques retain their cast cadence while movement is driven by the rider.
fr.koka.sanctuary.familiar.FamiliarTechniques.advance(this);return;
@@ -227,7 +253,7 @@ public final class FamiliarEntity extends PathfinderMob {
motionMode=mode;getNavigation().stop();setNoGravity(mode==1||mode==2);
if(mode==1){navigation=new FlyingPathNavigation(this,level());moveControl=new FlyingMoveControl(this,20,true);}
else if(mode==2){navigation=new WaterBoundPathNavigation(this,level());moveControl=new SmoothSwimmingMoveControl(this,85,10,.5F,.1F,false);}
else{navigation=new GroundPathNavigation(this,level());moveControl=profile.hops()?new MoveControl(this){
else{navigation=FamiliarMotion.climbs(egg())?new WallClimberNavigation(this,level()):new GroundPathNavigation(this,level());moveControl=profile.hops()?new MoveControl(this){
@Override public void tick(){
super.tick();
if(onGround()&&!getNavigation().isDone()){
@@ -13,6 +13,11 @@ public enum FamiliarMotion {
public boolean flies(){return this==FLYING||this==DRAGON||this==WITHER||this==GHAST;}
public boolean swims(){return this==AQUATIC||this==AMPHIBIOUS||this==AMPHIBIOUS_HOPPING;}
public boolean hops(){return this==HOPPING||this==AMPHIBIOUS_HOPPING;}
public static boolean climbs(ItemStack egg){
if(!(egg.getItem() instanceof SpawnEggItem))return false;
var type=SpawnEggItem.getType(egg);
return type==net.minecraft.world.entity.EntityTypes.SPIDER||type==net.minecraft.world.entity.EntityTypes.CAVE_SPIDER;
}
public static FamiliarMotion of(ItemStack egg){
if(!(egg.getItem() instanceof SpawnEggItem))return GROUND;
return of(SpawnEggItem.getType(egg));
@@ -0,0 +1,35 @@
package fr.koka.sanctuary.cosmetics;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.SpawnEggItem;
/** Native rider effects belong to the mount, independently of its learned combat technique. */
public final class FamiliarMountTraits {
private FamiliarMountTraits(){}
public static boolean preservesBreath(ItemStack egg){
if(!(egg.getItem() instanceof SpawnEggItem))return false;
var type=SpawnEggItem.getType(egg);
return type==EntityTypes.NAUTILUS||type==EntityTypes.ZOMBIE_NAUTILUS;
}
public static boolean walksOnLava(ItemStack egg){
return egg.getItem() instanceof SpawnEggItem&&SpawnEggItem.getType(egg)==EntityTypes.STRIDER;
}
public static void floatOnLava(FamiliarEntity mount){
if(!walksOnLava(mount.egg())||!mount.isInLava())return;
var context=net.minecraft.world.phys.shapes.CollisionContext.of(mount);
if(context.isAbove(mount.getLiquidCollisionShape(),mount.blockPosition(),true)
&&!mount.level().getFluidState(mount.blockPosition().above()).is(net.minecraft.tags.FluidTags.LAVA))mount.setOnGround(true);
else mount.setDeltaMovement(mount.getDeltaMovement().scale(.5).add(0,.05,0));
}
public static void tick(FamiliarEntity mount,ServerPlayer rider){
if(rider.getVehicle()!=mount||!mount.isAlive()||!FamiliarSize.colossal(mount.egg()))return;
// Minecraft 26.3 AbstractNautilus: immediate effect, 60 ticks, refreshed every 40 ticks.
// Native expiry after dismount also preserves effects granted by a different source.
if(preservesBreath(mount.egg())&&(!rider.hasEffect(MobEffects.BREATH_OF_THE_NAUTILUS)||mount.level().getGameTime()%40==0))
rider.addEffect(new MobEffectInstance(MobEffects.BREATH_OF_THE_NAUTILUS,60,0,true,true,true));
}
}
@@ -40,6 +40,7 @@ public final class ExpansionRuntime {
if (server.getWorldData().overworldData().isInitialized()
&& !java.nio.file.Files.isDirectory(directory, java.nio.file.LinkOption.NOFOLLOW_LINKS))
throw new IOException("Initialized laboratory world is missing its expansion journal directory");
if(generator instanceof SanctuaryChunkGenerator sanctuary)sanctuary.attachPlanCache(level);
String initialAerialToken = "";
if (generator.rootGeneration() == 24 && !java.nio.file.Files.exists(directory, java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
if (!(generator instanceof SanctuaryChunkGenerator sanctuary)) throw new IOException("Aerial world requires the Sanctuary generator");
@@ -55,6 +56,7 @@ public final class ExpansionRuntime {
}
ExpansionVoidReuse.open(level, generator, journal.islands());
generator.initialize(level, journal.islands());
fr.koka.sanctuary.loading.PreloadProgress.stage(server,"spawn");
var session = new Session(level, generator, journal);
SESSIONS.put(level, session);
for (var island : journal.islands()) if (island.status().equals("reserved")) session.enqueue(island);
@@ -35,10 +35,15 @@ public final class FamiliarBattle {
World(MinecraftServer server,FamiliarCatalog catalog,FamiliarBondStore store)throws Exception{this.catalog=catalog;this.store=store;this.experience=new FamiliarExperience(server,store);}
}
public static final class State {
public UUID individual,target,protectedEntity;
public UUID individual,target,protectedEntity,threat;
public boolean explicitTarget;
public int evadeUntil,automaticAfter;
public UUID unreachable;
public int retryAfter,progressAt,lastSeen;
public double bestDistance;
public Order order=Order.FOLLOW;
public Vec3 protectedPoint;
public int nextInitiative, lastRepath, lastHurt=-1000, lastPlayerHit=-1000, lastRequest=-100;
public int nextInitiative, lastRepath, lastHurt=-1000, lastPlayerHit=-1000, lastRequest=-100, lastRefresh=-100;
public long playerCombatUntil;
public Vec3 journeyStart;
public boolean workMode;
@@ -67,7 +72,7 @@ public final class FamiliarBattle {
public static boolean active(ServerPlayer p){return profile(p)!=null&&bond(p)!=null&&!world(p.level().getServer()).experience.blocked(bond(p).id())&&CompanionService.available(p);}
public static boolean fighting(ServerPlayer p){var b=bond(p);return FamiliarDuels.fighting(p)||now()<Math.max(p.getAttachedOrElse(COMBAT_LOCK,0L),b==null?0:b.combatUntil());}
public static void register(){
FamiliarNetwork.register();FamiliarDuelNetwork.register();
FamiliarNetwork.register();FamiliarDuelNetwork.register();fr.koka.sanctuary.arena.Arenas.register();
ServerLifecycleEvents.SERVER_STARTED.register(server->{
FamiliarDuels.start(server);
try{WORLDS.put(server,new World(server,FamiliarCatalog.load(server),new FamiliarBondStore(server.getWorldPath(LevelResource.ROOT).resolve("sanctuary/familiars-v1.json"))));}
@@ -148,13 +153,14 @@ public final class FamiliarBattle {
if(state.target!=null){state.target=null;state.order=Order.FOLLOW;cancel(p);}
}
var pet=FamiliarService.active(p);
if(pet!=null&&pet.isAlive())FamiliarBrain.observe(pet);
if(pet!=null&&pet.isAlive()&&p.tickCount%100==0&&state.journeyStart!=null&&p.position().distanceToSqr(state.journeyStart)>=128*128){
var next=bond(p).remember("journey",p.level().dimension().identifier()+" "+p.blockPosition().toShortString());update(p,next);
}
if(p.tickCount%20==0)send(p,"");
}
public static void release(ServerPlayer p){var w=world(p.level().getServer());var state=STATES.remove(p);if(w!=null&&state!=null&&state.individual!=null)w.active.remove(state.individual,p.getUUID());if(state!=null)state.cast=null;}
public static void cancel(ServerPlayer p){var s=state(p);s.cast=null;s.guard=null;s.target=null;FamiliarTechniques.cancel(p);}
public static void cancel(ServerPlayer p){var s=state(p);s.cast=null;s.guard=null;s.target=null;s.explicitTarget=false;s.threat=null;s.evadeUntil=0;s.unreachable=null;s.retryAfter=0;FamiliarTechniques.cancel(p);}
public static void combat(ServerPlayer p){
var until=now()+COMBAT_MILLIS;state(p).playerCombatUntil=until;p.setAttached(COMBAT_LOCK,until);var b=bond(p);if(b!=null)update(p,b.combat(until));
}
@@ -162,6 +168,11 @@ public final class FamiliarBattle {
public static boolean enemy(ServerPlayer p,LivingEntity e){
var duel=FamiliarDuels.enemy(p,e);if(duel!=null)return duel;
if(e instanceof FamiliarEntity pet)return pet.owner()!=null&&pet.owner()!=p&&CompanionWorld.enemy(p,pet.owner());
// Neutral animals can become real attackers without belonging to MONSTER (bees, wolves...).
if(e instanceof NeutralMob&&e instanceof Mob mob&&mob.getTarget()!=null
&&(mob.getTarget()==p||mob.getTarget()==FamiliarService.active(p))
&&e.isAlive()&&!e.isInvulnerable()&&!e.isSpectator()&&!p.isAlliedTo(e)
&&!(e instanceof TamableAnimal tame&&tame.isTame())&&!CarryService.pair(p,e))return true;
return CompanionWorld.enemy(p,e);
}
public static boolean sight(FamiliarEntity pet,Entity target){return target.level()==pet.level()&&pet.hasLineOfSight(target);}
@@ -170,33 +181,65 @@ public final class FamiliarBattle {
&&target.distanceToSqr(p)<=24*24&&CompanionWorld.permitted(p,target.blockPosition(),"attack");
}
public static void engage(ServerPlayer p,LivingEntity target,boolean defend){
engage(p,target,defend,true);
}
private static void engage(ServerPlayer p,LivingEntity target,boolean defend,boolean explicit){
if(!active(p)||!enemy(p,target)||target.distanceToSqr(p)>24*24)return;
var pet=FamiliarService.active(p);if(pet==null||bond(p).recalled())return;
var s=state(p);s.target=target.getUUID();if(s.order!=Order.PROTECT)s.order=Order.ENGAGE;
var pet=FamiliarService.active(p);if(pet==null||bond(p).recalled()||!mayHit(pet,target))return;
var s=state(p);
if(!explicit&&s.explicitTarget&&target(p)!=null)return;
boolean changed=!Objects.equals(s.target,target.getUUID());
s.explicitTarget=explicit||!changed&&s.explicitTarget;s.target=target.getUUID();s.threat=null;s.evadeUntil=0;
if(changed){
if(s.cast!=null&&s.cast.automatic&&!Objects.equals(s.cast.target,target.getUUID()))s.cast=null;
s.lastRepath=p.tickCount-10;s.progressAt=s.lastSeen=p.tickCount;s.bestDistance=pet.distanceTo(target);
}
if(explicit){s.workMode=false;s.order=Order.ENGAGE;s.protectedEntity=null;s.protectedPoint=null;}
else if(s.order!=Order.PROTECT)s.order=Order.ENGAGE;
combat(p);if(defend&&trainingTarget(target))update(p,bond(p).remember("defence",target.getType().toShortString()));
}
public static void automatic(ServerPlayer p,LivingEntity target,FamiliarPersonality.Trigger trigger){
if(!active(p)||target==null||!enemy(p,target))return;
var pet=FamiliarService.active(p);var s=state(p);
boolean defending=trigger==FamiliarPersonality.Trigger.OWNER_HURT||trigger==FamiliarPersonality.Trigger.PET_HURT;
if(pet==null||s.workMode||pet.isVehicle()||CarryService.carried(pet)||FamiliarDuels.fighting(p)
||!defending&&p.tickCount<s.automaticAfter||s.explicitTarget&&target(p)!=null)return;
boolean orderedGuard=s.order==Order.PROTECT&&(defending||trigger==FamiliarPersonality.Trigger.GUARD_INTRUDER);
if((FamiliarPersonality.of(bond(p)).responds(trigger)||orderedGuard)&&FamiliarBrain.inTerritory(p,target)){
if(!defending&&Objects.equals(s.unreachable,target.getUUID())&&p.tickCount<s.retryAfter)return;
engage(p,target,defending,false);
}
else if(trigger==FamiliarPersonality.Trigger.PET_HURT||trigger==FamiliarPersonality.Trigger.OWNER_HURT){
s.threat=target.getUUID();s.evadeUntil=p.tickCount+80;s.lastRepath=p.tickCount-10;
}
}
public static boolean trainingTarget(LivingEntity target){return !(target instanceof net.minecraft.world.entity.player.Player)&&!(target instanceof FamiliarEntity)&&!target.entityTags().contains("sanctuary:no_familiar_training");}
public static void damageEvent(LivingEntity target,DamageSource source,float lost){
if(lost<=0)return;
if(target instanceof ServerPlayer p&&source.getEntity() instanceof LivingEntity attacker){
engage(p,attacker,true);
for(var ally:p.level().getServer().getPlayerList().getPlayers())if(Objects.equals(state(ally).protectedEntity,p.getUUID()))engage(ally,attacker,true);
if(active(p)&&enemy(p,attacker))combat(p);
automatic(p,attacker,FamiliarPersonality.Trigger.OWNER_HURT);
for(var ally:p.level().getServer().getPlayerList().getPlayers())if(Objects.equals(state(ally).protectedEntity,p.getUUID()))automatic(ally,attacker,FamiliarPersonality.Trigger.OWNER_HURT);
}
if(source.getEntity() instanceof ServerPlayer p&&FamiliarTechniques.current()==null){
state(p).lastPlayerHit=p.tickCount;engage(p,target,false);
state(p).lastPlayerHit=p.tickCount;
if(active(p)&&enemy(p,target))combat(p);
automatic(p,target,FamiliarPersonality.Trigger.OWNER_ATTACK);
}
}
public static boolean hurt(FamiliarEntity pet,DamageSource source,float amount){
var p=pet.owner();if(p==null||!active(p)||amount<=0||!Float.isFinite(amount))return false;
var attacker=source.getEntity();
if(FamiliarDuels.fighting(p)){
boolean burning=attacker==null&&pet.burnsInDaylight()&&source.is(net.minecraft.world.damagesource.DamageTypes.ON_FIRE);
if(Boolean.FALSE.equals(fr.koka.sanctuary.arena.Arenas.damageAllowed(pet,source)))return false;
if(FamiliarDuels.fighting(p)&&!fr.koka.sanctuary.arena.Arenas.running(p)&&!burning){
if(!(attacker instanceof FamiliarEntity rival)||rival.owner()==null||!Boolean.TRUE.equals(FamiliarDuels.enemy(p,rival)))return false;
engage(p,rival,false);
}
var hostile=attacker instanceof LivingEntity living&&enemy(p,living);
if(attacker instanceof FamiliarEntity familiar)attacker=familiar.owner();
if(attacker instanceof LivingEntity living&&!hostile&&!enemy(p,living))return false;
if(!FamiliarDuels.fighting(p)&&attacker instanceof LivingEntity living)engage(p,living,true);
if(!FamiliarDuels.fighting(p)&&attacker instanceof LivingEntity living)automatic(p,living,FamiliarPersonality.Trigger.PET_HURT);
state(p).cast=null;state(p).lastHurt=p.tickCount;combat(p);
float scale=FamiliarLevels.health(bond(p));
float damage=FamiliarTechniques.incoming(pet,source,amount)/scale,health=Math.max(0,pet.getHealth()-damage);
@@ -209,6 +252,7 @@ public final class FamiliarBattle {
return true;
}
public static boolean care(ServerPlayer p,ItemStack food){
if(fr.koka.sanctuary.arena.Arenas.running(p)&&!fr.koka.sanctuary.arena.Arenas.match(p).rules.supplies())return false;
var b=bond(p);var profile=profile(p);var nutrition=food.get(DataComponents.FOOD);
if(!active(p)||b==null||profile==null||nutrition==null||nutrition.nutrition()<1||b.careAt()>now())return false;
if(b.health()>=profile.health()&&b.knockedOutUntil()==0)return false;
@@ -219,7 +263,13 @@ public final class FamiliarBattle {
send(p,"fed");return true;
}
public static void command(ServerPlayer p,String action){
var s=state(p);if(p.tickCount-s.lastRequest<3)return;s.lastRequest=p.tickCount;
var s=state(p);
// Opening H requests a snapshot; it must not consume the following order's cooldown.
if(action.equals("refresh")){
if(p.tickCount-s.lastRefresh<3)return;s.lastRefresh=p.tickCount;
send(p,active(p)?"":"unavailable");return;
}
if(p.tickCount-s.lastRequest<3)return;s.lastRequest=p.tickCount;
if(action.equals("duel_open")){FamiliarDuels.open(p);return;}
if(action.equals("duel_invite")){
var other=FamiliarTechniques.aim(p,12,e->e instanceof ServerPlayer);
@@ -228,18 +278,24 @@ public final class FamiliarBattle {
if(!active(p)){send(p,"unavailable");return;}
var b=bond(p);var pet=FamiliarService.active(p);
switch(action){
case "refresh"->{}
case "recall"->{FamiliarDuels.withdrawing(p);cancel(p);update(p,b.recall(true));if(pet!=null)pet.discard();}
case "follow"->{cancel(p);s.order=Order.FOLLOW;s.protectedEntity=null;s.protectedPoint=null;update(p,b.recall(false));}
case "engage"->{var aim=FamiliarTechniques.aim(p,24,e->enemy(p,e));if(aim==null){send(p,"no_target");return;}update(p,b.recall(false));engage(p,aim,false);}
case "recall"->{FamiliarDuels.withdrawing(p);cancel(p);update(p,b.recall(true));if(pet!=null)pet.discard();send(p,"recalled");return;}
case "follow"->{follow(p);send(p,"order.following");return;}
case "quick"->{
var aim=FamiliarTechniques.aim(p,24,e->enemy(p,e));
if(aim==null){follow(p);send(p,"order.following");return;}
send(p,attackOrder(p,aim));return;
}
case "engage"->{var aim=FamiliarTechniques.aim(p,24,e->enemy(p,e));send(p,aim==null?"no_target":attackOrder(p,aim));return;}
case "protect"->{
cancel(p);s.order=Order.PROTECT;var ally=FamiliarTechniques.aim(p,12,e->FamiliarTechniques.ally(p,e));
cancel(p);s.order=Order.PROTECT;s.workMode=false;s.automaticAfter=0;var ally=FamiliarTechniques.aim(p,12,e->FamiliarTechniques.ally(p,e));
s.protectedEntity=ally==null?p.getUUID():ally.getUUID();s.protectedPoint=null;
if(p.isShiftKeyDown()){var hit=CompanionWorld.block(p,12,false);if(hit!=null){s.protectedPoint=hit.getLocation();s.protectedEntity=null;}}
update(p,b.recall(false));
send(p,"order.protecting");return;
}
case "signature","variant","work"->{
if(fighting(p)){send(p,"in_combat");return;}
if(action.equals("work")&&!CompanionService.record(p).unlocked()&&!ProgressionService.canOperate(p)){send(p,"work_locked");return;}
if(action.equals("variant")&&(!b.learned()||!CompanionService.record(p).unlocked())){send(p,"variant_locked");return;}
s.workMode=action.equals("work");update(p,b.variant(action.equals("variant")));
}
@@ -248,9 +304,20 @@ public final class FamiliarBattle {
}
send(p,"");
}
private static String attackOrder(ServerPlayer p,LivingEntity target){
if(bond(p).recalled())return "recalled";
var pet=FamiliarService.active(p);if(pet==null)return "no_familiar";
if(pet.isVehicle()||CarryService.carried(pet))return "order.cannot_move";
if(!mayHit(pet,target))return "blocked";
engage(p,target,false);return state(p).explicitTarget&&target==target(p)?"order.attacking":"no_target";
}
private static void follow(ServerPlayer p){
cancel(p);var s=state(p);s.order=Order.FOLLOW;s.protectedEntity=null;s.protectedPoint=null;s.automaticAfter=p.tickCount+100;
update(p,bond(p).recall(false));var pet=FamiliarService.active(p);if(pet!=null){pet.getNavigation().stop();pet.setAggressive(false);}
}
public static String activate(ServerPlayer p){
if(!active(p))return "unavailable";var b=bond(p);var s=state(p);
if(s.workMode){if(fighting(p))return "in_combat";return CompanionService.activate(p,-1);}
if(s.workMode){if(fighting(p))return "in_combat";return "work."+CompanionService.activate(p,-1);}
if(b.health()<=0)return "knocked_out";if(b.recalled())return "recalled";
if(Math.max(b.readyAt(),CompanionService.record(p).readyAt())>now())return "cooldown";
var pet=FamiliarService.active(p);if(pet==null)return "no_familiar";
@@ -4,7 +4,12 @@ import fr.koka.sanctuary.cosmetics.FamiliarEntity;
import fr.koka.sanctuary.cosmetics.CarryService;
import fr.koka.sanctuary.companion.CompanionWorld;
import java.util.Set;
import java.util.Objects;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.NeutralMob;
import net.minecraft.world.phys.Vec3;
/** Local navigation and action cadence. No chunk scans and at most one path request per ten ticks. */
@@ -13,47 +18,119 @@ public final class FamiliarBrain {
private FamiliarBrain(){}
public static boolean controls(FamiliarEntity pet){
var p=pet.owner();if(p==null||!FamiliarBattle.active(p)||CarryService.carried(pet))return false;
var s=FamiliarBattle.state(p);return s.cast!=null||s.target!=null||s.order==FamiliarBattle.Order.PROTECT||FamiliarFields.busy(p);
var s=FamiliarBattle.state(p);return s.cast!=null||s.target!=null||!s.workMode&&guarding(p)||s.evadeUntil>p.tickCount||FamiliarFields.busy(p);
}
private static final double HUNT_RADIUS=12, GUARD_RADIUS=6, GUARD_LEASH=10;
public static boolean guarding(ServerPlayer p){
return stateGuard(p)||FamiliarPersonality.of(FamiliarBattle.bond(p))==FamiliarPersonality.PROTECTIVE;
}
private static boolean stateGuard(ServerPlayer p){return FamiliarBattle.state(p).order==FamiliarBattle.Order.PROTECT;}
public static Vec3 anchor(ServerPlayer p){
var s=FamiliarBattle.state(p);
if(stateGuard(p)){
if(s.protectedPoint!=null&&s.protectedPoint.distanceToSqr(p.position())<=24*24)return s.protectedPoint;
var ally=s.protectedEntity==null?null:p.level().getEntity(s.protectedEntity);
if(ally instanceof LivingEntity living&&living.isAlive()&&FamiliarTechniques.ally(p,living)&&ally.distanceToSqr(p)<=24*24)return ally.position();
}
return p.position();
}
public static boolean inTerritory(ServerPlayer p,LivingEntity target){
return !guarding(p)||target.position().distanceToSqr(anchor(p))<=GUARD_LEASH*GUARD_LEASH;
}
/** One staggered query per second. Only loaded mobs; automatic proximity never starts PvP. */
public static void observe(FamiliarEntity pet){
var p=pet.owner();if(p==null||!FamiliarBattle.active(p)||pet.isVehicle()||CarryService.carried(pet))return;
var s=FamiliarBattle.state(p);
if(s.target!=null&&FamiliarBattle.target(p)==null)dropTarget(p,pet,false);
if((p.tickCount+pet.getId())%20!=0||s.explicitTarget||s.cast!=null||s.workMode
||p.tickCount<s.automaticAfter||FamiliarDuels.fighting(p)||FamiliarBattle.bond(p).recalled())return;
boolean guard=guarding(p);
if(!guard&&FamiliarPersonality.of(FamiliarBattle.bond(p))!=FamiliarPersonality.AGGRESSIVE)return;
var current=FamiliarBattle.target(p);
if(current!=null&&(!guard||current instanceof Mob mob&&threatens(p,pet,mob)))return;
var center=guard?anchor(p):p.position();double radius=guard?GUARD_RADIUS:HUNT_RADIUS;
Mob closest=null;double best=Double.MAX_VALUE;
for(var mob:p.level().getEntitiesOfClass(Mob.class,new AABB(center,center).inflate(radius),e->!(e instanceof FamiliarEntity))){
double distance=mob.position().distanceToSqr(center);
if(distance>radius*radius||!FamiliarBattle.enemy(p,mob)||!FamiliarBattle.mayHit(pet,mob)
||Objects.equals(s.unreachable,mob.getUUID())&&p.tickCount<s.retryAfter
||mob instanceof NeutralMob&&mob.getTarget()==null||mob.isInvisibleTo(p)
||!CompanionWorld.permitted(p,mob.blockPosition(),"target"))continue;
// Intercept a monster already targeting the owner/guarded ally before an idle intruder.
boolean threat=threatens(p,pet,mob);
if(current!=null&&!threat)continue;
double score=distance-(threat?radius*radius:0);
if(score>=best||!CompanionWorld.loaded(p.level(),pet.getEyePosition(),mob.getEyePosition())||!pet.hasLineOfSight(mob))continue;
closest=mob;best=score;
}
if(closest!=null)FamiliarBattle.automatic(p,closest,guard?FamiliarPersonality.Trigger.GUARD_INTRUDER:FamiliarPersonality.Trigger.NEARBY_HOSTILE);
}
private static boolean threatens(ServerPlayer p,FamiliarEntity pet,Mob mob){
var victim=mob.getTarget();
return victim==p||victim==pet||victim!=null&&Objects.equals(FamiliarBattle.state(p).protectedEntity,victim.getUUID());
}
private static void dropTarget(ServerPlayer p,FamiliarEntity pet,boolean unreachable){
var s=FamiliarBattle.state(p);
if(unreachable){s.unreachable=s.target;s.retryAfter=p.tickCount+200;}
if(s.cast!=null&&s.cast.automatic&&Objects.equals(s.cast.target,s.target))s.cast=null;
s.target=null;s.explicitTarget=false;pet.setAggressive(false);pet.getNavigation().stop();
if(s.order==FamiliarBattle.Order.ENGAGE)s.order=FamiliarBattle.Order.FOLLOW;
}
public static void tick(FamiliarEntity pet){
var p=pet.owner();if(p==null)return;var s=FamiliarBattle.state(p);var profile=FamiliarBattle.profile(p);if(profile==null)return;
var personality=FamiliarPersonality.of(FamiliarBattle.bond(p));
var initiative=personality.initiative(profile,s.explicitTarget||s.order==FamiliarBattle.Order.PROTECT);
var target=FamiliarBattle.target(p);
if(target!=null&&(!FamiliarBattle.mayHit(pet,target)||target.distanceToSqr(p)>24*24)){s.target=null;target=null;}
if(target!=null&&(!FamiliarBattle.mayHit(pet,target)||!s.explicitTarget&&!inTerritory(p,target))){dropTarget(p,pet,false);target=null;}
if(target==null&&s.target!=null)dropTarget(p,pet,false);
if(target!=null&&!s.explicitTarget&&!FamiliarDuels.fighting(p)){
double distance=pet.distanceTo(target);boolean visible=pet.hasLineOfSight(target);
if(visible)s.lastSeen=p.tickCount;
if(distance<s.bestDistance-.5||visible&&distance<=initiative.range()+.25){s.bestDistance=distance;s.progressAt=p.tickCount;}
if(p.tickCount-s.lastSeen>100||p.tickCount-s.progressAt>120){dropTarget(p,pet,true);target=null;}
}
if(s.cast!=null){FamiliarTechniques.advance(pet);return;}
if(FamiliarFields.busy(p))return;
var guard=s.guard;
if(guard!=null&&guard.until<FamiliarBattle.now())s.guard=null;
if(guard!=null&&guard.kind.equals("stationary")){pet.getNavigation().stop();return;}
Vec3 destination=p.position();
if(s.order==FamiliarBattle.Order.PROTECT){
var ally=s.protectedEntity==null?null:p.level().getEntity(s.protectedEntity);
destination=s.protectedPoint!=null?s.protectedPoint:ally!=null?ally.position():p.position();
if(target!=null&&target.position().distanceToSqr(destination)>12*12){s.target=null;target=null;}
if(target!=null)destination=destination.add(target.position().subtract(destination).normalize().scale(1.5));
if(!s.workMode&&guarding(p)){
destination=anchor(p);
// Settle beside the guarded player instead of occupying their feet.
if(s.protectedPoint==null)destination=destination.add(Math.cos(pet.getId())*2,0,Math.sin(pet.getId())*2);
}
if(target!=null){
if(profile.id().equals("sanctuary:creaking")&&observed(pet,target)){
pet.getNavigation().stop();if(p.tickCount>=s.nextInitiative){s.guard=new FamiliarBattle.Guard(pet.getUUID(),target.position().subtract(pet.position()).normalize(),FamiliarBattle.now()+2000,2,"");s.nextInitiative=p.tickCount+80;}return;
}
double range=profile.initiative().range(),distance=pet.distanceTo(target);
double range=initiative.range(),distance=pet.distanceTo(target);
if(range>3){
var away=pet.position().subtract(target.position()).normalize();destination=target.position().add(away.scale(profile.distance()));
if(distance>=3&&distance<=range&&pet.hasLineOfSight(target))destination=pet.position();
}else{
destination=target.position();
if(FLANK.contains(profile.id())&&distance>2){var side=new Vec3(Math.cos(pet.getId()),0,Math.sin(pet.getId()));destination=destination.add(side.scale(1.3));}
if(FLANK.contains(profile.id())&&distance>4){var side=new Vec3(Math.cos(pet.getId()),0,Math.sin(pet.getId()));destination=destination.add(side.scale(1.3));}
}
pet.getLookControl().setLookAt(target,30,30);
if(distance<=range&&pet.hasLineOfSight(target)&&p.tickCount>=s.nextInitiative){
var result=FamiliarTechniques.begin(pet,profile.initiative(),true);
s.nextInitiative=p.tickCount+(result.equals("activated")?profile.initiative().cooldown():20);
var result=FamiliarTechniques.begin(pet,initiative,true);
s.nextInitiative=p.tickCount+(result.equals("activated")?personality.initiativeCooldown(initiative.cooldown()):20);
}
}
if(target==null&&s.evadeUntil>p.tickCount){
var threat=s.threat==null?null:p.level().getEntity(s.threat);
if(threat!=null&&threat.isAlive()&&pet.distanceToSqr(threat)<36){
var away=pet.position().subtract(threat.position()).multiply(1,0,1).normalize();
var escape=pet.position().add(away.scale(4));
destination=escape.distanceToSqr(p.position())<=100?escape:p.position();
}else{s.threat=null;s.evadeUntil=0;}
}
if(p.tickCount-s.lastRepath>=10){
s.lastRepath=p.tickCount;
if(destination.distanceToSqr(pet.position())<1)pet.getNavigation().stop();
else if(CompanionWorld.permitted(p,net.minecraft.core.BlockPos.containing(destination),"move"))
pet.getNavigation().moveTo(destination.x,destination.y+(pet.motion().flies()?1:0),destination.z,1.15);
pet.getNavigation().moveTo(destination.x,destination.y+(pet.motion().flies()?1:0),destination.z,target!=null?personality.pursuitSpeed():1.15);
}
}
private static boolean observed(FamiliarEntity pet,LivingEntity target){
@@ -36,12 +36,13 @@ public final class FamiliarDuels {
public static void start(MinecraftServer server){try{LEDGERS.put(server,new FamiliarStakes(server));}catch(Exception e){SanctuaryMod.LOGGER.error("Familiar stakes unavailable; existing journal preserved",e);}}
public static FamiliarStakes ledger(MinecraftServer server){return LEDGERS.get(server);}
public static Match match(ServerPlayer p){return MATCHES.get(p);}
public static UUID combatId(ServerPlayer p){var m=match(p);return m!=null&&m.running()?m.id:null;}
public static UUID combatId(ServerPlayer p){var m=match(p);return m!=null&&m.running()?m.id:fr.koka.sanctuary.arena.Arenas.combatId(p);}
public static boolean fighting(ServerPlayer p){return combatId(p)!=null;}
public static boolean castAllowed(FamiliarTechniques.Cast cast){var m=match(cast.owner);return Objects.equals(cast.duel,combatId(cast.owner))&&(m==null||!m.running()||m.phase==Phase.FIGHT);}
public static boolean castAllowed(FamiliarTechniques.Cast cast){var m=match(cast.owner);return fr.koka.sanctuary.arena.Arenas.castAllowed(cast)&&Objects.equals(cast.duel,combatId(cast.owner))&&(m==null||!m.running()||m.phase==Phase.FIGHT);}
public static ServerPlayer owner(LivingEntity e){return e instanceof FamiliarEntity pet?pet.owner():e instanceof ServerPlayer p?p:null;}
/** null delegates to ordinary PvE/PvP. Duels grant permission exclusively between the two familiars. */
public static Boolean enemy(ServerPlayer p,LivingEntity e){
var arena=fr.koka.sanctuary.arena.Arenas.enemy(p,e);if(arena!=null)return arena;
var m=match(p);var owner=owner(e);
if(m!=null&&m.running())return m.phase==Phase.FIGHT&&e instanceof FamiliarEntity&&owner==m.players[1-m.side(p)];
return owner!=null&&fighting(owner)?Boolean.FALSE:null;
@@ -55,10 +56,10 @@ public final class FamiliarDuels {
long now=FamiliarBattle.now();if(INVITES.getOrDefault(p,0L)>now)return "duel.wait";INVITES.put(p,now+5000);
var ledger=ledger(p.level().getServer());
if(other==null||other==p||other.level()!=p.level()||p.distanceToSqr(other)>12*12||!p.hasLineOfSight(other))return "duel.no_player";
if(match(p)!=null||match(other)!=null||!ready(p)||!ready(other)||FamiliarBattle.fighting(p)||FamiliarBattle.fighting(other))return "duel.unavailable";
if(fr.koka.sanctuary.arena.Arenas.enrolled(p)||fr.koka.sanctuary.arena.Arenas.enrolled(other)||match(p)!=null||match(other)!=null||!ready(p)||!ready(other)||FamiliarBattle.fighting(p)||FamiliarBattle.fighting(other))return "duel.unavailable";
if(ledger==null||!ledger.healthy())return "duel.storage_error";
if(ledger.pending(p)||ledger.pending(other))return "duel.pending";
var m=new Match(p,other);MATCHES.put(p,m);MATCHES.put(other,m);send(m);
var m=new Match(p,other);MATCHES.put(p,m);MATCHES.put(other,m);fr.koka.sanctuary.arena.Arenas.legacyCreated(m);send(m);
other.sendSystemMessage(FamiliarBattle.label("duel.invitation",p.getDisplayName()));return "duel.sent";
}
public static void open(ServerPlayer p){var ledger=ledger(p.level().getServer());if(ledger!=null&&match(p)==null)ledger.claim(p);FamiliarDuelNetwork.send(p,true);}
@@ -89,7 +90,7 @@ public final class FamiliarDuels {
if(wagered&&(ledger==null||!ledger.reserve(m.id,m.players[0],m.players[1],m.stakes[0],m.stakes[1]))){
m.ready[0]=m.ready[1]=false;m.revision++;for(var player:m.players)FamiliarBattle.send(player,"duel.stake_missing");send(m);return;
}
m.phase=Phase.COUNTDOWN;m.until=FamiliarBattle.now()+3000;m.center=m.players[0].position().add(m.players[1].position()).scale(.5);m.revision++;
m.phase=Phase.COUNTDOWN;m.until=FamiliarBattle.now()+3000;m.center=m.players[0].position().add(m.players[1].position()).scale(.5);m.revision++;fr.koka.sanctuary.arena.Arenas.legacyStarted(m);
for(var player:m.players){FamiliarBattle.cancel(player);FamiliarBattle.state(player).workMode=false;FamiliarBattle.combat(player);}
}
}
@@ -116,7 +117,7 @@ public final class FamiliarDuels {
boolean a=m.players[0].position().distanceToSqr(m.center)>24*24,b=m.players[1].position().distanceToSqr(m.center)>24*24;
if(a||b){finish(m,a&&b?-1:a?1:0,"left_arena");continue;}
if(m.phase==Phase.COUNTDOWN&&now>=m.until){
m.phase=Phase.FIGHT;m.until=now+180000;m.revision++;
m.phase=Phase.FIGHT;m.until=now+180000;m.revision++;fr.koka.sanctuary.arena.Arenas.legacyFight(m);
for(int i=0;i<2;i++){FamiliarBattle.engage(m.players[i],FamiliarService.active(m.players[1-i]),false);FamiliarBattle.send(m.players[i],"duel.started");}
}else if(now>=m.until){finish(m,-1,"draw");continue;}
for(var p:m.players)if(p.tickCount%20==0)FamiliarBattle.combat(p);
@@ -125,15 +126,15 @@ public final class FamiliarDuels {
if(server.getTickCount()%10==0)send(m);
}
}
public static void knockedOut(ServerPlayer p){var m=match(p);if(m!=null&&m.running())finish(m,1-m.side(p),"ko");}
public static void withdrawing(ServerPlayer p){var m=match(p);if(m!=null)finish(m,m.running()?1-m.side(p):-1,m.running()?"forfeit":"cancelled");}
public static void knockedOut(ServerPlayer p){fr.koka.sanctuary.arena.Arenas.knockedOut(p);var m=match(p);if(m!=null&&m.running())finish(m,1-m.side(p),"ko");}
public static void withdrawing(ServerPlayer p){fr.koka.sanctuary.arena.Arenas.withdraw(p);var m=match(p);if(m!=null)finish(m,m.running()?1-m.side(p):-1,m.running()?"forfeit":"cancelled");}
public static void disconnect(ServerPlayer p){var m=match(p);if(m!=null)finish(m,-1,"interrupted");INVITES.remove(p);}
public static void stop(MinecraftServer server){for(var m:new HashSet<>(MATCHES.values()))if(m.players[0].level().getServer()==server)finish(m,-1,"interrupted");LEDGERS.remove(server);}
public static void reload(MinecraftServer server){for(var m:new HashSet<>(MATCHES.values()))if(m.players[0].level().getServer()==server)finish(m,-1,"interrupted");}
public static void hit(FamiliarTechniques.Cast cast,float damage){var m=match(cast.owner);if(m!=null&&m.phase==Phase.FIGHT){int i=m.side(cast.owner);m.hits[i]++;m.damage[i]+=Math.max(0,damage);}}
public static void technique(FamiliarTechniques.Cast cast){var m=match(cast.owner);if(m!=null&&m.phase==Phase.FIGHT&&!cast.automatic)m.techniques[m.side(cast.owner)]++;}
public static void finish(Match m,int winner,String reason){
if(match(m.players[0])!=m)return;
if(match(m.players[0])!=m)return;fr.koka.sanctuary.arena.Arenas.legacyFinished(m,winner,reason);
var ledger=ledger(m.players[0].level().getServer());if(m.running()&&ledger!=null)ledger.finish(m.id,winner);
for(var p:m.players)MATCHES.remove(p);
for(int i=0;i<2;i++){
@@ -65,7 +65,7 @@ public final class FamiliarExperience {
if(!healthy||pending.has(p.getStringUUID()))return "xp.unavailable";
if(b==null||profile==null||!FamiliarBattle.active(p)||!p.isAlive()||p.isSpectator()||!b.controller().equals(p.getUUID()))return "unavailable";
if(!b.id().equals(individual)||b.storedXp()!=expected)return "xp.changed";
if(FamiliarBattle.fighting(p)||FamiliarDuels.match(p)!=null||FamiliarBattle.state(p).cast!=null)return "xp.locked";
if(fr.koka.sanctuary.arena.Arenas.enrolled(p)||FamiliarBattle.fighting(p)||FamiliarDuels.match(p)!=null||FamiliarBattle.state(p).cast!=null)return "xp.locked";
if(blocked(individual))return "xp.unavailable";
long playerBefore=playerXp(p),delta;
if(playerBefore<0||playerBefore>Integer.MAX_VALUE)return "xp.native_limit";
@@ -0,0 +1,52 @@
package fr.koka.sanctuary.familiar;
import java.util.Set;
import java.util.UUID;
import net.minecraft.network.chat.Component;
/** Frozen UUID/species roll: old eggs gain a stable combat personality without rewriting their saves. */
public enum FamiliarPersonality {
AGGRESSIVE("aggressive",1.55,.90), PROTECTIVE("protective",1.45,1),
PASSIVE("passive",1.35,1.05), PACIFIST("pacifist",1.35,1);
public enum Trigger { NEARBY_HOSTILE, GUARD_INTRUDER, OWNER_ATTACK, OWNER_HURT, PET_HURT }
private static final Set<String> FIERCE=Set.of("vindicator","pillager","evoker","ravager","piglin_brute",
"zombie","husk","drowned","skeleton","stray","bogged","parched","wither_skeleton",
"creeper","blaze","breeze","ghast","wither","ender_dragon","warden","zoglin");
private static final Set<String> GENTLE=Set.of("villager","wandering_trader","allay","cow","mooshroom",
"sheep","pig","chicken","rabbit","turtle","tadpole","horse","donkey","mule","happy_ghast");
private static final FamiliarProfile.Technique DEFENCE=new FamiliarProfile.Technique(FamiliarProfile.Action.STRIKE,
"Coup de défense","Defensive strike","Frappe au contact pour attaquer ou défendre.",
"A contact strike to attack or defend.",40,5,1.7,2,0,1,"","",1);
private final String key;
private final double pursuit, cadence;
FamiliarPersonality(String key,double pursuit,double cadence){this.key=key;this.pursuit=pursuit;this.cadence=cadence;}
public Component label(){return FamiliarBattle.label("personality."+key);}
public Component help(){return FamiliarBattle.label("personality."+key+".help");}
public double pursuitSpeed(){return pursuit;}
public int initiativeCooldown(int ticks){return Math.max(5,(int)Math.round(ticks*cadence));}
public boolean responds(Trigger trigger){
return switch(this){
case AGGRESSIVE->true;
case PROTECTIVE->trigger==Trigger.GUARD_INTRUDER||trigger==Trigger.OWNER_HURT||trigger==Trigger.PET_HURT;
case PASSIVE->trigger==Trigger.OWNER_HURT||trigger==Trigger.PET_HURT;
case PACIFIST->false;
};
}
public static FamiliarPersonality of(FamiliarBond bond){return of(bond.id(),bond.species());}
public static FamiliarPersonality of(UUID id,String species){
long roll=id.getMostSignificantBits()^Long.rotateLeft(id.getLeastSignificantBits(),23)^species.hashCode()^0x71c5a9d42e013bf6L;
roll=(roll^(roll>>>30))*0xbf58476d1ce4e5b9L;roll=(roll^(roll>>>27))*0x94d049bb133111ebL;
int value=(int)Math.floorMod(roll^(roll>>>31),100L);
String path=species.startsWith("sanctuary:")?species.substring(10):species;
// Cumulative thresholds: every species retains all four possible personalities.
int aggressive=FIERCE.contains(path)?70:GENTLE.contains(path)?5:25;
int protective=FIERCE.contains(path)?90:GENTLE.contains(path)?20:65;
int passive=FIERCE.contains(path)?97:GENTLE.contains(path)?45:90;
return value<aggressive?AGGRESSIVE:value<protective?PROTECTIVE:value<passive?PASSIVE:PACIFIST;
}
public FamiliarProfile.Technique initiative(FamiliarProfile profile,boolean explicitlyOrdered){
// Support species keep their signature, but can really strike when ordered to attack.
return (this!=PACIFIST||explicitlyOrdered)&&FamiliarTechniques.supports(profile.initiative())?DEFENCE:profile.initiative();
}
}
@@ -13,7 +13,8 @@ public final class FamiliarSupplies {
public record Reserved(ItemStack item,Runnable commit){}
private FamiliarSupplies(){}
public static Reserved reserve(ServerPlayer p,FamiliarProfile.Technique technique){
String resource=technique.resource();if(resource.isEmpty())return new Reserved(ItemStack.EMPTY,()->{});
String resource=technique.resource();
if(fr.koka.sanctuary.arena.Arenas.running(p)&&!fr.koka.sanctuary.arena.Arenas.match(p).rules.supplies()&&java.util.Set.of("food","milk","invisibility","splash_potion","offhand").contains(resource))return null;if(resource.isEmpty())return new Reserved(ItemStack.EMPTY,()->{});
if(resource.equals("offhand")){
var before=p.getOffhandItem().copy();if(before.isEmpty())return null;
int count=Math.min(before.getCount(),technique.count());
@@ -34,6 +34,7 @@ public final class FamiliarTechniques {
private static final Set<Action> ENEMY=Set.of(Action.STRIKE,Action.CHARGE,Action.ARROW,Action.SPIT,Action.SNOWBALL,Action.FIREBALL,Action.WIND,Action.SKULL,Action.BEAM,Action.FANGS,Action.PULL,Action.ROOT,Action.INTERCEPT,Action.REBOUND,Action.POTION);
private FamiliarTechniques(){}
public static Cast current(){return CURRENT.get();}
public static boolean supports(FamiliarProfile.Technique technique){return FRIENDLY.contains(technique.action())||technique.action()==Action.PICKUP;}
public static boolean ally(ServerPlayer p,LivingEntity e){
if(!FamiliarDuels.support(p,e))return false;
if(e==p)return true;
@@ -64,7 +65,9 @@ public final class FamiliarTechniques {
var reason=FamiliarActions.validate(cast);if(!reason.isEmpty())return reason;
if(FamiliarSupplies.reserve(p,technique)==null)return "need_supply";
state.cast=cast;
if(target!=null&&FamiliarBattle.enemy(p,target))FamiliarBattle.engage(p,target,false);
if(target!=null&&FamiliarBattle.enemy(p,target)){
if(automatic)FamiliarBattle.combat(p);else FamiliarBattle.engage(p,target,false);
}
pet.getNavigation().stop();pet.setAggressive(true);
if(!automatic)FamiliarBattle.send(p,"preparing");
return "activated";
@@ -0,0 +1,100 @@
package fr.koka.sanctuary.loading;
import com.google.gson.*;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.*;
import java.util.*;
import java.util.concurrent.atomic.LongAdder;
import java.util.function.Function;
import java.util.zip.*;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.resources.RegistryOps;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.storage.LevelResource;
/** Disposable, bounded geometry cache. No Java object deserialization or saved chunks. */
public final class DerivedPlanCache {
public static final Gson JSON=new GsonBuilder().serializeNulls().create();
public static final DerivedPlanCache DISABLED=new DerivedPlanCache(null,"disabled");
private static final int MAX_BYTES=32*1024*1024;
private record Envelope(String key,String checksum,String payload) {}
private final Path directory;
private final String key;
private final LongAdder hits=new LongAdder(), misses=new LongAdder(), writes=new LongAdder();
public DerivedPlanCache(Path root,String identity) { key=hash(identity);directory=root==null?null:root.resolve(key); }
public Path directory(){return directory;}
public long hits(){return hits.sum();} public long misses(){return misses.sum();} public long writes(){return writes.sum();}
public static String hash(String value){return HexFormat.of().formatHex(digest().digest(value.getBytes(StandardCharsets.UTF_8)));}
private static MessageDigest digest(){try{return MessageDigest.getInstance("SHA-256");}catch(NoSuchAlgorithmException e){throw new AssertionError(e);}}
public static DerivedPlanCache open(ServerLevel level,SanctuaryChunkGenerator generator) {
if(generator.rootGeneration()!=24)return DISABLED;
try(var phase=PreloadProgress.phase(level.getServer(),"cache_check")) {
var digest=digest();
var descriptor=ChunkGenerator.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE,level.registryAccess()),generator).getOrThrow();
update(digest,"sanctuary-derived-plans-v1");update(digest,Long.toString(level.getSeed()));
update(digest,descriptor.toString());update(digest,Boolean.toString(level.structureManager().shouldGenerateStructures()));
FabricLoader.getInstance().getAllMods().stream().map(m->m.getMetadata().getId()+"="+m.getMetadata().getVersion().getFriendlyString()).sorted().forEach(s->update(digest,s));
// A blank resource path is invalid for directory-backed packs in 26.3.
// Include every layer of tags, density/biome settings and native templates.
var resources=new TreeMap<net.minecraft.resources.Identifier,java.util.List<net.minecraft.server.packs.resources.Resource>>();
for(String prefix:List.of("worldgen","structure","tags","dimension","dimension_type"))
resources.putAll(level.getServer().getResourceManager().listResourceStacks(prefix,id->true));
if(resources.isEmpty())throw new IOException("No generation resources available for the cache identity");
for(var id:resources.keySet().stream().sorted().toList()) {
update(digest,id.toString());
for(var resource:resources.get(id))try(var in=resource.open()) {
update(digest,resource.sourcePackId());
byte[] bytes=in.readAllBytes();update(digest,Integer.toString(bytes.length));digest.update(bytes);
}
}
// Structure blocks may supply world-local template overrides outside packs.
Path generated=level.getServer().getWorldPath(LevelResource.ROOT).resolve("generated");
if(Files.isDirectory(generated))try(var files=Files.walk(generated)) {
for(Path file:files.filter(p->Files.isRegularFile(p)&&p.toString().endsWith(".nbt")).sorted().toList()) {
update(digest,generated.relativize(file).toString());update(digest,Long.toString(Files.size(file)));
try(var in=Files.newInputStream(file)){byte[] buffer=new byte[8192];int size;while((size=in.read(buffer))!=-1)digest.update(buffer,0,size);}
}
}
return new DerivedPlanCache(level.getServer().getWorldPath(LevelResource.ROOT).resolve("cache/sanctuary-plans-v1"),HexFormat.of().formatHex(digest.digest()));
}catch(Exception e){SanctuaryMod.LOGGER.warn("Plan cache unavailable; using the original planners",e);return DISABLED;}
}
private static void update(MessageDigest digest,String text){digest.update(text.getBytes(StandardCharsets.UTF_8));digest.update((byte)0);}
private Path file(String name){if(!name.matches("[a-z0-9_-]{1,100}"))throw new IllegalArgumentException("Invalid cache entry");return directory.resolve(name+".json.gz");}
public <T,R> R read(String name,Class<T> type,Function<T,R> validate) {
if(directory==null)return null;
try {
Path file=file(name);
if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS)){misses.increment();return null;}
if(Files.size(file)>MAX_BYTES)throw new IOException("Cache entry too large");
byte[] bytes;
try(var in=new GZIPInputStream(Files.newInputStream(file))){bytes=in.readNBytes(MAX_BYTES+1);}
if(bytes.length>MAX_BYTES)throw new IOException("Inflated cache entry too large");
var envelope=JSON.fromJson(new String(bytes,StandardCharsets.UTF_8),Envelope.class);
if(envelope==null||!key.equals(envelope.key())||!hash(envelope.payload()).equals(envelope.checksum()))throw new IOException("Cache identity or checksum mismatch");
R result=Objects.requireNonNull(validate.apply(JSON.fromJson(envelope.payload(),type)));
hits.increment();SanctuaryMod.LOGGER.info("Plan cache hit: {}",name);return result;
}catch(IOException|RuntimeException e){misses.increment();SanctuaryMod.LOGGER.warn("Ignoring derived plan cache {}: {}; recalculating",name,e.toString());return null;}
}
public void write(String name,Object value) {
if(directory==null)return;
Path temporary=null;
try {
String payload=JSON.toJson(value);byte[] bytes=JSON.toJson(new Envelope(key,hash(payload),payload)).getBytes(StandardCharsets.UTF_8);
if(bytes.length>MAX_BYTES)throw new IOException("Cache entry too large");
var compressed=new ByteArrayOutputStream();try(var gzip=new GZIPOutputStream(compressed)){gzip.write(bytes);}
Files.createDirectories(directory);temporary=Files.createTempFile(directory,name+"-",".tmp");
try(var channel=FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=ByteBuffer.wrap(compressed.toByteArray());while(buffer.hasRemaining())channel.write(buffer);channel.force(true);}
// Unsupported atomic replacement simply leaves caching disabled for this entry.
Files.move(temporary,file(name),StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);writes.increment();
}catch(IOException|RuntimeException e){SanctuaryMod.LOGGER.warn("Could not save optional plan cache {}: {}",name,e.toString());}
finally{if(temporary!=null)try{Files.deleteIfExists(temporary);}catch(IOException ignored){}}
}
}

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