Synchronize verified Sanctuary beta.092 sources and release documentation
Build Sanctuary / build (push) Canceled after 0s
Build Sanctuary / build (push) Canceled after 0s
This commit is contained in:
@@ -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 2014–2015 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 d’intégration optionnel historique ;
|
||||
il n’est 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 l’historique 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é.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -907,7 +907,7 @@
|
||||
"accessWidener" : "jei.accesswidener",
|
||||
|
||||
"depends": {
|
||||
+ "minecraft": "26.3-pre.2",
|
||||
+ "minecraft": "${minecraftVersion}",
|
||||
"fabricloader": "${fabricLoaderVersionRange}",
|
||||
"fabric-api": "${fabricApiVersionRange}",
|
||||
"java": ">=${modJavaVersion}"
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
+139
@@ -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&¤t.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&¤t.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&¤t.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()&¤t.phase==Arenas.Phase.OPEN&¤t.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);}
|
||||
}
|
||||
+138
@@ -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);}
|
||||
}
|
||||
+131
@@ -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);}
|
||||
}
|
||||
+66
@@ -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);}
|
||||
}
|
||||
+117
@@ -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);}
|
||||
}
|
||||
+147
@@ -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);}
|
||||
}
|
||||
+100
@@ -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);}
|
||||
}
|
||||
+96
@@ -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);}
|
||||
}
|
||||
+64
@@ -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);}
|
||||
}
|
||||
+125
@@ -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)&®ion.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);}
|
||||
}
|
||||
+80
@@ -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);}
|
||||
}
|
||||
+125
@@ -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);}
|
||||
}
|
||||
+206
@@ -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);}
|
||||
}
|
||||
+93
@@ -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);}
|
||||
}
|
||||
+164
@@ -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);}
|
||||
}
|
||||
+119
@@ -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);}
|
||||
}
|
||||
+68
@@ -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);}
|
||||
}
|
||||
+98
@@ -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);}
|
||||
}
|
||||
+90
@@ -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);}
|
||||
}
|
||||
+36
@@ -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);}
|
||||
}
|
||||
+140
@@ -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);}
|
||||
}
|
||||
+92
@@ -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);}
|
||||
}
|
||||
+4
-4
@@ -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);
|
||||
|
||||
+93
@@ -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);}
|
||||
}
|
||||
+70
@@ -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);}
|
||||
}
|
||||
+94
@@ -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);}
|
||||
}
|
||||
+129
@@ -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);}
|
||||
}
|
||||
+3
-1
@@ -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");
|
||||
|
||||
+1
-1
@@ -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))));}
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"sanctuary_gametest:vortex"
|
||||
]
|
||||
}
|
||||
+43
@@ -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
|
||||
+1
@@ -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
Reference in New Issue
Block a user